Merge branch 'main' into vincentkoc-code/slack-block-kit-interactions

This commit is contained in:
Vincent Koc
2026-03-13 17:08:05 -04:00
committed by GitHub
411 changed files with 23387 additions and 12445 deletions

View File

@@ -54,6 +54,49 @@ describe("acpx ensure", () => {
}
});
function mockEnsureInstallFlow() {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "added 1 package\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
}
function expectEnsureInstallCalls(stripProviderAuthEnvVars?: boolean) {
expect(spawnAndCollectMock.mock.calls[0]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars,
});
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
cwd: "/plugin",
stripProviderAuthEnvVars,
});
expect(spawnAndCollectMock.mock.calls[2]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars,
});
}
it("accepts the pinned acpx version", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
@@ -177,25 +220,7 @@ describe("acpx ensure", () => {
});
it("installs and verifies pinned acpx when precheck fails", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "added 1 package\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
mockEnsureInstallFlow();
await ensureAcpx({
command: "/plugin/node_modules/.bin/acpx",
@@ -204,33 +229,11 @@ describe("acpx ensure", () => {
});
expect(spawnAndCollectMock).toHaveBeenCalledTimes(3);
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
cwd: "/plugin",
});
expectEnsureInstallCalls();
});
it("threads stripProviderAuthEnvVars through version probes and install", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "added 1 package\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
mockEnsureInstallFlow();
await ensureAcpx({
command: "/plugin/node_modules/.bin/acpx",
@@ -239,24 +242,7 @@ describe("acpx ensure", () => {
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock.mock.calls[0]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock.mock.calls[2]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
expectEnsureInstallCalls(true);
});
it("fails with actionable error when npm install fails", async () => {

View File

@@ -1,9 +1,7 @@
import crypto from "node:crypto";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFeishuRuntimeMockModule } from "./monitor.test-mocks.js";
import { withRunningWebhookMonitor } from "./monitor.webhook.test-helpers.js";
const probeFeishuMock = vi.hoisted(() => vi.fn());
@@ -23,61 +21,6 @@ vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
async function getFreePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const address = server.address() as AddressInfo | null;
if (!address) {
throw new Error("missing server address");
}
await new Promise<void>((resolve) => server.close(() => resolve()));
return address.port;
}
async function waitUntilServerReady(url: string): Promise<void> {
for (let i = 0; i < 50; i += 1) {
try {
const response = await fetch(url, { method: "GET" });
if (response.status >= 200 && response.status < 500) {
return;
}
} catch {
// retry
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`server did not start: ${url}`);
}
function buildConfig(params: {
accountId: string;
path: string;
port: number;
verificationToken?: string;
encryptKey?: string;
}): ClawdbotConfig {
return {
channels: {
feishu: {
enabled: true,
accounts: {
[params.accountId]: {
enabled: true,
appId: "cli_test",
appSecret: "secret_test", // pragma: allowlist secret
connectionMode: "webhook",
webhookHost: "127.0.0.1",
webhookPort: params.port,
webhookPath: params.path,
encryptKey: params.encryptKey,
verificationToken: params.verificationToken,
},
},
},
},
} as ClawdbotConfig;
}
function signFeishuPayload(params: {
encryptKey: string;
payload: Record<string, unknown>;
@@ -107,43 +50,6 @@ function encryptFeishuPayload(encryptKey: string, payload: Record<string, unknow
return Buffer.concat([iv, encrypted]).toString("base64");
}
async function withRunningWebhookMonitor(
params: {
accountId: string;
path: string;
verificationToken: string;
encryptKey: string;
},
run: (url: string) => Promise<void>,
) {
const port = await getFreePort();
const cfg = buildConfig({
accountId: params.accountId,
path: params.path,
port,
encryptKey: params.encryptKey,
verificationToken: params.verificationToken,
});
const abortController = new AbortController();
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const monitorPromise = monitorFeishuProvider({
config: cfg,
runtime,
abortSignal: abortController.signal,
});
const url = `http://127.0.0.1:${port}${params.path}`;
await waitUntilServerReady(url);
try {
await run(url);
} finally {
abortController.abort();
await monitorPromise;
}
}
afterEach(() => {
stopFeishuMonitor();
});
@@ -159,6 +65,7 @@ describe("Feishu webhook signed-request e2e", () => {
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const payload = { type: "url_verification", challenge: "challenge-token" };
const response = await fetch(url, {
@@ -185,6 +92,7 @@ describe("Feishu webhook signed-request e2e", () => {
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const response = await fetch(url, {
method: "POST",
@@ -208,6 +116,7 @@ describe("Feishu webhook signed-request e2e", () => {
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const response = await fetch(url, {
method: "POST",
@@ -231,6 +140,7 @@ describe("Feishu webhook signed-request e2e", () => {
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const payload = { type: "url_verification", challenge: "challenge-token" };
const response = await fetch(url, {
@@ -255,6 +165,7 @@ describe("Feishu webhook signed-request e2e", () => {
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const payload = {
schema: "2.0",
@@ -283,6 +194,7 @@ describe("Feishu webhook signed-request e2e", () => {
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const payload = {
encrypt: encryptFeishuPayload("encrypt_key", {

View File

@@ -1,11 +1,13 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createFeishuClientMockModule,
createFeishuRuntimeMockModule,
} from "./monitor.test-mocks.js";
import {
buildWebhookConfig,
getFreePort,
withRunningWebhookMonitor,
} from "./monitor.webhook.test-helpers.js";
const probeFeishuMock = vi.hoisted(() => vi.fn());
@@ -33,98 +35,6 @@ import {
stopFeishuMonitor,
} from "./monitor.js";
async function getFreePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const address = server.address() as AddressInfo | null;
if (!address) {
throw new Error("missing server address");
}
await new Promise<void>((resolve) => server.close(() => resolve()));
return address.port;
}
async function waitUntilServerReady(url: string): Promise<void> {
for (let i = 0; i < 50; i += 1) {
try {
const response = await fetch(url, { method: "GET" });
if (response.status >= 200 && response.status < 500) {
return;
}
} catch {
// retry
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`server did not start: ${url}`);
}
function buildConfig(params: {
accountId: string;
path: string;
port: number;
verificationToken?: string;
encryptKey?: string;
}): ClawdbotConfig {
return {
channels: {
feishu: {
enabled: true,
accounts: {
[params.accountId]: {
enabled: true,
appId: "cli_test",
appSecret: "secret_test", // pragma: allowlist secret
connectionMode: "webhook",
webhookHost: "127.0.0.1",
webhookPort: params.port,
webhookPath: params.path,
encryptKey: params.encryptKey,
verificationToken: params.verificationToken,
},
},
},
},
} as ClawdbotConfig;
}
async function withRunningWebhookMonitor(
params: {
accountId: string;
path: string;
verificationToken: string;
encryptKey: string;
},
run: (url: string) => Promise<void>,
) {
const port = await getFreePort();
const cfg = buildConfig({
accountId: params.accountId,
path: params.path,
port,
encryptKey: params.encryptKey,
verificationToken: params.verificationToken,
});
const abortController = new AbortController();
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const monitorPromise = monitorFeishuProvider({
config: cfg,
runtime,
abortSignal: abortController.signal,
});
const url = `http://127.0.0.1:${port}${params.path}`;
await waitUntilServerReady(url);
try {
await run(url);
} finally {
abortController.abort();
await monitorPromise;
}
}
afterEach(() => {
clearFeishuWebhookRateLimitStateForTest();
stopFeishuMonitor();
@@ -134,7 +44,7 @@ describe("Feishu webhook security hardening", () => {
it("rejects webhook mode without verificationToken", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
const cfg = buildConfig({
const cfg = buildWebhookConfig({
accountId: "missing-token",
path: "/hook-missing-token",
port: await getFreePort(),
@@ -148,7 +58,7 @@ describe("Feishu webhook security hardening", () => {
it("rejects webhook mode without encryptKey", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
const cfg = buildConfig({
const cfg = buildWebhookConfig({
accountId: "missing-encrypt-key",
path: "/hook-missing-encrypt",
port: await getFreePort(),
@@ -167,6 +77,7 @@ describe("Feishu webhook security hardening", () => {
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
const response = await fetch(url, {
method: "POST",
@@ -189,6 +100,7 @@ describe("Feishu webhook security hardening", () => {
verificationToken: "verify_token",
encryptKey: "encrypt_key",
},
monitorFeishuProvider,
async (url) => {
let saw429 = false;
for (let i = 0; i < 130; i += 1) {

View File

@@ -0,0 +1,98 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { vi } from "vitest";
import type { monitorFeishuProvider } from "./monitor.js";
export async function getFreePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const address = server.address() as AddressInfo | null;
if (!address) {
throw new Error("missing server address");
}
await new Promise<void>((resolve) => server.close(() => resolve()));
return address.port;
}
async function waitUntilServerReady(url: string): Promise<void> {
for (let i = 0; i < 50; i += 1) {
try {
const response = await fetch(url, { method: "GET" });
if (response.status >= 200 && response.status < 500) {
return;
}
} catch {
// retry
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`server did not start: ${url}`);
}
export function buildWebhookConfig(params: {
accountId: string;
path: string;
port: number;
verificationToken?: string;
encryptKey?: string;
}): ClawdbotConfig {
return {
channels: {
feishu: {
enabled: true,
accounts: {
[params.accountId]: {
enabled: true,
appId: "cli_test",
appSecret: "secret_test", // pragma: allowlist secret
connectionMode: "webhook",
webhookHost: "127.0.0.1",
webhookPort: params.port,
webhookPath: params.path,
encryptKey: params.encryptKey,
verificationToken: params.verificationToken,
},
},
},
},
} as ClawdbotConfig;
}
export async function withRunningWebhookMonitor(
params: {
accountId: string;
path: string;
verificationToken: string;
encryptKey: string;
},
monitor: typeof monitorFeishuProvider,
run: (url: string) => Promise<void>,
) {
const port = await getFreePort();
const cfg = buildWebhookConfig({
accountId: params.accountId,
path: params.path,
port,
encryptKey: params.encryptKey,
verificationToken: params.verificationToken,
});
const abortController = new AbortController();
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const monitorPromise = monitor({
config: cfg,
runtime,
abortSignal: abortController.signal,
});
const url = `http://127.0.0.1:${port}${params.path}`;
await waitUntilServerReady(url);
try {
await run(url);
} finally {
abortController.abort();
await monitorPromise;
}
}

View File

@@ -7,6 +7,9 @@
"dependencies": {
"google-auth-library": "^10.6.1"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.11"
},

View File

@@ -27,6 +27,28 @@ function createMockFetch(response?: { status?: number; body?: unknown; contentTy
return { mockFetch: mockFetch as unknown as typeof fetch, calls };
}
function createTestClient(response?: { status?: number; body?: unknown; contentType?: string }) {
const { mockFetch, calls } = createMockFetch(response);
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
return { client, calls };
}
async function updatePostAndCapture(
update: Parameters<typeof updateMattermostPost>[2],
response?: { status?: number; body?: unknown; contentType?: string },
) {
const { client, calls } = createTestClient(response ?? { body: { id: "post1" } });
await updateMattermostPost(client, "post1", update);
return {
calls,
body: JSON.parse(calls[0].init?.body as string) as Record<string, unknown>,
};
}
// ── normalizeMattermostBaseUrl ────────────────────────────────────────
describe("normalizeMattermostBaseUrl", () => {
@@ -229,68 +251,38 @@ describe("createMattermostPost", () => {
describe("updateMattermostPost", () => {
it("sends PUT to /posts/{id}", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await updateMattermostPost(client, "post1", { message: "Updated" });
const { calls } = await updatePostAndCapture({ message: "Updated" });
expect(calls[0].url).toContain("/posts/post1");
expect(calls[0].init?.method).toBe("PUT");
});
it("includes post id in the body", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await updateMattermostPost(client, "post1", { message: "Updated" });
const body = JSON.parse(calls[0].init?.body as string);
const { body } = await updatePostAndCapture({ message: "Updated" });
expect(body.id).toBe("post1");
expect(body.message).toBe("Updated");
});
it("includes props for button completion updates", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await updateMattermostPost(client, "post1", {
const { body } = await updatePostAndCapture({
message: "Original message",
props: {
attachments: [{ text: "✓ **do_now** selected by @tony" }],
},
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.message).toBe("Original message");
expect(body.props.attachments[0].text).toContain("✓");
expect(body.props.attachments[0].text).toContain("do_now");
expect(body.props).toMatchObject({
attachments: [{ text: expect.stringContaining("✓") }],
});
expect(body.props).toMatchObject({
attachments: [{ text: expect.stringContaining("do_now") }],
});
});
it("omits message when not provided", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await updateMattermostPost(client, "post1", {
const { body } = await updatePostAndCapture({
props: { attachments: [] },
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.id).toBe("post1");
expect(body.message).toBeUndefined();
expect(body.props).toEqual({ attachments: [] });

View File

@@ -496,6 +496,104 @@ describe("createMattermostInteractionHandler", () => {
return res as unknown as ServerResponse & { headers: Record<string, string>; body: string };
}
function createActionContext(actionId = "approve", channelId = "chan-1") {
const context = { action_id: actionId, __openclaw_channel_id: channelId };
return { context, token: generateInteractionToken(context, "acct") };
}
function createInteractionBody(params: {
context: Record<string, unknown>;
token: string;
channelId?: string;
postId?: string;
userId?: string;
userName?: string;
}) {
return {
user_id: params.userId ?? "user-1",
...(params.userName ? { user_name: params.userName } : {}),
channel_id: params.channelId ?? "chan-1",
post_id: params.postId ?? "post-1",
context: { ...params.context, _token: params.token },
};
}
async function runHandler(
handler: ReturnType<typeof createMattermostInteractionHandler>,
params: {
body: unknown;
remoteAddress?: string;
headers?: Record<string, string>;
},
) {
const req = createReq({
remoteAddress: params.remoteAddress,
headers: params.headers,
body: params.body,
});
const res = createRes();
await handler(req, res);
return res;
}
function expectForbiddenResponse(
res: ServerResponse & { body: string },
expectedMessage: string,
) {
expect(res.statusCode).toBe(403);
expect(res.body).toContain(expectedMessage);
}
function expectSuccessfulApprovalUpdate(
res: ServerResponse & { body: string },
requestLog?: Array<{ path: string; method?: string }>,
) {
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
if (requestLog) {
expect(requestLog).toEqual([
{ path: "/posts/post-1", method: undefined },
{ path: "/posts/post-1", method: "PUT" },
]);
}
}
function createActionPost(params?: {
actionId?: string;
actionName?: string;
channelId?: string;
rootId?: string;
}): MattermostPost {
return {
id: "post-1",
channel_id: params?.channelId ?? "chan-1",
...(params?.rootId ? { root_id: params.rootId } : {}),
message: "Choose",
props: {
attachments: [
{
actions: [
{
id: params?.actionId ?? "approve",
name: params?.actionName ?? "Approve",
},
],
},
],
},
};
}
function createUnusedInteractionHandler() {
return createMattermostInteractionHandler({
client: {
request: async () => ({ message: "unused" }),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
});
}
async function runApproveInteraction(params?: {
actionName?: string;
allowedSourceIps?: string[];
@@ -503,8 +601,7 @@ describe("createMattermostInteractionHandler", () => {
remoteAddress?: string;
headers?: Record<string, string>;
}) {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext();
const requestLog: Array<{ path: string; method?: string }> = [];
const handler = createMattermostInteractionHandler({
client: {
@@ -513,15 +610,7 @@ describe("createMattermostInteractionHandler", () => {
if (init?.method === "PUT") {
return { id: "post-1" };
}
return {
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [
{ actions: [{ id: "approve", name: params?.actionName ?? "Approve" }] },
],
},
};
return createActionPost({ actionName: params?.actionName });
},
} as unknown as MattermostClient,
botUserId: "bot",
@@ -530,50 +619,27 @@ describe("createMattermostInteractionHandler", () => {
trustedProxies: params?.trustedProxies,
});
const req = createReq({
const res = await runHandler(handler, {
remoteAddress: params?.remoteAddress,
headers: params?.headers,
body: {
user_id: "user-1",
user_name: "alice",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
body: createInteractionBody({ context, token, userName: "alice" }),
});
const res = createRes();
await handler(req, res);
return { res, requestLog };
}
async function runInvalidActionRequest(actionId: string) {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext();
const handler = createMattermostInteractionHandler({
client: {
request: async () => ({
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [{ actions: [{ id: actionId, name: actionId }] }],
},
}),
request: async () => createActionPost({ actionId, actionName: actionId }),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
});
const req = createReq({
body: {
user_id: "user-1",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
return await runHandler(handler, {
body: createInteractionBody({ context, token }),
});
const res = createRes();
await handler(req, res);
return res;
}
it("accepts callback requests from an allowlisted source IP", async () => {
@@ -582,12 +648,7 @@ describe("createMattermostInteractionHandler", () => {
remoteAddress: "198.51.100.8",
});
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
expect(requestLog).toEqual([
{ path: "/posts/post-1", method: undefined },
{ path: "/posts/post-1", method: "PUT" },
]);
expectSuccessfulApprovalUpdate(res, requestLog);
});
it("accepts forwarded Mattermost source IPs from a trusted proxy", async () => {
@@ -603,8 +664,7 @@ describe("createMattermostInteractionHandler", () => {
});
it("rejects callback requests from non-allowlisted source IPs", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext();
const handler = createMattermostInteractionHandler({
client: {
request: async () => {
@@ -616,33 +676,17 @@ describe("createMattermostInteractionHandler", () => {
allowedSourceIps: ["127.0.0.1"],
});
const req = createReq({
const res = await runHandler(handler, {
remoteAddress: "198.51.100.8",
body: {
user_id: "user-1",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
body: createInteractionBody({ context, token }),
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Forbidden origin");
expectForbiddenResponse(res, "Forbidden origin");
});
it("rejects requests with an invalid interaction token", async () => {
const handler = createMattermostInteractionHandler({
client: {
request: async () => ({ message: "unused" }),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
});
const handler = createUnusedInteractionHandler();
const req = createReq({
const res = await runHandler(handler, {
body: {
user_id: "user-1",
channel_id: "chan-1",
@@ -650,72 +694,33 @@ describe("createMattermostInteractionHandler", () => {
context: { action_id: "approve", _token: "deadbeef" },
},
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Invalid token");
expectForbiddenResponse(res, "Invalid token");
});
it("rejects requests when the signed channel does not match the callback payload", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const handler = createMattermostInteractionHandler({
client: {
request: async () => ({ message: "unused" }),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
const { context, token } = createActionContext();
const handler = createUnusedInteractionHandler();
const res = await runHandler(handler, {
body: createInteractionBody({ context, token, channelId: "chan-2" }),
});
const req = createReq({
body: {
user_id: "user-1",
channel_id: "chan-2",
post_id: "post-1",
context: { ...context, _token: token },
},
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Channel mismatch");
expectForbiddenResponse(res, "Channel mismatch");
});
it("rejects requests when the fetched post does not belong to the callback channel", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext();
const handler = createMattermostInteractionHandler({
client: {
request: async () => ({
channel_id: "chan-9",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "approve", name: "Approve" }] }],
},
}),
request: async () => createActionPost({ channelId: "chan-9" }),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
});
const req = createReq({
body: {
user_id: "user-1",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
const res = await runHandler(handler, {
body: createInteractionBody({ context, token }),
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Post/channel mismatch");
expectForbiddenResponse(res, "Post/channel mismatch");
});
it("rejects requests when the action is not present on the fetched post", async () => {
@@ -730,12 +735,7 @@ describe("createMattermostInteractionHandler", () => {
actionName: "approve",
});
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
expect(requestLog).toEqual([
{ path: "/posts/post-1", method: undefined },
{ path: "/posts/post-1", method: "PUT" },
]);
expectSuccessfulApprovalUpdate(res, requestLog);
});
it("forwards fetched post threading metadata to session and button callbacks", async () => {
@@ -745,19 +745,10 @@ describe("createMattermostInteractionHandler", () => {
enqueueSystemEvent,
},
} as unknown as Parameters<typeof setMattermostRuntime>[0]);
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext();
const resolveSessionKey = vi.fn().mockResolvedValue("session:thread:root-9");
const dispatchButtonClick = vi.fn();
const fetchedPost: MattermostPost = {
id: "post-1",
channel_id: "chan-1",
root_id: "root-9",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "approve", name: "Approve" }] }],
},
};
const fetchedPost = createActionPost({ rootId: "root-9" });
const handler = createMattermostInteractionHandler({
client: {
request: async (_path: string, init?: { method?: string }) =>
@@ -769,19 +760,9 @@ describe("createMattermostInteractionHandler", () => {
dispatchButtonClick,
});
const req = createReq({
body: {
user_id: "user-1",
user_name: "alice",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
const res = await runHandler(handler, {
body: createInteractionBody({ context, token, userName: "alice" }),
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(resolveSessionKey).toHaveBeenCalledWith({
channelId: "chan-1",
@@ -803,8 +784,7 @@ describe("createMattermostInteractionHandler", () => {
});
it("lets a custom interaction handler short-circuit generic completion updates", async () => {
const context = { action_id: "mdlprov", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const { context, token } = createActionContext("mdlprov");
const requestLog: Array<{ path: string; method?: string }> = [];
const handleInteraction = vi.fn().mockResolvedValue({
ephemeral_text: "Only the original requester can use this picker.",
@@ -814,14 +794,10 @@ describe("createMattermostInteractionHandler", () => {
client: {
request: async (path: string, init?: { method?: string }) => {
requestLog.push({ path, method: init?.method });
return {
id: "post-1",
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "mdlprov", name: "Browse providers" }] }],
},
};
return createActionPost({
actionId: "mdlprov",
actionName: "Browse providers",
});
},
} as unknown as MattermostClient,
botUserId: "bot",
@@ -830,18 +806,14 @@ describe("createMattermostInteractionHandler", () => {
dispatchButtonClick,
});
const req = createReq({
body: {
user_id: "user-2",
user_name: "alice",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
const res = await runHandler(handler, {
body: createInteractionBody({
context,
token,
userId: "user-2",
userName: "alice",
}),
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toBe(

View File

@@ -16,6 +16,35 @@ const accountFixture: ResolvedMattermostAccount = {
config: {},
};
function authorizeGroupCommand(senderId: string) {
return authorizeMattermostCommandInvocation({
account: {
...accountFixture,
config: {
groupPolicy: "allowlist",
allowFrom: ["trusted-user"],
},
},
cfg: {
commands: {
useAccessGroups: true,
},
},
senderId,
senderName: senderId,
channelId: "chan-1",
channelInfo: {
id: "chan-1",
type: "O",
name: "general",
display_name: "General",
},
storeAllowFrom: [],
allowTextCommands: true,
hasControlCommand: true,
});
}
describe("mattermost monitor authz", () => {
it("keeps DM allowlist merged with pairing-store entries", () => {
const resolved = resolveMattermostEffectiveAllowFromLists({
@@ -72,32 +101,7 @@ describe("mattermost monitor authz", () => {
});
it("denies group control commands when the sender is outside the allowlist", () => {
const decision = authorizeMattermostCommandInvocation({
account: {
...accountFixture,
config: {
groupPolicy: "allowlist",
allowFrom: ["trusted-user"],
},
},
cfg: {
commands: {
useAccessGroups: true,
},
},
senderId: "attacker",
senderName: "attacker",
channelId: "chan-1",
channelInfo: {
id: "chan-1",
type: "O",
name: "general",
display_name: "General",
},
storeAllowFrom: [],
allowTextCommands: true,
hasControlCommand: true,
});
const decision = authorizeGroupCommand("attacker");
expect(decision).toMatchObject({
ok: false,
@@ -107,32 +111,7 @@ describe("mattermost monitor authz", () => {
});
it("authorizes group control commands for allowlisted senders", () => {
const decision = authorizeMattermostCommandInvocation({
account: {
...accountFixture,
config: {
groupPolicy: "allowlist",
allowFrom: ["trusted-user"],
},
},
cfg: {
commands: {
useAccessGroups: true,
},
},
senderId: "trusted-user",
senderName: "trusted-user",
channelId: "chan-1",
channelInfo: {
id: "chan-1",
type: "O",
name: "general",
display_name: "General",
},
storeAllowFrom: [],
allowTextCommands: true,
hasControlCommand: true,
});
const decision = authorizeGroupCommand("trusted-user");
expect(decision).toMatchObject({
ok: true,

View File

@@ -14,6 +14,28 @@ describe("mattermost reactions", () => {
resetMattermostReactionBotUserCacheForTests();
});
async function addReactionWithFetch(
fetchMock: ReturnType<typeof createMattermostReactionFetchMock>,
) {
return addMattermostReaction({
cfg: createMattermostTestConfig(),
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
}
async function removeReactionWithFetch(
fetchMock: ReturnType<typeof createMattermostReactionFetchMock>,
) {
return removeMattermostReaction({
cfg: createMattermostTestConfig(),
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
}
it("adds reactions by calling /users/me then POST /reactions", async () => {
const fetchMock = createMattermostReactionFetchMock({
mode: "add",
@@ -21,12 +43,7 @@ describe("mattermost reactions", () => {
emojiName: "thumbsup",
});
const result = await addMattermostReaction({
cfg: createMattermostTestConfig(),
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
const result = await addReactionWithFetch(fetchMock);
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalled();
@@ -41,12 +58,7 @@ describe("mattermost reactions", () => {
body: { id: "err", message: "boom" },
});
const result = await addMattermostReaction({
cfg: createMattermostTestConfig(),
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
const result = await addReactionWithFetch(fetchMock);
expect(result.ok).toBe(false);
if (!result.ok) {
@@ -61,12 +73,7 @@ describe("mattermost reactions", () => {
emojiName: "thumbsup",
});
const result = await removeMattermostReaction({
cfg: createMattermostTestConfig(),
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
const result = await removeReactionWithFetch(fetchMock);
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalled();

View File

@@ -10,6 +10,25 @@ import {
} from "./slash-commands.js";
describe("slash-commands", () => {
async function registerSingleStatusCommand(
request: (path: string, init?: { method?: string }) => Promise<unknown>,
) {
const client = { request } as unknown as MattermostClient;
return registerSlashCommands({
client,
teamId: "team-1",
creatorUserId: "bot-user",
callbackUrl: "http://gateway/callback",
commands: [
{
trigger: "oc_status",
description: "status",
autoComplete: true,
},
],
});
}
it("parses application/x-www-form-urlencoded payloads", () => {
const payload = parseSlashCommandPayload(
"token=t1&team_id=team&channel_id=ch1&user_id=u1&command=%2Foc_status&text=now",
@@ -101,21 +120,7 @@ describe("slash-commands", () => {
}
throw new Error(`unexpected request path: ${path}`);
});
const client = { request } as unknown as MattermostClient;
const result = await registerSlashCommands({
client,
teamId: "team-1",
creatorUserId: "bot-user",
callbackUrl: "http://gateway/callback",
commands: [
{
trigger: "oc_status",
description: "status",
autoComplete: true,
},
],
});
const result = await registerSingleStatusCommand(request);
expect(result).toHaveLength(1);
expect(result[0]?.managed).toBe(false);
@@ -144,21 +149,7 @@ describe("slash-commands", () => {
}
throw new Error(`unexpected request path: ${path}`);
});
const client = { request } as unknown as MattermostClient;
const result = await registerSlashCommands({
client,
teamId: "team-1",
creatorUserId: "bot-user",
callbackUrl: "http://gateway/callback",
commands: [
{
trigger: "oc_status",
description: "status",
autoComplete: true,
},
],
});
const result = await registerSingleStatusCommand(request);
expect(result).toHaveLength(0);
expect(request).toHaveBeenCalledTimes(1);

View File

@@ -58,6 +58,23 @@ const accountFixture: ResolvedMattermostAccount = {
config: {},
};
async function runSlashRequest(params: {
commandTokens: Set<string>;
body: string;
method?: string;
}) {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens: params.commandTokens,
});
const req = createRequest({ method: params.method, body: params.body });
const response = createResponse();
await handler(req, response.res);
return response;
}
describe("slash-http", () => {
it("rejects non-POST methods", async () => {
const handler = createSlashCommandHttpHandler({
@@ -93,36 +110,20 @@ describe("slash-http", () => {
});
it("fails closed when no command tokens are registered", async () => {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
const response = await runSlashRequest({
commandTokens: new Set<string>(),
});
const req = createRequest({
body: "token=tok1&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=",
});
const response = createResponse();
await handler(req, response.res);
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
});
it("rejects unknown command tokens", async () => {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
const response = await runSlashRequest({
commandTokens: new Set(["known-token"]),
});
const req = createRequest({
body: "token=unknown&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=",
});
const response = createResponse();
await handler(req, response.res);
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");

View File

@@ -4,6 +4,9 @@
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.11"
},

View File

@@ -78,146 +78,17 @@ An alternative register for OpenProse that draws from One Thousand and One Night
| `prompt` | `command` | What is commanded of the djinn |
| `model` | `spirit` | Which spirit answers |
### Unchanged
### Shared appendix
These keywords already work or are too functional to replace sensibly:
Use [shared-appendix.md](./shared-appendix.md) for unchanged keywords and the common comparison pattern.
- `**...**` discretion markers — already work
- `until`, `while` — already work
- `map`, `filter`, `reduce`, `pmap` — pipeline operators
- `max` — constraint modifier
- `as` — aliasing
- Model names: `sonnet`, `opus`, `haiku` — already poetic
Recommended Arabian Nights rewrite targets:
---
## Side-by-Side Comparison
### Simple Program
```prose
# Functional
use "@alice/research" as research
input topic: "What to investigate"
agent helper:
model: sonnet
let findings = session: helper
prompt: "Research {topic}"
output summary = session "Summarize"
context: findings
```
```prose
# Nights
conjure "@alice/research" as research
wish topic: "What to investigate"
djinn helper:
spirit: sonnet
name findings = tale: helper
command: "Research {topic}"
gift summary = tale "Summarize"
scroll: findings
```
### Parallel Execution
```prose
# Functional
parallel:
security = session "Check security"
perf = session "Check performance"
style = session "Check style"
session "Synthesize review"
context: { security, perf, style }
```
```prose
# Nights
bazaar:
security = tale "Check security"
perf = tale "Check performance"
style = tale "Check style"
tale "Synthesize review"
scroll: { security, perf, style }
```
### Loop with Condition
```prose
# Functional
loop until **the code is bug-free** (max: 5):
session "Find and fix bugs"
```
```prose
# Nights
telling until **the code is bug-free** (max: 5):
tale "Find and fix bugs"
```
### Error Handling
```prose
# Functional
try:
session "Risky operation"
catch as err:
session "Handle error"
context: err
finally:
session "Cleanup"
```
```prose
# Nights
venture:
tale "Risky operation"
should misfortune strike as err:
tale "Handle error"
scroll: err
and so it was:
tale "Cleanup"
```
### Choice Block
```prose
# Functional
choice **the severity level**:
option "Critical":
session "Escalate immediately"
option "Minor":
session "Log for later"
```
```prose
# Nights
crossroads **the severity level**:
path "Critical":
tale "Escalate immediately"
path "Minor":
tale "Log for later"
```
### Conditionals
```prose
# Functional
if **has security issues**:
session "Fix security"
elif **has performance issues**:
session "Optimize"
else:
session "Approve"
```
- `session` sample -> `tale`
- `parallel` sample -> `bazaar`
- `loop` sample -> `telling`
- `try/catch/finally` sample -> `venture` / `should misfortune strike` / `and so it was`
- `choice` sample -> `crossroads` / `path`
```prose
# Nights

View File

@@ -78,146 +78,17 @@ An alternative register for OpenProse that draws from Greek epic poetry—the Il
| `prompt` | `charge` | The quest given |
| `model` | `muse` | Which muse inspires |
### Unchanged
### Shared appendix
These keywords already work or are too functional to replace sensibly:
Use [shared-appendix.md](./shared-appendix.md) for unchanged keywords and the common comparison pattern.
- `**...**` discretion markers — already work
- `until`, `while` — already work
- `map`, `filter`, `reduce`, `pmap` — pipeline operators
- `max` — constraint modifier
- `as` — aliasing
- Model names: `sonnet`, `opus`, `haiku` — already poetic
Recommended Homeric rewrite targets:
---
## Side-by-Side Comparison
### Simple Program
```prose
# Functional
use "@alice/research" as research
input topic: "What to investigate"
agent helper:
model: sonnet
let findings = session: helper
prompt: "Research {topic}"
output summary = session "Summarize"
context: findings
```
```prose
# Homeric
invoke "@alice/research" as research
omen topic: "What to investigate"
hero helper:
muse: sonnet
decree findings = trial: helper
charge: "Research {topic}"
glory summary = trial "Summarize"
tidings: findings
```
### Parallel Execution
```prose
# Functional
parallel:
security = session "Check security"
perf = session "Check performance"
style = session "Check style"
session "Synthesize review"
context: { security, perf, style }
```
```prose
# Homeric
host:
security = trial "Check security"
perf = trial "Check performance"
style = trial "Check style"
trial "Synthesize review"
tidings: { security, perf, style }
```
### Loop with Condition
```prose
# Functional
loop until **the code is bug-free** (max: 5):
session "Find and fix bugs"
```
```prose
# Homeric
ordeal until **the code is bug-free** (max: 5):
trial "Find and fix bugs"
```
### Error Handling
```prose
# Functional
try:
session "Risky operation"
catch as err:
session "Handle error"
context: err
finally:
session "Cleanup"
```
```prose
# Homeric
venture:
trial "Risky operation"
should ruin come as err:
trial "Handle error"
tidings: err
in the end:
trial "Cleanup"
```
### Choice Block
```prose
# Functional
choice **the severity level**:
option "Critical":
session "Escalate immediately"
option "Minor":
session "Log for later"
```
```prose
# Homeric
crossroads **the severity level**:
path "Critical":
trial "Escalate immediately"
path "Minor":
trial "Log for later"
```
### Conditionals
```prose
# Functional
if **has security issues**:
session "Fix security"
elif **has performance issues**:
session "Optimize"
else:
session "Approve"
```
- `session` sample -> `trial`
- `parallel` sample -> `host`
- `loop` sample -> `ordeal`
- `try/catch/finally` sample -> `venture` / `should ruin come` / `in the end`
- `choice` sample -> `crossroads` / `path`
```prose
# Homeric

View File

@@ -0,0 +1,35 @@
---
role: reference
summary: Shared appendix for experimental OpenProse alternate registers.
status: draft
requires: prose.md
---
# OpenProse Alternate Register Appendix
Use this appendix with experimental register files such as `arabian-nights.md` and `homer.md`.
## Unchanged keywords
These keywords already work or are too functional to replace sensibly:
- `**...**` discretion markers
- `until`, `while`
- `map`, `filter`, `reduce`, `pmap`
- `max`
- `as`
- model names such as `sonnet`, `opus`, and `haiku`
## Comparison pattern
Use the translation map in each register file to rewrite the same functional sample programs:
- simple program
- parallel execution
- loop with condition
- error handling
- choice block
- conditionals
The goal is consistency, not one canonical wording.
Keep the functional version intact and rewrite only the register-specific aliases.

View File

@@ -87,71 +87,28 @@ The `agents` and `agent_segments` tables for project-scoped agents live in `.pro
## Responsibility Separation
This section defines **who does what**. This is the contract between the VM and subagents.
The VM/subagent contract matches [postgres.md](./postgres.md#responsibility-separation).
### VM Responsibilities
SQLite-specific differences:
The VM (the orchestrating agent running the .prose program) is responsible for:
- the VM creates `state.db` instead of an `openprose` schema
- subagent confirmation messages point at a local database path, for example `.prose/runs/<runId>/state.db`
- cleanup is typically `VACUUM` or file deletion rather than dropping schema objects
| Responsibility | Description |
| ------------------------- | -------------------------------------------------------------------------------------------------------- |
| **Database creation** | Create `state.db` and initialize core tables at run start |
| **Program registration** | Store the program source and metadata |
| **Execution tracking** | Update position, status, and timing as statements execute |
| **Subagent spawning** | Spawn sessions via Task tool with database path and instructions |
| **Parallel coordination** | Track branch status, implement join strategies |
| **Loop management** | Track iteration counts, evaluate conditions |
| **Error aggregation** | Record failures, manage retry state |
| **Context preservation** | Maintain sufficient narration in the main conversation thread so execution can be understood and resumed |
| **Completion detection** | Mark the run as complete when finished |
Example return values:
**Critical:** The VM must preserve enough context in its own conversation to understand execution state without re-reading the entire database. The database is for coordination and persistence, not a replacement for working memory.
### Subagent Responsibilities
Subagents (sessions spawned by the VM) are responsible for:
| Responsibility | Description |
| ----------------------- | ----------------------------------------------------------------- |
| **Writing own outputs** | Insert/update their binding in the `bindings` table |
| **Memory management** | For persistent agents: read and update their memory record |
| **Segment recording** | For persistent agents: append segment history |
| **Attachment handling** | Write large outputs to `attachments/` directory, store path in DB |
| **Atomic writes** | Use transactions when updating multiple related records |
**Critical:** Subagents write ONLY to `bindings`, `agents`, and `agent_segments` tables. The VM owns the `execution` table entirely. Completion signaling happens through the substrate (Task tool return), not database updates.
**Critical:** Subagents must write their outputs directly to the database. The VM does not write subagent outputs—it only reads them after the subagent completes.
**What subagents return to the VM:** A confirmation message with the binding location—not the full content:
**Root scope:**
```
```text
Binding written: research
Location: .prose/runs/20260116-143052-a7b3c9/state.db (bindings table, name='research', execution_id=NULL)
Summary: AI safety research covering alignment, robustness, and interpretability with 15 citations.
```
**Inside block invocation:**
```
```text
Binding written: result
Location: .prose/runs/20260116-143052-a7b3c9/state.db (bindings table, name='result', execution_id=43)
Execution ID: 43
Summary: Processed chunk into 3 sub-parts for recursive processing.
```
The VM tracks locations, not values. This keeps the VM's context lean and enables arbitrarily large intermediate values.
### Shared Concerns
| Concern | Who Handles |
| ---------------- | ------------------------------------------------------------------ |
| Schema evolution | Either (use `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE` as needed) |
| Custom tables | Either (prefix with `x_` for extensions) |
| Indexing | Either (add indexes for frequently-queried columns) |
| Cleanup | VM (at run end, optionally vacuum) |
The VM still tracks locations, not full values.
---

View File

@@ -45,6 +45,27 @@ describe("uploadImageFromUrl", () => {
});
}
async function setupSuccessfulUpload(params?: {
sourceUrl?: string;
contentType?: string;
uploadedUrl?: string;
}) {
const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks();
const sourceUrl = params?.sourceUrl ?? "https://example.com/image.png";
const contentType = params?.contentType ?? "image/png";
const mockBlob = new Blob(["fake-image"], { type: contentType });
mockSuccessfulFetch({
mockFetch,
blob: mockBlob,
finalUrl: sourceUrl,
contentType,
});
if (params?.uploadedUrl) {
mockUploadFile.mockResolvedValue({ url: params.uploadedUrl });
}
return { mockBlob, mockUploadFile, uploadImageFromUrl };
}
beforeEach(() => {
vi.clearAllMocks();
});
@@ -54,16 +75,9 @@ describe("uploadImageFromUrl", () => {
});
it("fetches image and calls uploadFile, returns uploaded URL", async () => {
const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks();
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
mockSuccessfulFetch({
mockFetch,
blob: mockBlob,
finalUrl: "https://example.com/image.png",
contentType: "image/png",
const { mockBlob, mockUploadFile, uploadImageFromUrl } = await setupSuccessfulUpload({
uploadedUrl: "https://memex.tlon.network/uploaded.png",
});
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" });
const result = await uploadImageFromUrl("https://example.com/image.png");
@@ -95,15 +109,7 @@ describe("uploadImageFromUrl", () => {
});
it("returns original URL if upload fails", async () => {
const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks();
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
mockSuccessfulFetch({
mockFetch,
blob: mockBlob,
finalUrl: "https://example.com/image.png",
contentType: "image/png",
});
const { mockUploadFile, uploadImageFromUrl } = await setupSuccessfulUpload();
mockUploadFile.mockRejectedValue(new Error("Upload failed"));
const result = await uploadImageFromUrl("https://example.com/image.png");

View File

@@ -89,56 +89,18 @@ Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls).
- Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
Streaming security defaults:
- `streaming.preStartTimeoutMs` closes sockets that never send a valid `start` frame.
- `streaming.maxPendingConnections` caps total unauthenticated pre-start sockets.
- `streaming.maxPendingConnectionsPerIp` caps unauthenticated pre-start sockets per source IP.
- `streaming.maxConnections` caps total open media stream sockets (pending + active).
- advanced webhook, streaming, and tunnel notes: `https://docs.openclaw.ai/plugins/voice-call`
## Stale call reaper
Use `staleCallReaperSeconds` to end calls that never receive a terminal webhook
(for example, notify-mode calls that never complete). The default is `0`
(disabled).
Recommended ranges:
- **Production:** `120``300` seconds for notify-style flows.
- Keep this value **higher than `maxDurationSeconds`** so normal calls can
finish. A good starting point is `maxDurationSeconds + 3060` seconds.
Example:
```json5
{
staleCallReaperSeconds: 360,
}
```
See the plugin docs for recommended ranges and production examples:
`https://docs.openclaw.ai/plugins/voice-call#stale-call-reaper`
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
streaming speech on calls. You can override it under the plugin config with the
same shape — overrides deep-merge with `messages.tts`.
```json5
{
tts: {
provider: "openai",
openai: {
voice: "alloy",
},
},
}
```
Notes:
- Edge TTS is ignored for voice calls (telephony audio needs PCM; Edge output is unreliable).
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
streaming speech on calls. Override examples and provider caveats live here:
`https://docs.openclaw.ai/plugins/voice-call#tts-for-calls`
## CLI

View File

@@ -9,121 +9,87 @@ import {
} from "./manager.test-harness.js";
describe("CallManager verification on restore", () => {
it("skips stale calls reported terminal by provider", async () => {
async function initializeManager(params?: {
callOverrides?: Parameters<typeof makePersistedCall>[0];
providerResult?: FakeProvider["getCallStatusResult"];
configureProvider?: (provider: FakeProvider) => void;
configOverrides?: Partial<{ maxDurationSeconds: number }>;
}) {
const storePath = createTestStorePath();
const call = makePersistedCall();
const call = makePersistedCall(params?.callOverrides);
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
provider.getCallStatusResult = { status: "completed", isTerminal: true };
if (params?.providerResult) {
provider.getCallStatusResult = params.providerResult;
}
params?.configureProvider?.(provider);
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
...params?.configOverrides,
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
return { call, manager };
}
it("skips stale calls reported terminal by provider", async () => {
const { manager } = await initializeManager({
providerResult: { status: "completed", isTerminal: true },
});
expect(manager.getActiveCalls()).toHaveLength(0);
});
it("keeps calls reported active by provider", async () => {
const storePath = createTestStorePath();
const call = makePersistedCall();
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
provider.getCallStatusResult = { status: "in-progress", isTerminal: false };
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { call, manager } = await initializeManager({
providerResult: { status: "in-progress", isTerminal: false },
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(manager.getActiveCalls()).toHaveLength(1);
expect(manager.getActiveCalls()[0]?.callId).toBe(call.callId);
});
it("keeps calls when provider returns unknown (transient error)", async () => {
const storePath = createTestStorePath();
const call = makePersistedCall();
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
provider.getCallStatusResult = { status: "error", isTerminal: false, isUnknown: true };
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager } = await initializeManager({
providerResult: { status: "error", isTerminal: false, isUnknown: true },
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(manager.getActiveCalls()).toHaveLength(1);
});
it("skips calls older than maxDurationSeconds", async () => {
const storePath = createTestStorePath();
const call = makePersistedCall({
startedAt: Date.now() - 600_000,
answeredAt: Date.now() - 590_000,
const { manager } = await initializeManager({
callOverrides: {
startedAt: Date.now() - 600_000,
answeredAt: Date.now() - 590_000,
},
configOverrides: { maxDurationSeconds: 300 },
});
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
maxDurationSeconds: 300,
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(manager.getActiveCalls()).toHaveLength(0);
});
it("skips calls without providerCallId", async () => {
const storePath = createTestStorePath();
const call = makePersistedCall({ providerCallId: undefined, state: "initiated" });
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager } = await initializeManager({
callOverrides: { providerCallId: undefined, state: "initiated" },
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(manager.getActiveCalls()).toHaveLength(0);
});
it("keeps call when getCallStatus throws (verification failure)", async () => {
const storePath = createTestStorePath();
const call = makePersistedCall();
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
provider.getCallStatus = async () => {
throw new Error("network failure");
};
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager } = await initializeManager({
configureProvider: (provider) => {
provider.getCallStatus = async () => {
throw new Error("network failure");
};
},
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(manager.getActiveCalls()).toHaveLength(1);
});

View File

@@ -21,6 +21,12 @@ function createContext(rawBody: string, query?: WebhookContext["query"]): Webhoo
};
}
function expectStreamingTwiml(body: string) {
expect(body).toContain(STREAM_URL);
expect(body).toContain('<Parameter name="token" value="');
expect(body).toContain("<Connect>");
}
describe("TwilioProvider", () => {
it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
const provider = createProvider();
@@ -30,9 +36,8 @@ describe("TwilioProvider", () => {
const result = provider.parseWebhookEvent(ctx);
expect(result.providerResponseBody).toContain(STREAM_URL);
expect(result.providerResponseBody).toContain('<Parameter name="token" value="');
expect(result.providerResponseBody).toContain("<Connect>");
expect(result.providerResponseBody).toBeDefined();
expectStreamingTwiml(result.providerResponseBody ?? "");
});
it("returns empty TwiML for status callbacks", () => {
@@ -55,9 +60,8 @@ describe("TwilioProvider", () => {
const result = provider.parseWebhookEvent(ctx);
expect(result.providerResponseBody).toContain(STREAM_URL);
expect(result.providerResponseBody).toContain('<Parameter name="token" value="');
expect(result.providerResponseBody).toContain("<Connect>");
expect(result.providerResponseBody).toBeDefined();
expectStreamingTwiml(result.providerResponseBody ?? "");
});
it("returns queue TwiML for second inbound call when first call is active", () => {

View File

@@ -32,6 +32,41 @@ async function waitForPollingLoopStart(): Promise<void> {
await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
}
const TEST_ACCOUNT = {
accountId: "default",
config: {},
} as unknown as ResolvedZaloAccount;
const TEST_CONFIG = {} as OpenClawConfig;
function createLifecycleRuntime() {
return {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
};
}
async function startLifecycleMonitor(
options: {
useWebhook?: boolean;
webhookSecret?: string;
webhookUrl?: string;
} = {},
) {
const { monitorZaloProvider } = await import("./monitor.js");
const abort = new AbortController();
const runtime = createLifecycleRuntime();
const run = monitorZaloProvider({
token: "test-token",
account: TEST_ACCOUNT,
config: TEST_CONFIG,
runtime,
abortSignal: abort.signal,
...options,
});
return { abort, runtime, run };
}
describe("monitorZaloProvider lifecycle", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -39,26 +74,9 @@ describe("monitorZaloProvider lifecycle", () => {
});
it("stays alive in polling mode until abort", async () => {
const { monitorZaloProvider } = await import("./monitor.js");
const abort = new AbortController();
const runtime = {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
};
const account = {
accountId: "default",
config: {},
} as unknown as ResolvedZaloAccount;
const config = {} as OpenClawConfig;
let settled = false;
const run = monitorZaloProvider({
token: "test-token",
account,
config,
runtime,
abortSignal: abort.signal,
}).then(() => {
const { abort, runtime, run } = await startLifecycleMonitor();
const monitoredRun = run.then(() => {
settled = true;
});
@@ -70,7 +88,7 @@ describe("monitorZaloProvider lifecycle", () => {
expect(settled).toBe(false);
abort.abort();
await run;
await monitoredRun;
expect(settled).toBe(true);
expect(runtime.log).toHaveBeenCalledWith(
@@ -84,25 +102,7 @@ describe("monitorZaloProvider lifecycle", () => {
result: { url: "https://example.com/hooks/zalo" },
});
const { monitorZaloProvider } = await import("./monitor.js");
const abort = new AbortController();
const runtime = {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
};
const account = {
accountId: "default",
config: {},
} as unknown as ResolvedZaloAccount;
const config = {} as OpenClawConfig;
const run = monitorZaloProvider({
token: "test-token",
account,
config,
runtime,
abortSignal: abort.signal,
});
const { abort, runtime, run } = await startLifecycleMonitor();
await waitForPollingLoopStart();
@@ -120,25 +120,7 @@ describe("monitorZaloProvider lifecycle", () => {
const { ZaloApiError } = await import("./api.js");
getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
const { monitorZaloProvider } = await import("./monitor.js");
const abort = new AbortController();
const runtime = {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
};
const account = {
accountId: "default",
config: {},
} as unknown as ResolvedZaloAccount;
const config = {} as OpenClawConfig;
const run = monitorZaloProvider({
token: "test-token",
account,
config,
runtime,
abortSignal: abort.signal,
});
const { abort, runtime, run } = await startLifecycleMonitor();
await waitForPollingLoopStart();
@@ -165,29 +147,13 @@ describe("monitorZaloProvider lifecycle", () => {
}),
);
const { monitorZaloProvider } = await import("./monitor.js");
const abort = new AbortController();
const runtime = {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
};
const account = {
accountId: "default",
config: {},
} as unknown as ResolvedZaloAccount;
const config = {} as OpenClawConfig;
let settled = false;
const run = monitorZaloProvider({
token: "test-token",
account,
config,
runtime,
abortSignal: abort.signal,
const { abort, runtime, run } = await startLifecycleMonitor({
useWebhook: true,
webhookUrl: "https://example.com/hooks/zalo",
webhookSecret: "supersecret", // pragma: allowlist secret
}).then(() => {
});
const monitoredRun = run.then(() => {
settled = true;
});
@@ -202,7 +168,7 @@ describe("monitorZaloProvider lifecycle", () => {
expect(registry.httpRoutes).toHaveLength(1);
resolveDeleteWebhook?.();
await run;
await monitoredRun;
expect(settled).toBe(true);
expect(registry.httpRoutes).toHaveLength(0);

View File

@@ -187,6 +187,31 @@ function installRuntime(params: {
};
}
function installGroupCommandAuthRuntime() {
return installRuntime({
resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
});
}
async function processGroupControlCommand(params: {
account: ResolvedZalouserAccount;
content?: string;
commandContent?: string;
}) {
await __testing.processMessage({
message: createGroupMessage({
content: params.content ?? "/new",
commandContent: params.commandContent ?? "/new",
hasAnyMention: true,
wasExplicitlyMentioned: true,
}),
account: params.account,
config: createConfig(),
runtime: createRuntimeEnv(),
});
}
function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
return {
threadId: "g-1",
@@ -229,57 +254,152 @@ describe("zalouser monitor group mention gating", () => {
sendSeenZalouserMock.mockClear();
});
it("skips unmentioned group messages when requireMention=true", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
async function processMessageWithDefaults(params: {
message: ZaloInboundMessage;
account?: ResolvedZalouserAccount;
historyState?: {
historyLimit: number;
groupHistories: Map<
string,
Array<{ sender: string; body: string; timestamp?: number; messageId?: string }>
>;
};
}) {
await __testing.processMessage({
message: createGroupMessage(),
account: createAccount(),
message: params.message,
account: params.account ?? createAccount(),
config: createConfig(),
runtime: createRuntimeEnv(),
historyState: params.historyState,
});
}
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(sendTypingZalouserMock).not.toHaveBeenCalled();
});
it("fails closed when requireMention=true but mention detection is unavailable", async () => {
async function expectSkippedGroupMessage(message?: Partial<ZaloInboundMessage>) {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await __testing.processMessage({
message: createGroupMessage({
canResolveExplicitMention: false,
hasAnyMention: false,
wasExplicitlyMentioned: false,
}),
account: createAccount(),
config: createConfig(),
runtime: createRuntimeEnv(),
await processMessageWithDefaults({
message: createGroupMessage(message),
});
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(sendTypingZalouserMock).not.toHaveBeenCalled();
});
}
it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
async function expectGroupCommandAuthorizers(params: {
accountConfig: ResolvedZalouserAccount["config"];
expectedAuthorizers: Array<{ configured: boolean; allowed: boolean }>;
}) {
const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
installGroupCommandAuthRuntime();
await processGroupControlCommand({
account: {
...createAccount(),
config: params.accountConfig,
},
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
expect(authCall?.authorizers).toEqual(params.expectedAuthorizers);
}
async function processOpenDmMessage(params?: {
message?: Partial<ZaloInboundMessage>;
readSessionUpdatedAt?: (input?: {
storePath: string;
sessionKey: string;
}) => number | undefined;
}) {
const runtime = installRuntime({
commandAuthorized: false,
});
if (params?.readSessionUpdatedAt) {
runtime.readSessionUpdatedAt.mockImplementation(params.readSessionUpdatedAt);
}
const account = createAccount();
await processMessageWithDefaults({
message: createDmMessage(params?.message),
account: {
...account,
config: {
...account.config,
dmPolicy: "open",
},
},
});
return runtime;
}
async function expectDangerousNameMatching(params: {
dangerouslyAllowNameMatching?: boolean;
expectedDispatches: number;
}) {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await __testing.processMessage({
await processMessageWithDefaults({
message: createGroupMessage({
threadId: "g-attacker-001",
groupName: "Trusted Team",
senderId: "666",
hasAnyMention: true,
wasExplicitlyMentioned: true,
content: "ping @bot",
}),
account: createAccount(),
config: createConfig(),
runtime: createRuntimeEnv(),
account: {
...createAccount(),
config: {
...createAccount().config,
...(params.dangerouslyAllowNameMatching ? { dangerouslyAllowNameMatching: true } : {}),
groupPolicy: "allowlist",
groupAllowFrom: ["*"],
groups: {
"group:g-trusted-001": { allow: true },
"Trusted Team": { allow: true },
},
},
},
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(
params.expectedDispatches,
);
return dispatchReplyWithBufferedBlockDispatcher;
}
async function dispatchGroupMessage(params: {
commandAuthorized: boolean;
message: Partial<ZaloInboundMessage>;
}) {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: params.commandAuthorized,
});
await processMessageWithDefaults({
message: createGroupMessage(params.message),
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
return dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
}
it("skips unmentioned group messages when requireMention=true", async () => {
await expectSkippedGroupMessage();
});
it("fails closed when requireMention=true but mention detection is unavailable", async () => {
await expectSkippedGroupMessage({
canResolveExplicitMention: false,
hasAnyMention: false,
wasExplicitlyMentioned: false,
});
});
it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
const callArg = await dispatchGroupMessage({
commandAuthorized: false,
message: {
hasAnyMention: true,
wasExplicitlyMentioned: true,
content: "ping @bot",
},
});
expect(callArg?.ctx?.WasMentioned).toBe(true);
expect(callArg?.ctx?.To).toBe("zalouser:group:g-1");
expect(callArg?.ctx?.OriginatingTo).toBe("zalouser:group:g-1");
@@ -290,22 +410,14 @@ describe("zalouser monitor group mention gating", () => {
});
it("allows authorized control commands to bypass mention gating", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
const callArg = await dispatchGroupMessage({
commandAuthorized: true,
});
await __testing.processMessage({
message: createGroupMessage({
message: {
content: "/status",
hasAnyMention: false,
wasExplicitlyMentioned: false,
}),
account: createAccount(),
config: createConfig(),
runtime: createRuntimeEnv(),
},
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
expect(callArg?.ctx?.WasMentioned).toBe(true);
});
@@ -346,57 +458,30 @@ describe("zalouser monitor group mention gating", () => {
});
it("uses commandContent for mention-prefixed control commands", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
const callArg = await dispatchGroupMessage({
commandAuthorized: true,
});
await __testing.processMessage({
message: createGroupMessage({
message: {
content: "@Bot /new",
commandContent: "/new",
hasAnyMention: true,
wasExplicitlyMentioned: true,
}),
account: createAccount(),
config: createConfig(),
runtime: createRuntimeEnv(),
},
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
expect(callArg?.ctx?.CommandBody).toBe("/new");
expect(callArg?.ctx?.BodyForCommands).toBe("/new");
});
it("allows group control commands when only allowFrom is configured", async () => {
const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
installRuntime({
resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
});
await __testing.processMessage({
message: createGroupMessage({
content: "/new",
commandContent: "/new",
hasAnyMention: true,
wasExplicitlyMentioned: true,
}),
account: {
...createAccount(),
config: {
...createAccount().config,
allowFrom: ["123"],
},
await expectGroupCommandAuthorizers({
accountConfig: {
...createAccount().config,
allowFrom: ["123"],
},
config: createConfig(),
runtime: createRuntimeEnv(),
expectedAuthorizers: [
{ configured: true, allowed: true },
{ configured: true, allowed: true },
],
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
expect(authCall?.authorizers).toEqual([
{ configured: true, allowed: true },
{ configured: true, allowed: true },
]);
});
it("blocks group messages when sender is not in groupAllowFrom/allowFrom", async () => {
@@ -425,123 +510,35 @@ describe("zalouser monitor group mention gating", () => {
});
it("does not accept a different group id by matching only the mutable group name by default", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await __testing.processMessage({
message: createGroupMessage({
threadId: "g-attacker-001",
groupName: "Trusted Team",
senderId: "666",
hasAnyMention: true,
wasExplicitlyMentioned: true,
content: "ping @bot",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
groupPolicy: "allowlist",
groupAllowFrom: ["*"],
groups: {
"group:g-trusted-001": { allow: true },
"Trusted Team": { allow: true },
},
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
await expectDangerousNameMatching({ expectedDispatches: 0 });
});
it("accepts mutable group-name matches only when dangerouslyAllowNameMatching is enabled", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
const dispatchReplyWithBufferedBlockDispatcher = await expectDangerousNameMatching({
dangerouslyAllowNameMatching: true,
expectedDispatches: 1,
});
await __testing.processMessage({
message: createGroupMessage({
threadId: "g-attacker-001",
groupName: "Trusted Team",
senderId: "666",
hasAnyMention: true,
wasExplicitlyMentioned: true,
content: "ping @bot",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
dangerouslyAllowNameMatching: true,
groupPolicy: "allowlist",
groupAllowFrom: ["*"],
groups: {
"group:g-trusted-001": { allow: true },
"Trusted Team": { allow: true },
},
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001");
});
it("allows group control commands when sender is in groupAllowFrom", async () => {
const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
installRuntime({
resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
});
await __testing.processMessage({
message: createGroupMessage({
content: "/new",
commandContent: "/new",
hasAnyMention: true,
wasExplicitlyMentioned: true,
}),
account: {
...createAccount(),
config: {
...createAccount().config,
allowFrom: ["999"],
groupAllowFrom: ["123"],
},
await expectGroupCommandAuthorizers({
accountConfig: {
...createAccount().config,
allowFrom: ["999"],
groupAllowFrom: ["123"],
},
config: createConfig(),
runtime: createRuntimeEnv(),
expectedAuthorizers: [
{ configured: true, allowed: false },
{ configured: true, allowed: true },
],
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
expect(authCall?.authorizers).toEqual([
{ configured: true, allowed: false },
{ configured: true, allowed: true },
]);
});
it("routes DM messages with direct peer kind", async () => {
const { dispatchReplyWithBufferedBlockDispatcher, resolveAgentRoute, buildAgentSessionKey } =
installRuntime({
commandAuthorized: false,
});
const account = createAccount();
await __testing.processMessage({
message: createDmMessage(),
account: {
...account,
config: {
...account.config,
dmPolicy: "open",
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
await processOpenDmMessage();
expect(resolveAgentRoute).toHaveBeenCalledWith(
expect.objectContaining({
@@ -559,24 +556,9 @@ describe("zalouser monitor group mention gating", () => {
});
it("reuses the legacy DM session key when only the old group-shaped session exists", async () => {
const { dispatchReplyWithBufferedBlockDispatcher, readSessionUpdatedAt } = installRuntime({
commandAuthorized: false,
});
readSessionUpdatedAt.mockImplementation((input?: { storePath: string; sessionKey: string }) =>
input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined,
);
const account = createAccount();
await __testing.processMessage({
message: createDmMessage(),
account: {
...account,
config: {
...account.config,
dmPolicy: "open",
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
const { dispatchReplyWithBufferedBlockDispatcher } = await processOpenDmMessage({
readSessionUpdatedAt: (input?: { storePath: string; sessionKey: string }) =>
input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined,
});
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];