mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 23:47:27 +00:00
Merge branch 'main' into vincentkoc-code/slack-block-kit-interactions
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
98
extensions/feishu/src/monitor.webhook.test-helpers.ts
Normal file
98
extensions/feishu/src/monitor.webhook.test-helpers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.11"
|
||||
},
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"private": true,
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.11"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
35
extensions/open-prose/skills/prose/alts/shared-appendix.md
Normal file
35
extensions/open-prose/skills/prose/alts/shared-appendix.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 + 30–60` 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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user