diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 74ea0b75983..fbaa5ce39fc 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -2,6 +2,7 @@ import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "open import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildProbeChannelStatusSummary, collectBlueBubblesStatusIssues, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, @@ -356,16 +357,8 @@ export const bluebubblesPlugin: ChannelPlugin = { lastError: null, }, collectStatusIssues: collectBlueBubblesStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - baseUrl: snapshot.baseUrl ?? null, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }), probeAccount: async ({ account, timeoutMs }) => probeBlueBubbles({ baseUrl: account.baseUrl, diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index b136de3095c..11d8faf1f76 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -2,6 +2,7 @@ import { isAllowedParsedChatSender, parseChatAllowTargetPrefixes, parseChatTargetPrefixesOrThrow, + type ParsedChatTarget, resolveServicePrefixedAllowTarget, resolveServicePrefixedTarget, } from "openclaw/plugin-sdk"; @@ -14,11 +15,7 @@ export type BlueBubblesTarget = | { kind: "chat_identifier"; chatIdentifier: string } | { kind: "handle"; to: string; service: BlueBubblesService }; -export type BlueBubblesAllowTarget = - | { kind: "chat_id"; chatId: number } - | { kind: "chat_guid"; chatGuid: string } - | { kind: "chat_identifier"; chatIdentifier: string } - | { kind: "handle"; handle: string }; +export type BlueBubblesAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string }; const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"]; const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"]; diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 593a277dba5..1ec3e1a67cc 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -41,14 +41,7 @@ describe("diffs tool", () => { it("returns an image artifact in image mode", async () => { const cleanupSpy = vi.spyOn(store, "scheduleCleanup"); - const screenshotter = { - screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => { - expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, Buffer.from("png")); - return outputPath; - }), - }; + const screenshotter = createScreenshotter(); const tool = createDiffsTool({ api: createApi(), @@ -178,14 +171,7 @@ describe("diffs tool", () => { }); it("prefers explicit tool params over configured defaults", async () => { - const screenshotter = { - screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => { - expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, Buffer.from("png")); - return outputPath; - }), - }; + const screenshotter = createScreenshotter(); const tool = createDiffsTool({ api: createApi(), store, @@ -256,3 +242,14 @@ function readTextContent(result: unknown, index: number): string { const entry = content?.[index]; return entry?.type === "text" ? (entry.text ?? "") : ""; } + +function createScreenshotter() { + return { + screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => { + expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, Buffer.from("png")); + return outputPath; + }), + }; +} diff --git a/extensions/feishu/src/doc-schema.ts b/extensions/feishu/src/doc-schema.ts index e2c0a56f23c..ab657065a69 100644 --- a/extensions/feishu/src/doc-schema.ts +++ b/extensions/feishu/src/doc-schema.ts @@ -1,5 +1,19 @@ import { Type, type Static } from "@sinclair/typebox"; +const tableCreationProperties = { + doc_token: Type.String({ description: "Document token" }), + parent_block_id: Type.Optional( + Type.String({ description: "Parent block ID (default: document root)" }), + ), + row_size: Type.Integer({ description: "Table row count", minimum: 1 }), + column_size: Type.Integer({ description: "Table column count", minimum: 1 }), + column_width: Type.Optional( + Type.Array(Type.Number({ minimum: 1 }), { + description: "Column widths in px (length should match column_size)", + }), + ), +}; + export const FeishuDocSchema = Type.Union([ Type.Object({ action: Type.Literal("read"), @@ -59,17 +73,7 @@ export const FeishuDocSchema = Type.Union([ // Table creation (explicit structure) Type.Object({ action: Type.Literal("create_table"), - doc_token: Type.String({ description: "Document token" }), - parent_block_id: Type.Optional( - Type.String({ description: "Parent block ID (default: document root)" }), - ), - row_size: Type.Integer({ description: "Table row count", minimum: 1 }), - column_size: Type.Integer({ description: "Table column count", minimum: 1 }), - column_width: Type.Optional( - Type.Array(Type.Number({ minimum: 1 }), { - description: "Column widths in px (length should match column_size)", - }), - ), + ...tableCreationProperties, }), Type.Object({ action: Type.Literal("write_table_cells"), @@ -82,17 +86,7 @@ export const FeishuDocSchema = Type.Union([ }), Type.Object({ action: Type.Literal("create_table_with_values"), - doc_token: Type.String({ description: "Document token" }), - parent_block_id: Type.Optional( - Type.String({ description: "Parent block ID (default: document root)" }), - ), - row_size: Type.Integer({ description: "Table row count", minimum: 1 }), - column_size: Type.Integer({ description: "Table column count", minimum: 1 }), - column_width: Type.Optional( - Type.Array(Type.Number({ minimum: 1 }), { - description: "Column widths in px (length should match column_size)", - }), - ), + ...tableCreationProperties, values: Type.Array(Type.Array(Type.String()), { description: "2D matrix values[row][col] to write into table cells", minItems: 1, diff --git a/extensions/feishu/src/docx.account-selection.test.ts b/extensions/feishu/src/docx.account-selection.test.ts index 6471192b6fe..562f5cbe45b 100644 --- a/extensions/feishu/src/docx.account-selection.test.ts +++ b/extensions/feishu/src/docx.account-selection.test.ts @@ -21,8 +21,8 @@ vi.mock("@larksuiteoapi/node-sdk", () => { }); describe("feishu_doc account selection", () => { - test("uses agentAccountId context when params omit accountId", async () => { - const cfg = { + function createDocEnabledConfig(): OpenClawPluginApi["config"] { + return { channels: { feishu: { enabled: true, @@ -33,6 +33,10 @@ describe("feishu_doc account selection", () => { }, }, } as OpenClawPluginApi["config"]; + } + + test("uses agentAccountId context when params omit accountId", async () => { + const cfg = createDocEnabledConfig(); const { api, resolveTool } = createToolFactoryHarness(cfg); registerFeishuDocTools(api); @@ -49,17 +53,7 @@ describe("feishu_doc account selection", () => { }); test("explicit accountId param overrides agentAccountId context", async () => { - const cfg = { - channels: { - feishu: { - enabled: true, - accounts: { - a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, - b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, - }, - }, - }, - } as OpenClawPluginApi["config"]; + const cfg = createDocEnabledConfig(); const { api, resolveTool } = createToolFactoryHarness(cfg); registerFeishuDocTools(api); diff --git a/extensions/feishu/src/docx.test.ts b/extensions/feishu/src/docx.test.ts index 665f4309a52..99139c2cc01 100644 --- a/extensions/feishu/src/docx.test.ts +++ b/extensions/feishu/src/docx.test.ts @@ -114,6 +114,29 @@ describe("feishu_doc image fetch hardening", () => { scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } }); }); + function resolveFeishuDocTool(context: Record = {}) { + const registerTool = vi.fn(); + registerFeishuDocTools({ + config: { + channels: { + feishu: { + appId: "app_id", + appSecret: "app_secret", + }, + }, + } as any, + logger: { debug: vi.fn(), info: vi.fn() } as any, + registerTool, + } as any); + + const tool = registerTool.mock.calls + .map((call) => call[0]) + .map((candidate) => (typeof candidate === "function" ? candidate(context) : candidate)) + .find((candidate) => candidate.name === "feishu_doc"); + expect(tool).toBeDefined(); + return tool as { execute: (callId: string, params: Record) => Promise }; + } + it("inserts blocks sequentially to preserve document order", async () => { const blocks = [ { block_type: 3, block_id: "h1" }, @@ -135,22 +158,7 @@ describe("feishu_doc image fetch hardening", () => { data: { children: [{ block_type: 3, block_id: "h1" }] }, }); - const registerTool = vi.fn(); - registerFeishuDocTools({ - config: { - channels: { - feishu: { appId: "app_id", appSecret: "app_secret" }, - }, - } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); - - const feishuDocTool = registerTool.mock.calls - .map((call) => call[0]) - .map((tool) => (typeof tool === "function" ? tool({}) : tool)) - .find((tool) => tool.name === "feishu_doc"); - expect(feishuDocTool).toBeDefined(); + const feishuDocTool = resolveFeishuDocTool(); const result = await feishuDocTool.execute("tool-call", { action: "append", @@ -194,22 +202,7 @@ describe("feishu_doc image fetch hardening", () => { }, })); - const registerTool = vi.fn(); - registerFeishuDocTools({ - config: { - channels: { - feishu: { appId: "app_id", appSecret: "app_secret" }, - }, - } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); - - const feishuDocTool = registerTool.mock.calls - .map((call) => call[0]) - .map((tool) => (typeof tool === "function" ? tool({}) : tool)) - .find((tool) => tool.name === "feishu_doc"); - expect(feishuDocTool).toBeDefined(); + const feishuDocTool = resolveFeishuDocTool(); const longMarkdown = Array.from( { length: 120 }, @@ -254,22 +247,7 @@ describe("feishu_doc image fetch hardening", () => { data: { children: data.children }, })); - const registerTool = vi.fn(); - registerFeishuDocTools({ - config: { - channels: { - feishu: { appId: "app_id", appSecret: "app_secret" }, - }, - } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); - - const feishuDocTool = registerTool.mock.calls - .map((call) => call[0]) - .map((tool) => (typeof tool === "function" ? tool({}) : tool)) - .find((tool) => tool.name === "feishu_doc"); - expect(feishuDocTool).toBeDefined(); + const feishuDocTool = resolveFeishuDocTool(); const fencedMarkdown = [ "## Section", @@ -306,25 +284,7 @@ describe("feishu_doc image fetch hardening", () => { new Error("Blocked: resolves to private/internal IP address"), ); - const registerTool = vi.fn(); - registerFeishuDocTools({ - config: { - channels: { - feishu: { - appId: "app_id", - appSecret: "app_secret", - }, - }, - } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); - - const feishuDocTool = registerTool.mock.calls - .map((call) => call[0]) - .map((tool) => (typeof tool === "function" ? tool({}) : tool)) - .find((tool) => tool.name === "feishu_doc"); - expect(feishuDocTool).toBeDefined(); + const feishuDocTool = resolveFeishuDocTool(); const result = await feishuDocTool.execute("tool-call", { action: "write", @@ -341,29 +301,10 @@ describe("feishu_doc image fetch hardening", () => { }); it("create grants permission only to trusted Feishu requester", async () => { - const registerTool = vi.fn(); - registerFeishuDocTools({ - config: { - channels: { - feishu: { - appId: "app_id", - appSecret: "app_secret", - }, - }, - } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); - - const feishuDocTool = registerTool.mock.calls - .map((call) => call[0]) - .map((tool) => - typeof tool === "function" - ? tool({ messageChannel: "feishu", requesterSenderId: "ou_123" }) - : tool, - ) - .find((tool) => tool.name === "feishu_doc"); - expect(feishuDocTool).toBeDefined(); + const feishuDocTool = resolveFeishuDocTool({ + messageChannel: "feishu", + requesterSenderId: "ou_123", + }); const result = await feishuDocTool.execute("tool-call", { action: "create", @@ -386,25 +327,9 @@ describe("feishu_doc image fetch hardening", () => { }); it("create skips requester grant when trusted requester identity is unavailable", async () => { - const registerTool = vi.fn(); - registerFeishuDocTools({ - config: { - channels: { - feishu: { - appId: "app_id", - appSecret: "app_secret", - }, - }, - } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); - - const feishuDocTool = registerTool.mock.calls - .map((call) => call[0]) - .map((tool) => (typeof tool === "function" ? tool({ messageChannel: "feishu" }) : tool)) - .find((tool) => tool.name === "feishu_doc"); - expect(feishuDocTool).toBeDefined(); + const feishuDocTool = resolveFeishuDocTool({ + messageChannel: "feishu", + }); const result = await feishuDocTool.execute("tool-call", { action: "create", @@ -417,29 +342,10 @@ describe("feishu_doc image fetch hardening", () => { }); it("create never grants permissions when grant_to_requester is false", async () => { - const registerTool = vi.fn(); - registerFeishuDocTools({ - config: { - channels: { - feishu: { - appId: "app_id", - appSecret: "app_secret", - }, - }, - } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); - - const feishuDocTool = registerTool.mock.calls - .map((call) => call[0]) - .map((tool) => - typeof tool === "function" - ? tool({ messageChannel: "feishu", requesterSenderId: "ou_123" }) - : tool, - ) - .find((tool) => tool.name === "feishu_doc"); - expect(feishuDocTool).toBeDefined(); + const feishuDocTool = resolveFeishuDocTool({ + messageChannel: "feishu", + requesterSenderId: "ou_123", + }); const result = await feishuDocTool.execute("tool-call", { action: "create", @@ -457,25 +363,7 @@ describe("feishu_doc image fetch hardening", () => { data: { document: { title: "Created Doc" } }, }); - const registerTool = vi.fn(); - registerFeishuDocTools({ - config: { - channels: { - feishu: { - appId: "app_id", - appSecret: "app_secret", - }, - }, - } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); - - const feishuDocTool = registerTool.mock.calls - .map((call) => call[0]) - .map((tool) => (typeof tool === "function" ? tool({}) : tool)) - .find((tool) => tool.name === "feishu_doc"); - expect(feishuDocTool).toBeDefined(); + const feishuDocTool = resolveFeishuDocTool(); const result = await feishuDocTool.execute("tool-call", { action: "create", @@ -496,25 +384,7 @@ describe("feishu_doc image fetch hardening", () => { const localPath = join(tmpdir(), `feishu-docx-upload-${Date.now()}.txt`); await fs.writeFile(localPath, "hello from local file", "utf8"); - const registerTool = vi.fn(); - registerFeishuDocTools({ - config: { - channels: { - feishu: { - appId: "app_id", - appSecret: "app_secret", - }, - }, - } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); - - const feishuDocTool = registerTool.mock.calls - .map((call) => call[0]) - .map((tool) => (typeof tool === "function" ? tool({}) : tool)) - .find((tool) => tool.name === "feishu_doc"); - expect(feishuDocTool).toBeDefined(); + const feishuDocTool = resolveFeishuDocTool(); const result = await feishuDocTool.execute("tool-call", { action: "upload_file", @@ -557,25 +427,7 @@ describe("feishu_doc image fetch hardening", () => { await fs.writeFile(localPath, "hello from local file", "utf8"); try { - const registerTool = vi.fn(); - registerFeishuDocTools({ - config: { - channels: { - feishu: { - appId: "app_id", - appSecret: "app_secret", - }, - }, - } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); - - const feishuDocTool = registerTool.mock.calls - .map((call) => call[0]) - .map((tool) => (typeof tool === "function" ? tool({}) : tool)) - .find((tool) => tool.name === "feishu_doc"); - expect(feishuDocTool).toBeDefined(); + const feishuDocTool = resolveFeishuDocTool(); const result = await feishuDocTool.execute("tool-call", { action: "upload_file", diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index 5abd61cc5b7..8f4630c3379 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -1,18 +1,7 @@ import type { ClawdbotConfig } from "openclaw/plugin-sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; - -const probeFeishuMock = vi.hoisted(() => vi.fn()); - -vi.mock("./probe.js", () => ({ - probeFeishu: probeFeishuMock, -})); - -vi.mock("./client.js", () => ({ - createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), - createEventDispatcher: vi.fn(() => ({ register: vi.fn() })), -})); - import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js"; +import { probeFeishuMock } from "./monitor.test-mocks.js"; function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig { return { diff --git a/extensions/feishu/src/monitor.test-mocks.ts b/extensions/feishu/src/monitor.test-mocks.ts new file mode 100644 index 00000000000..083088cdde0 --- /dev/null +++ b/extensions/feishu/src/monitor.test-mocks.ts @@ -0,0 +1,12 @@ +import { vi } from "vitest"; + +export const probeFeishuMock = vi.hoisted(() => vi.fn()); + +vi.mock("./probe.js", () => ({ + probeFeishu: probeFeishuMock, +})); + +vi.mock("./client.js", () => ({ + createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), + createEventDispatcher: vi.fn(() => ({ register: vi.fn() })), +})); diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index 9da288032de..b984500922d 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -2,8 +2,7 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import type { ClawdbotConfig } from "openclaw/plugin-sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; - -const probeFeishuMock = vi.hoisted(() => vi.fn()); +import { probeFeishuMock } from "./monitor.test-mocks.js"; vi.mock("@larksuiteoapi/node-sdk", () => ({ adaptDefault: vi.fn( @@ -14,15 +13,6 @@ vi.mock("@larksuiteoapi/node-sdk", () => ({ ), })); -vi.mock("./probe.js", () => ({ - probeFeishu: probeFeishuMock, -})); - -vi.mock("./client.js", () => ({ - createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), - createEventDispatcher: vi.fn(() => ({ register: vi.fn() })), -})); - import { clearFeishuWebhookRateLimitStateForTest, getFeishuWebhookRateLimitStateSizeForTest, diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index 46a12a0a5ee..afc0971b16e 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -273,6 +273,36 @@ describe("loginGeminiCliOAuth", () => { }); } + async function runRemoteLoginWithCapturedAuthUrl( + loginGeminiCliOAuth: (options: { + isRemote: boolean; + openUrl: () => Promise; + log: (msg: string) => void; + note: () => Promise; + prompt: () => Promise; + progress: { update: () => void; stop: () => void }; + }) => Promise<{ projectId: string }>, + ) { + let authUrl = ""; + const result = await loginGeminiCliOAuth({ + isRemote: true, + openUrl: async () => {}, + log: (msg) => { + const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/); + if (found?.[0]) { + authUrl = found[0]; + } + }, + note: async () => {}, + prompt: async () => { + const state = new URL(authUrl).searchParams.get("state"); + return `${"http://localhost:8085/oauth2callback"}?code=oauth-code&state=${state}`; + }, + progress: { update: () => {}, stop: () => {} }, + }); + return { result, authUrl }; + } + let envSnapshot: Partial>; beforeEach(() => { envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); @@ -325,24 +355,8 @@ describe("loginGeminiCliOAuth", () => { }); vi.stubGlobal("fetch", fetchMock); - let authUrl = ""; const { loginGeminiCliOAuth } = await import("./oauth.js"); - const result = await loginGeminiCliOAuth({ - isRemote: true, - openUrl: async () => {}, - log: (msg) => { - const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/); - if (found?.[0]) { - authUrl = found[0]; - } - }, - note: async () => {}, - prompt: async () => { - const state = new URL(authUrl).searchParams.get("state"); - return `${"http://localhost:8085/oauth2callback"}?code=oauth-code&state=${state}`; - }, - progress: { update: () => {}, stop: () => {} }, - }); + const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); expect(result.projectId).toBe("daily-project"); const loadRequests = requests.filter((request) => @@ -398,24 +412,8 @@ describe("loginGeminiCliOAuth", () => { }); vi.stubGlobal("fetch", fetchMock); - let authUrl = ""; const { loginGeminiCliOAuth } = await import("./oauth.js"); - const result = await loginGeminiCliOAuth({ - isRemote: true, - openUrl: async () => {}, - log: (msg) => { - const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/); - if (found?.[0]) { - authUrl = found[0]; - } - }, - note: async () => {}, - prompt: async () => { - const state = new URL(authUrl).searchParams.get("state"); - return `${"http://localhost:8085/oauth2callback"}?code=oauth-code&state=${state}`; - }, - progress: { update: () => {}, stop: () => {} }, - }); + const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); expect(result.projectId).toBe("env-project"); expect(requests.filter((url) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3); diff --git a/extensions/googlechat/src/channel.startup.test.ts b/extensions/googlechat/src/channel.startup.test.ts index 8823775cfd6..abc086ce93a 100644 --- a/extensions/googlechat/src/channel.startup.test.ts +++ b/extensions/googlechat/src/channel.startup.test.ts @@ -1,10 +1,6 @@ -import type { - ChannelAccountSnapshot, - ChannelGatewayContext, - OpenClawConfig, -} from "openclaw/plugin-sdk"; +import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createStartAccountContext } from "../../test-utils/start-account-context.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ @@ -21,32 +17,6 @@ vi.mock("./monitor.js", async () => { import { googlechatPlugin } from "./channel.js"; -function createStartAccountCtx(params: { - account: ResolvedGoogleChatAccount; - abortSignal: AbortSignal; - statusPatchSink?: (next: ChannelAccountSnapshot) => void; -}): ChannelGatewayContext { - const snapshot: ChannelAccountSnapshot = { - accountId: params.account.accountId, - configured: true, - enabled: true, - running: false, - }; - return { - accountId: params.account.accountId, - account: params.account, - cfg: {} as OpenClawConfig, - runtime: createRuntimeEnv(), - abortSignal: params.abortSignal, - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, - getStatus: () => snapshot, - setStatus: (next) => { - Object.assign(snapshot, next); - params.statusPatchSink?.(snapshot); - }, - }; -} - describe("googlechatPlugin gateway.startAccount", () => { afterEach(() => { vi.clearAllMocks(); @@ -72,7 +42,7 @@ describe("googlechatPlugin gateway.startAccount", () => { const patches: ChannelAccountSnapshot[] = []; const abort = new AbortController(); const task = googlechatPlugin.gateway!.startAccount!( - createStartAccountCtx({ + createStartAccountContext({ account, abortSignal: abort.signal, statusPatchSink: (next) => patches.push({ ...next }), diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index e31905a55ce..ce81c4a9d64 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { GROUP_POLICY_BLOCKED_LABEL, + createInboundEnvelopeBuilder, createScopedPairingAccess, createReplyPrefixOptions, readJsonBodyWithLimit, @@ -646,6 +647,15 @@ async function processMessageWithPipeline(params: { id: spaceId, }, }); + const buildEnvelope = createInboundEnvelopeBuilder({ + cfg: config, + route, + sessionStore: config.session?.store, + resolveStorePath: core.channel.session.resolveStorePath, + readSessionUpdatedAt: core.channel.session.readSessionUpdatedAt, + resolveEnvelopeFormatOptions: core.channel.reply.resolveEnvelopeFormatOptions, + formatAgentEnvelope: core.channel.reply.formatAgentEnvelope, + }); let mediaPath: string | undefined; let mediaType: string | undefined; @@ -661,20 +671,10 @@ async function processMessageWithPipeline(params: { const fromLabel = isGroup ? space.displayName || `space:${spaceId}` : senderName || `user:${senderId}`; - const storePath = core.channel.session.resolveStorePath(config.session?.store, { - agentId: route.agentId, - }); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); - const body = core.channel.reply.formatAgentEnvelope({ + const { storePath, body } = buildEnvelope({ channel: "Google Chat", from: fromLabel, timestamp: event.eventTime ? Date.parse(event.eventTime) : undefined, - previousTimestamp, - envelope: envelopeOptions, body: rawBody, }); diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index a2b7bbde630..0c573f46b75 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -4,6 +4,7 @@ import { DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, + formatTrimmedAllowFromEntries, getChatChannelMeta, imessageOnboardingAdapter, IMessageConfigSchema, @@ -16,6 +17,8 @@ import { resolveChannelMediaMaxBytes, resolveDefaultIMessageAccountId, resolveIMessageAccount, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, resolveAllowlistProviderRuntimeGroupPolicy, @@ -28,6 +31,50 @@ import { getIMessageRuntime } from "./runtime.js"; const meta = getChatChannelMeta("imessage"); +function buildIMessageSetupPatch(input: { + cliPath?: string; + dbPath?: string; + service?: string; + region?: string; +}) { + return { + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.dbPath ? { dbPath: input.dbPath } : {}), + ...(input.service ? { service: input.service } : {}), + ...(input.region ? { region: input.region } : {}), + }; +} + +type IMessageSendFn = ReturnType< + typeof getIMessageRuntime +>["channel"]["imessage"]["sendMessageIMessage"]; + +async function sendIMessageOutbound(params: { + cfg: Parameters[0]["cfg"]; + to: string; + text: string; + mediaUrl?: string; + accountId?: string; + deps?: { sendIMessage?: IMessageSendFn }; + replyToId?: string; +}) { + const send = + params.deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage; + const maxBytes = resolveChannelMediaMaxBytes({ + cfg: params.cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.channels?.imessage?.mediaMaxMb, + accountId: params.accountId, + }); + return await send(params.to, params.text, { + ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), + maxBytes, + accountId: params.accountId ?? undefined, + replyToId: params.replyToId ?? undefined, + }); +} + export const imessagePlugin: ChannelPlugin = { id: "imessage", meta: { @@ -74,14 +121,9 @@ export const imessagePlugin: ChannelPlugin = { enabled: account.enabled, configured: account.configured, }), - resolveAllowFrom: ({ cfg, accountId }) => - (resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => - String(entry), - ), - formatAllowFrom: ({ allowFrom }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - resolveDefaultTo: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined, + resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { @@ -155,10 +197,7 @@ export const imessagePlugin: ChannelPlugin = { imessage: { ...next.channels?.imessage, enabled: true, - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.dbPath ? { dbPath: input.dbPath } : {}), - ...(input.service ? { service: input.service } : {}), - ...(input.region ? { region: input.region } : {}), + ...buildIMessageSetupPatch(input), }, }, }; @@ -175,10 +214,7 @@ export const imessagePlugin: ChannelPlugin = { [accountId]: { ...next.channels?.imessage?.accounts?.[accountId], enabled: true, - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.dbPath ? { dbPath: input.dbPath } : {}), - ...(input.service ? { service: input.service } : {}), - ...(input.region ? { region: input.region } : {}), + ...buildIMessageSetupPatch(input), }, }, }, @@ -192,35 +228,25 @@ export const imessagePlugin: ChannelPlugin = { chunkerMode: "text", textChunkLimit: 4000, sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => { - const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage; - const maxBytes = resolveChannelMediaMaxBytes({ + const result = await sendIMessageOutbound({ cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.imessage?.mediaMaxMb, + to, + text, accountId, - }); - const result = await send(to, text, { - maxBytes, - accountId: accountId ?? undefined, - replyToId: replyToId ?? undefined, + deps, + replyToId, }); return { channel: "imessage", ...result }; }, sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps, replyToId }) => { - const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage; - const maxBytes = resolveChannelMediaMaxBytes({ + const result = await sendIMessageOutbound({ cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.imessage?.mediaMaxMb, - accountId, - }); - const result = await send(to, text, { + to, + text, mediaUrl, - maxBytes, - accountId: accountId ?? undefined, - replyToId: replyToId ?? undefined, + accountId, + deps, + replyToId, }); return { channel: "imessage", ...result }; }, diff --git a/extensions/irc/src/onboarding.test.ts b/extensions/irc/src/onboarding.test.ts index e0493f270c8..1a0f79b21ae 100644 --- a/extensions/irc/src/onboarding.test.ts +++ b/extensions/irc/src/onboarding.test.ts @@ -11,14 +11,23 @@ const selectFirstOption = async (params: { options: Array<{ value: T }> }): P return first.value; }; +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: selectFirstOption as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + describe("irc onboarding", () => { it("configures host and nick via onboarding prompts", async () => { - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), - select: selectFirstOption as WizardPrompter["select"], - multiselect: vi.fn(async () => []), + const prompter = createPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "IRC server host") { return "irc.libera.chat"; @@ -52,8 +61,7 @@ describe("irc onboarding", () => { } return false; }), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + }); const runtime: RuntimeEnv = { log: vi.fn(), @@ -84,12 +92,7 @@ describe("irc onboarding", () => { }); it("writes DM allowFrom to top-level config for non-default account prompts", async () => { - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), - select: selectFirstOption as WizardPrompter["select"], - multiselect: vi.fn(async () => []), + const prompter = createPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "IRC allowFrom (nick or nick!user@host)") { return "Alice, Bob!ident@example.org"; @@ -97,8 +100,7 @@ describe("irc onboarding", () => { throw new Error(`Unexpected prompt: ${message}`); }) as WizardPrompter["text"], confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + }); const promptAllowFrom = ircOnboardingAdapter.dmPolicy?.promptAllowFrom; expect(promptAllowFrom).toBeTypeOf("function"); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 20dde4dc6ed..d02cf2db522 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -1,6 +1,7 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildProbeChannelStatusSummary, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, @@ -393,16 +394,8 @@ export const matrixPlugin: ChannelPlugin = { }, ]; }), - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - baseUrl: snapshot.baseUrl ?? null, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }), probeAccount: async ({ account, timeoutMs, cfg }) => { try { const auth = await resolveMatrixAuth({ diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 3ad9588c06e..7bc3f227528 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -1,6 +1,7 @@ import type { DmPolicy } from "openclaw/plugin-sdk"; import { addWildcardAllowFrom, + formatResolvedUnresolvedNote, formatDocsLink, mergeAllowFromEntries, promptChannelAccessConfig, @@ -408,18 +409,12 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { } } roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - if (resolvedIds.length > 0 || unresolved.length > 0) { - await prompter.note( - [ - resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, - unresolved.length > 0 - ? `Unresolved (kept as typed): ${unresolved.join(", ")}` - : undefined, - ] - .filter(Boolean) - .join("\n"), - "Matrix rooms", - ); + const resolution = formatResolvedUnresolvedNote({ + resolved: resolvedIds, + unresolved, + }); + if (resolution) { + await prompter.note(resolution, "Matrix rooms"); } } catch (err) { await prompter.note( diff --git a/extensions/minimax-portal-auth/oauth.ts b/extensions/minimax-portal-auth/oauth.ts index 0d60e79b034..ac387f72d14 100644 --- a/extensions/minimax-portal-auth/oauth.ts +++ b/extensions/minimax-portal-auth/oauth.ts @@ -1,4 +1,5 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; +import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk"; export type MiniMaxRegion = "cn" | "global"; @@ -49,15 +50,8 @@ type TokenResult = | TokenPending | { status: "error"; message: string }; -function toFormUrlEncoded(data: Record): string { - return Object.entries(data) - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) - .join("&"); -} - function generatePkce(): { verifier: string; challenge: string; state: string } { - const verifier = randomBytes(32).toString("base64url"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); + const { verifier, challenge } = generatePkceVerifierChallenge(); const state = randomBytes(16).toString("base64url"); return { verifier, challenge, state }; } diff --git a/extensions/nextcloud-talk/src/channel.startup.test.ts b/extensions/nextcloud-talk/src/channel.startup.test.ts index 68f8490efb9..a15aa491606 100644 --- a/extensions/nextcloud-talk/src/channel.startup.test.ts +++ b/extensions/nextcloud-talk/src/channel.startup.test.ts @@ -1,10 +1,5 @@ -import type { - ChannelAccountSnapshot, - ChannelGatewayContext, - OpenClawConfig, -} from "openclaw/plugin-sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createStartAccountContext } from "../../test-utils/start-account-context.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ @@ -21,30 +16,6 @@ vi.mock("./monitor.js", async () => { import { nextcloudTalkPlugin } from "./channel.js"; -function createStartAccountCtx(params: { - account: ResolvedNextcloudTalkAccount; - abortSignal: AbortSignal; -}): ChannelGatewayContext { - const snapshot: ChannelAccountSnapshot = { - accountId: params.account.accountId, - configured: true, - enabled: true, - running: false, - }; - return { - accountId: params.account.accountId, - account: params.account, - cfg: {} as OpenClawConfig, - runtime: createRuntimeEnv(), - abortSignal: params.abortSignal, - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, - getStatus: () => snapshot, - setStatus: (next) => { - Object.assign(snapshot, next); - }, - }; -} - function buildAccount(): ResolvedNextcloudTalkAccount { return { accountId: "default", @@ -72,7 +43,7 @@ describe("nextcloudTalkPlugin gateway.startAccount", () => { const abort = new AbortController(); const task = nextcloudTalkPlugin.gateway!.startAccount!( - createStartAccountCtx({ + createStartAccountContext({ account: buildAccount(), abortSignal: abort.signal, }), @@ -103,7 +74,7 @@ describe("nextcloudTalkPlugin gateway.startAccount", () => { abort.abort(); await nextcloudTalkPlugin.gateway!.startAccount!( - createStartAccountCtx({ + createStartAccountContext({ account: buildAccount(), abortSignal: abort.signal, }), diff --git a/extensions/nextcloud-talk/src/monitor.backend.test.ts b/extensions/nextcloud-talk/src/monitor.backend.test.ts index aaf9a30a9c8..37fdbfcbab7 100644 --- a/extensions/nextcloud-talk/src/monitor.backend.test.ts +++ b/extensions/nextcloud-talk/src/monitor.backend.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js"; import { startWebhookServer } from "./monitor.test-harness.js"; -import { generateNextcloudTalkSignature } from "./signature.js"; describe("createNextcloudTalkWebhookServer backend allowlist", () => { it("rejects requests from unexpected backend origins", async () => { @@ -11,31 +11,12 @@ describe("createNextcloudTalkWebhookServer backend allowlist", () => { onMessage, }); - const payload = { - type: "Create", - actor: { type: "Person", id: "alice", name: "Alice" }, - object: { - type: "Note", - id: "msg-1", - name: "hello", - content: "hello", - mediaType: "text/plain", - }, - target: { type: "Collection", id: "room-1", name: "Room 1" }, - }; - const body = JSON.stringify(payload); - const { random, signature } = generateNextcloudTalkSignature({ - body, - secret: "nextcloud-secret", + const { body, headers } = createSignedCreateMessageRequest({ + backend: "https://nextcloud.unexpected", }); const response = await fetch(harness.webhookUrl, { method: "POST", - headers: { - "content-type": "application/json", - "x-nextcloud-talk-random": random, - "x-nextcloud-talk-signature": signature, - "x-nextcloud-talk-backend": "https://nextcloud.unexpected", - }, + headers, body, }); diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts index 387e7a8304f..4cb2abeecd9 100644 --- a/extensions/nextcloud-talk/src/monitor.replay.test.ts +++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts @@ -1,15 +1,8 @@ import { describe, expect, it, vi } from "vitest"; +import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js"; import { startWebhookServer } from "./monitor.test-harness.js"; -import { generateNextcloudTalkSignature } from "./signature.js"; import type { NextcloudTalkInboundMessage } from "./types.js"; -function createSignedRequest(body: string): { random: string; signature: string } { - return generateNextcloudTalkSignature({ - body, - secret: "nextcloud-secret", - }); -} - describe("createNextcloudTalkWebhookServer replay handling", () => { it("acknowledges replayed requests and skips onMessage side effects", async () => { const seen = new Set(); @@ -27,26 +20,7 @@ describe("createNextcloudTalkWebhookServer replay handling", () => { onMessage, }); - const payload = { - type: "Create", - actor: { type: "Person", id: "alice", name: "Alice" }, - object: { - type: "Note", - id: "msg-1", - name: "hello", - content: "hello", - mediaType: "text/plain", - }, - target: { type: "Collection", id: "room-1", name: "Room 1" }, - }; - const body = JSON.stringify(payload); - const { random, signature } = createSignedRequest(body); - const headers = { - "content-type": "application/json", - "x-nextcloud-talk-random": random, - "x-nextcloud-talk-signature": signature, - "x-nextcloud-talk-backend": "https://nextcloud.example", - }; + const { body, headers } = createSignedCreateMessageRequest(); const first = await fetch(harness.webhookUrl, { method: "POST", diff --git a/extensions/nextcloud-talk/src/monitor.test-fixtures.ts b/extensions/nextcloud-talk/src/monitor.test-fixtures.ts new file mode 100644 index 00000000000..21d41976c98 --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.test-fixtures.ts @@ -0,0 +1,30 @@ +import { generateNextcloudTalkSignature } from "./signature.js"; + +export function createSignedCreateMessageRequest(params?: { backend?: string }) { + const payload = { + type: "Create", + actor: { type: "Person", id: "alice", name: "Alice" }, + object: { + type: "Note", + id: "msg-1", + name: "hello", + content: "hello", + mediaType: "text/plain", + }, + target: { type: "Collection", id: "room-1", name: "Room 1" }, + }; + const body = JSON.stringify(payload); + const { random, signature } = generateNextcloudTalkSignature({ + body, + secret: "nextcloud-secret", + }); + return { + body, + headers: { + "content-type": "application/json", + "x-nextcloud-talk-random": random, + "x-nextcloud-talk-signature": signature, + "x-nextcloud-talk-backend": params?.backend ?? "https://nextcloud.example", + }, + }; +} diff --git a/extensions/qwen-portal-auth/oauth.ts b/extensions/qwen-portal-auth/oauth.ts index 3707274f62f..b75a8639a4d 100644 --- a/extensions/qwen-portal-auth/oauth.ts +++ b/extensions/qwen-portal-auth/oauth.ts @@ -1,4 +1,5 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; +import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk"; const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"; const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`; @@ -30,18 +31,6 @@ type DeviceTokenResult = | TokenPending | { status: "error"; message: string }; -function toFormUrlEncoded(data: Record): string { - return Object.entries(data) - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) - .join("&"); -} - -function generatePkce(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("base64url"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - async function requestDeviceCode(params: { challenge: string }): Promise { const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, { method: "POST", @@ -142,7 +131,7 @@ export async function loginQwenPortalOAuth(params: { note: (message: string, title?: string) => Promise; progress: { update: (message: string) => void; stop: (message?: string) => void }; }): Promise { - const { verifier, challenge } = generatePkce(); + const { verifier, challenge } = generatePkceVerifierChallenge(); const device = await requestDeviceCode({ challenge }); const verificationUrl = device.verification_uri_complete || device.verification_uri; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 9f3a96b6c41..8c325a71d7f 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -45,6 +45,46 @@ const signalMessageActions: ChannelMessageActionAdapter = { const meta = getChatChannelMeta("signal"); +function buildSignalSetupPatch(input: { + signalNumber?: string; + cliPath?: string; + httpUrl?: string; + httpHost?: string; + httpPort?: string; +}) { + return { + ...(input.signalNumber ? { account: input.signalNumber } : {}), + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), + ...(input.httpHost ? { httpHost: input.httpHost } : {}), + ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + }; +} + +type SignalSendFn = ReturnType["channel"]["signal"]["sendMessageSignal"]; + +async function sendSignalOutbound(params: { + cfg: Parameters[0]["cfg"]; + to: string; + text: string; + mediaUrl?: string; + accountId?: string; + deps?: { sendSignal?: SignalSendFn }; +}) { + const send = params.deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal; + const maxBytes = resolveChannelMediaMaxBytes({ + cfg: params.cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb, + accountId: params.accountId, + }); + return await send(params.to, params.text, { + ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), + maxBytes, + accountId: params.accountId ?? undefined, + }); +} + export const signalPlugin: ChannelPlugin = { id: "signal", meta: { @@ -190,11 +230,7 @@ export const signalPlugin: ChannelPlugin = { signal: { ...next.channels?.signal, enabled: true, - ...(input.signalNumber ? { account: input.signalNumber } : {}), - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), - ...(input.httpHost ? { httpHost: input.httpHost } : {}), - ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + ...buildSignalSetupPatch(input), }, }, }; @@ -211,11 +247,7 @@ export const signalPlugin: ChannelPlugin = { [accountId]: { ...next.channels?.signal?.accounts?.[accountId], enabled: true, - ...(input.signalNumber ? { account: input.signalNumber } : {}), - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), - ...(input.httpHost ? { httpHost: input.httpHost } : {}), - ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + ...buildSignalSetupPatch(input), }, }, }, @@ -229,33 +261,23 @@ export const signalPlugin: ChannelPlugin = { chunkerMode: "text", textChunkLimit: 4000, sendText: async ({ cfg, to, text, accountId, deps }) => { - const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal; - const maxBytes = resolveChannelMediaMaxBytes({ + const result = await sendSignalOutbound({ cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.signal?.mediaMaxMb, + to, + text, accountId, - }); - const result = await send(to, text, { - maxBytes, - accountId: accountId ?? undefined, + deps, }); return { channel: "signal", ...result }; }, sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { - const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal; - const maxBytes = resolveChannelMediaMaxBytes({ + const result = await sendSignalOutbound({ cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.signal?.mediaMaxMb, - accountId, - }); - const result = await send(to, text, { + to, + text, mediaUrl, - maxBytes, - accountId: accountId ?? undefined, + accountId, + deps, }); return { channel: "signal", ...result }; }, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index ab6047f10cc..b02a36e3b07 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -63,6 +63,24 @@ function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { return Boolean(account.appToken?.trim()); } +type SlackSendFn = ReturnType["channel"]["slack"]["sendMessageSlack"]; + +function resolveSlackSendContext(params: { + cfg: Parameters[0]["cfg"]; + accountId?: string; + deps?: { sendSlack?: SlackSendFn }; + replyToId?: string | null; + threadId?: string | null; +}) { + const send = params.deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; + const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); + const token = getTokenForOperation(account, "write"); + const botToken = account.botToken?.trim(); + const tokenOverride = token && token !== botToken ? token : undefined; + const threadTsValue = params.replyToId ?? params.threadId; + return { send, threadTsValue, tokenOverride }; +} + export const slackPlugin: ChannelPlugin = { id: "slack", meta: { @@ -339,12 +357,13 @@ export const slackPlugin: ChannelPlugin = { chunker: null, textChunkLimit: 4000, sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { - const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; - const account = resolveSlackAccount({ cfg, accountId }); - const token = getTokenForOperation(account, "write"); - const botToken = account.botToken?.trim(); - const tokenOverride = token && token !== botToken ? token : undefined; - const threadTsValue = replyToId ?? threadId; + const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ + cfg, + accountId, + deps, + replyToId, + threadId, + }); const result = await send(to, text, { threadTs: threadTsValue != null ? String(threadTsValue) : undefined, accountId: accountId ?? undefined, @@ -353,12 +372,13 @@ export const slackPlugin: ChannelPlugin = { return { channel: "slack", ...result }; }, sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId, cfg }) => { - const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; - const account = resolveSlackAccount({ cfg, accountId }); - const token = getTokenForOperation(account, "write"); - const botToken = account.botToken?.trim(); - const tokenOverride = token && token !== botToken ? token : undefined; - const threadTsValue = replyToId ?? threadId; + const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ + cfg, + accountId, + deps, + replyToId, + threadId, + }); const result = await send(to, text, { mediaUrl, threadTs: threadTsValue != null ? String(threadTsValue) : undefined, diff --git a/extensions/synology-chat/src/channel.integration.test.ts b/extensions/synology-chat/src/channel.integration.test.ts index 2032a83512a..555bf3da65b 100644 --- a/extensions/synology-chat/src/channel.integration.test.ts +++ b/extensions/synology-chat/src/channel.integration.test.ts @@ -1,6 +1,5 @@ -import { EventEmitter } from "node:events"; -import type { IncomingMessage, ServerResponse } from "node:http"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js"; type RegisteredRoute = { path: string; @@ -41,37 +40,6 @@ vi.mock("./client.js", () => ({ const { createSynologyChatPlugin } = await import("./channel.js"); -function makeReq(method: string, body: string): IncomingMessage { - const req = new EventEmitter() as IncomingMessage; - req.method = method; - req.socket = { remoteAddress: "127.0.0.1" } as any; - process.nextTick(() => { - req.emit("data", Buffer.from(body)); - req.emit("end"); - }); - return req; -} - -function makeRes(): ServerResponse & { _status: number; _body: string } { - const res = { - _status: 0, - _body: "", - writeHead(statusCode: number, _headers: Record) { - res._status = statusCode; - }, - end(body?: string) { - res._body = body ?? ""; - }, - } as any; - return res; -} - -function makeFormBody(fields: Record): string { - return Object.entries(fields) - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) - .join("&"); -} - describe("Synology channel wiring integration", () => { beforeEach(() => { registerPluginHttpRouteMock.mockClear(); diff --git a/extensions/synology-chat/src/test-http-utils.ts b/extensions/synology-chat/src/test-http-utils.ts new file mode 100644 index 00000000000..ea268a48320 --- /dev/null +++ b/extensions/synology-chat/src/test-http-utils.ts @@ -0,0 +1,33 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +export function makeReq(method: string, body: string): IncomingMessage { + const req = new EventEmitter() as IncomingMessage; + req.method = method; + req.socket = { remoteAddress: "127.0.0.1" } as unknown as IncomingMessage["socket"]; + process.nextTick(() => { + req.emit("data", Buffer.from(body)); + req.emit("end"); + }); + return req; +} + +export function makeRes(): ServerResponse & { _status: number; _body: string } { + const res = { + _status: 0, + _body: "", + writeHead(statusCode: number, _headers: Record) { + res._status = statusCode; + }, + end(body?: string) { + res._body = body ?? ""; + }, + } as unknown as ServerResponse & { _status: number; _body: string }; + return res; +} + +export function makeFormBody(fields: Record): string { + return Object.entries(fields) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join("&"); +} diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index b79b313c840..0c4e8c17e2d 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -1,6 +1,5 @@ -import { EventEmitter } from "node:events"; -import type { IncomingMessage, ServerResponse } from "node:http"; import { describe, it, expect, vi, beforeEach } from "vitest"; +import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js"; import type { ResolvedSynologyChatAccount } from "./types.js"; import { clearSynologyWebhookRateLimiterStateForTest, @@ -31,40 +30,6 @@ function makeAccount( }; } -function makeReq(method: string, body: string): IncomingMessage { - const req = new EventEmitter() as IncomingMessage; - req.method = method; - req.socket = { remoteAddress: "127.0.0.1" } as any; - - // Simulate body delivery - process.nextTick(() => { - req.emit("data", Buffer.from(body)); - req.emit("end"); - }); - - return req; -} - -function makeRes(): ServerResponse & { _status: number; _body: string } { - const res = { - _status: 0, - _body: "", - writeHead(statusCode: number, _headers: Record) { - res._status = statusCode; - }, - end(body?: string) { - res._body = body ?? ""; - }, - } as any; - return res; -} - -function makeFormBody(fields: Record): string { - return Object.entries(fields) - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) - .join("&"); -} - const validBody = makeFormBody({ token: "valid-token", user_id: "123", diff --git a/extensions/test-utils/start-account-context.ts b/extensions/test-utils/start-account-context.ts new file mode 100644 index 00000000000..99d76dd7c81 --- /dev/null +++ b/extensions/test-utils/start-account-context.ts @@ -0,0 +1,33 @@ +import type { + ChannelAccountSnapshot, + ChannelGatewayContext, + OpenClawConfig, +} from "openclaw/plugin-sdk"; +import { vi } from "vitest"; +import { createRuntimeEnv } from "./runtime-env.js"; + +export function createStartAccountContext(params: { + account: TAccount; + abortSignal: AbortSignal; + statusPatchSink?: (next: ChannelAccountSnapshot) => void; +}): ChannelGatewayContext { + const snapshot: ChannelAccountSnapshot = { + accountId: params.account.accountId, + configured: true, + enabled: true, + running: false, + }; + return { + accountId: params.account.accountId, + account: params.account, + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + abortSignal: params.abortSignal, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getStatus: () => snapshot, + setStatus: (next) => { + Object.assign(snapshot, next); + params.statusPatchSink?.(snapshot); + }, + }; +} diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index d110dcc9c24..00bed8c949a 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -189,6 +189,16 @@ const voiceCallPlugin = { respond(false, { error: err instanceof Error ? err.message : String(err) }); }; + const resolveCallMessageRequest = async (params: GatewayRequestHandlerOptions["params"]) => { + const callId = typeof params?.callId === "string" ? params.callId.trim() : ""; + const message = typeof params?.message === "string" ? params.message.trim() : ""; + if (!callId || !message) { + return { error: "callId and message required" } as const; + } + const rt = await ensureRuntime(); + return { rt, callId, message } as const; + }; + api.registerGatewayMethod( "voicecall.initiate", async ({ params, respond }: GatewayRequestHandlerOptions) => { @@ -228,14 +238,12 @@ const voiceCallPlugin = { "voicecall.continue", async ({ params, respond }: GatewayRequestHandlerOptions) => { try { - const callId = typeof params?.callId === "string" ? params.callId.trim() : ""; - const message = typeof params?.message === "string" ? params.message.trim() : ""; - if (!callId || !message) { - respond(false, { error: "callId and message required" }); + const request = await resolveCallMessageRequest(params); + if ("error" in request) { + respond(false, { error: request.error }); return; } - const rt = await ensureRuntime(); - const result = await rt.manager.continueCall(callId, message); + const result = await request.rt.manager.continueCall(request.callId, request.message); if (!result.success) { respond(false, { error: result.error || "continue failed" }); return; @@ -251,14 +259,12 @@ const voiceCallPlugin = { "voicecall.speak", async ({ params, respond }: GatewayRequestHandlerOptions) => { try { - const callId = typeof params?.callId === "string" ? params.callId.trim() : ""; - const message = typeof params?.message === "string" ? params.message.trim() : ""; - if (!callId || !message) { - respond(false, { error: "callId and message required" }); + const request = await resolveCallMessageRequest(params); + if ("error" in request) { + respond(false, { error: request.error }); return; } - const rt = await ensureRuntime(); - const result = await rt.manager.speak(callId, message); + const result = await request.rt.manager.speak(request.callId, request.message); if (!result.success) { respond(false, { error: result.error || "speak failed" }); return; diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index dd7fb69502e..a80af69b605 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -86,6 +86,18 @@ function twilioSignature(params: { authToken: string; url: string; postBody: str return crypto.createHmac("sha1", params.authToken).update(dataToSign).digest("base64"); } +function expectReplayResultPair( + first: { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string }, + second: { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string }, +) { + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); +} + describe("verifyPlivoWebhook", () => { it("accepts valid V2 signature", () => { const authToken = "test-auth-token"; @@ -196,12 +208,7 @@ describe("verifyPlivoWebhook", () => { const first = verifyPlivoWebhook(ctx, authToken); const second = verifyPlivoWebhook(ctx, authToken); - expect(first.ok).toBe(true); - expect(first.isReplay).toBeFalsy(); - expect(first.verifiedRequestKey).toBeTruthy(); - expect(second.ok).toBe(true); - expect(second.isReplay).toBe(true); - expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + expectReplayResultPair(first, second); }); it("returns a stable request key when verification is skipped", () => { @@ -245,12 +252,7 @@ describe("verifyTelnyxWebhook", () => { const first = verifyTelnyxWebhook(ctx, pemPublicKey); const second = verifyTelnyxWebhook(ctx, pemPublicKey); - expect(first.ok).toBe(true); - expect(first.isReplay).toBeFalsy(); - expect(first.verifiedRequestKey).toBeTruthy(); - expect(second.ok).toBe(true); - expect(second.isReplay).toBe(true); - expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + expectReplayResultPair(first, second); }); it("returns a stable request key when verification is skipped", () => { diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 759ff85d010..e4a2ff1e1e8 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -55,6 +55,21 @@ const createManager = (calls: CallRecord[]) => { return { manager, endCall, processEvent }; }; +async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string, body: string) { + const address = ( + server as unknown as { server?: { address?: () => unknown } } + ).server?.address?.(); + const requestUrl = new URL(baseUrl); + if (address && typeof address === "object" && "port" in address && address.port) { + requestUrl.port = String(address.port); + } + return await fetch(requestUrl.toString(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); +} + describe("VoiceCallWebhookServer stale call reaper", () => { beforeEach(() => { vi.useFakeTimers(); @@ -146,18 +161,7 @@ describe("VoiceCallWebhookServer replay handling", () => { try { const baseUrl = await server.start(); - const address = ( - server as unknown as { server?: { address?: () => unknown } } - ).server?.address?.(); - const requestUrl = new URL(baseUrl); - if (address && typeof address === "object" && "port" in address && address.port) { - requestUrl.port = String(address.port); - } - const response = await fetch(requestUrl.toString(), { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: "CallSid=CA123&SpeechResult=hello", - }); + const response = await postWebhookForm(server, baseUrl, "CallSid=CA123&SpeechResult=hello"); expect(response.status).toBe(200); expect(processEvent).not.toHaveBeenCalled(); @@ -193,18 +197,7 @@ describe("VoiceCallWebhookServer replay handling", () => { try { const baseUrl = await server.start(); - const address = ( - server as unknown as { server?: { address?: () => unknown } } - ).server?.address?.(); - const requestUrl = new URL(baseUrl); - if (address && typeof address === "object" && "port" in address && address.port) { - requestUrl.port = String(address.port); - } - const response = await fetch(requestUrl.toString(), { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: "CallSid=CA123&SpeechResult=hello", - }); + const response = await postWebhookForm(server, baseUrl, "CallSid=CA123&SpeechResult=hello"); expect(response.status).toBe(200); expect(parseWebhookEvent).toHaveBeenCalledTimes(1); @@ -231,18 +224,7 @@ describe("VoiceCallWebhookServer replay handling", () => { try { const baseUrl = await server.start(); - const address = ( - server as unknown as { server?: { address?: () => unknown } } - ).server?.address?.(); - const requestUrl = new URL(baseUrl); - if (address && typeof address === "object" && "port" in address && address.port) { - requestUrl.port = String(address.port); - } - const response = await fetch(requestUrl.toString(), { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: "CallSid=CA123&SpeechResult=hello", - }); + const response = await postWebhookForm(server, baseUrl, "CallSid=CA123&SpeechResult=hello"); expect(response.status).toBe(401); expect(parseWebhookEvent).not.toHaveBeenCalled(); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index a5554cd4c5e..67d270d093e 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -13,7 +13,7 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeE164, - normalizeWhatsAppAllowFromEntries, + formatWhatsAppConfigAllowFromEntries, normalizeWhatsAppMessagingTarget, readStringParam, resolveDefaultWhatsAppAccountId, @@ -21,6 +21,8 @@ import { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveWhatsAppAccount, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupIntroHint, resolveWhatsAppGroupToolPolicy, @@ -113,15 +115,9 @@ export const whatsappPlugin: ChannelPlugin = { dmPolicy: account.dmPolicy, allowFrom: account.allowFrom, }), - resolveAllowFrom: ({ cfg, accountId }) => - resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], - formatAllowFrom: ({ allowFrom }) => normalizeWhatsAppAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => { - const root = cfg.channels?.whatsapp; - const normalized = normalizeAccountId(accountId); - const account = root?.accounts?.[normalized]; - return (account?.defaultTo ?? root?.defaultTo)?.trim() || undefined; - }, + resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 8cf9f7efb76..e2a2edd1be0 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk"; import { + createInboundEnvelopeBuilder, createScopedPairingAccess, createReplyPrefixOptions, resolveSenderCommandAuthorization, @@ -443,6 +444,15 @@ async function processMessageWithPipeline(params: { id: chatId, }, }); + const buildEnvelope = createInboundEnvelopeBuilder({ + cfg: config, + route, + sessionStore: config.session?.store, + resolveStorePath: core.channel.session.resolveStorePath, + readSessionUpdatedAt: core.channel.session.readSessionUpdatedAt, + resolveEnvelopeFormatOptions: core.channel.reply.resolveEnvelopeFormatOptions, + formatAgentEnvelope: core.channel.reply.formatAgentEnvelope, + }); if ( isGroup && @@ -454,20 +464,10 @@ async function processMessageWithPipeline(params: { } const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; - const storePath = core.channel.session.resolveStorePath(config.session?.store, { - agentId: route.agentId, - }); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); - const body = core.channel.reply.formatAgentEnvelope({ + const { storePath, body } = buildEnvelope({ channel: "Zalo", from: fromLabel, timestamp: date ? date * 1000 : undefined, - previousTimestamp, - envelope: envelopeOptions, body: rawBody, }); diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index c6aee6adcc8..72c8753fe71 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -6,6 +6,7 @@ import type { RuntimeEnv, } from "openclaw/plugin-sdk"; import { + createInboundEnvelopeBuilder, createScopedPairingAccess, createReplyPrefixOptions, resolveOutboundMediaUrls, @@ -314,22 +315,21 @@ async function processMessage( id: peer.id, }, }); + const buildEnvelope = createInboundEnvelopeBuilder({ + cfg: config, + route, + sessionStore: config.session?.store, + resolveStorePath: core.channel.session.resolveStorePath, + readSessionUpdatedAt: core.channel.session.readSessionUpdatedAt, + resolveEnvelopeFormatOptions: core.channel.reply.resolveEnvelopeFormatOptions, + formatAgentEnvelope: core.channel.reply.formatAgentEnvelope, + }); const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; - const storePath = core.channel.session.resolveStorePath(config.session?.store, { - agentId: route.agentId, - }); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); - const body = core.channel.reply.formatAgentEnvelope({ + const { storePath, body } = buildEnvelope({ channel: "Zalo Personal", from: fromLabel, timestamp: timestamp ? timestamp * 1000 : undefined, - previousTimestamp, - envelope: envelopeOptions, body: rawBody, }); diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index c623349e7c8..fa694a64748 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -7,6 +7,7 @@ import type { import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, + formatResolvedUnresolvedNote, mergeAllowFromEntries, normalizeAccountId, promptAccountId, @@ -398,18 +399,12 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { .filter((entry) => !entry.resolved) .map((entry) => entry.input); keys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - if (resolvedIds.length > 0 || unresolved.length > 0) { - await prompter.note( - [ - resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, - unresolved.length > 0 - ? `Unresolved (kept as typed): ${unresolved.join(", ")}` - : undefined, - ] - .filter(Boolean) - .join("\n"), - "Zalo groups", - ); + const resolution = formatResolvedUnresolvedNote({ + resolved: resolvedIds, + unresolved, + }); + if (resolution) { + await prompter.note(resolution, "Zalo groups"); } } catch (err) { await prompter.note( diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 2556ba5996c..98db2a2cf49 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -3,7 +3,14 @@ import { resolveChannelGroupToolsPolicy, } from "../config/group-policy.js"; import { resolveDiscordAccount } from "../discord/accounts.js"; -import { resolveIMessageAccount } from "../imessage/accounts.js"; +import { + formatTrimmedAllowFromEntries, + formatWhatsAppConfigAllowFromEntries, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, +} from "../plugin-sdk/channel-config-helpers.js"; import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { normalizeAccountId } from "../routing/session-key.js"; import { resolveSignalAccount } from "../signal/accounts.js"; @@ -11,7 +18,6 @@ import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts. import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; import { resolveTelegramAccount } from "../telegram/accounts.js"; import { normalizeE164 } from "../utils.js"; -import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, @@ -27,7 +33,6 @@ import { resolveWhatsAppGroupToolPolicy, } from "./plugins/group-mentions.js"; import { normalizeSignalMessagingTarget } from "./plugins/normalize/signal.js"; -import { normalizeWhatsAppAllowFromEntries } from "./plugins/normalize/whatsapp.js"; import type { ChannelCapabilities, ChannelCommandAdapter, @@ -289,15 +294,9 @@ const DOCKS: Record = { }, outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000, config: { - resolveAllowFrom: ({ cfg, accountId }) => - resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], - formatAllowFrom: ({ allowFrom }) => normalizeWhatsAppAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => { - const root = cfg.channels?.whatsapp; - const normalized = normalizeAccountId(accountId); - const account = root?.accounts?.[normalized]; - return (account?.defaultTo ?? root?.defaultTo)?.trim() || undefined; - }, + resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), }, groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, @@ -534,14 +533,9 @@ const DOCKS: Record = { }, outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000, config: { - resolveAllowFrom: ({ cfg, accountId }) => - (resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => - String(entry), - ), - formatAllowFrom: ({ allowFrom }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - resolveDefaultTo: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined, + resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), }, groups: { resolveRequireMention: resolveIMessageGroupRequireMention, diff --git a/src/imessage/targets.ts b/src/imessage/targets.ts index dc1a02ec534..75f159576ff 100644 --- a/src/imessage/targets.ts +++ b/src/imessage/targets.ts @@ -1,6 +1,7 @@ import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js"; import { normalizeE164 } from "../utils.js"; import { + type ParsedChatTarget, parseChatAllowTargetPrefixes, parseChatTargetPrefixesOrThrow, resolveServicePrefixedAllowTarget, @@ -15,11 +16,7 @@ export type IMessageTarget = | { kind: "chat_identifier"; chatIdentifier: string } | { kind: "handle"; to: string; service: IMessageService }; -export type IMessageAllowTarget = - | { kind: "chat_id"; chatId: number } - | { kind: "chat_guid"; chatGuid: string } - | { kind: "chat_identifier"; chatIdentifier: string } - | { kind: "handle"; handle: string }; +export type IMessageAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string }; const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"]; const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"]; diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts new file mode 100644 index 00000000000..90cbd4b980f --- /dev/null +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -0,0 +1,44 @@ +import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveIMessageAccount } from "../imessage/accounts.js"; +import { normalizeAccountId } from "../routing/session-key.js"; +import { resolveWhatsAppAccount } from "../web/accounts.js"; + +export function formatTrimmedAllowFromEntries(allowFrom: Array): string[] { + return allowFrom.map((entry) => String(entry).trim()).filter(Boolean); +} + +export function resolveWhatsAppConfigAllowFrom(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + return resolveWhatsAppAccount(params).allowFrom ?? []; +} + +export function formatWhatsAppConfigAllowFromEntries(allowFrom: Array): string[] { + return normalizeWhatsAppAllowFromEntries(allowFrom); +} + +export function resolveWhatsAppConfigDefaultTo(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string | undefined { + const root = params.cfg.channels?.whatsapp; + const normalized = normalizeAccountId(params.accountId); + const account = root?.accounts?.[normalized]; + return (account?.defaultTo ?? root?.defaultTo)?.trim() || undefined; +} + +export function resolveIMessageConfigAllowFrom(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + return (resolveIMessageAccount(params).config.allowFrom ?? []).map((entry) => String(entry)); +} + +export function resolveIMessageConfigDefaultTo(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string | undefined { + return resolveIMessageAccount(params).config.defaultTo?.trim() || undefined; +} diff --git a/src/plugin-sdk/inbound-envelope.ts b/src/plugin-sdk/inbound-envelope.ts new file mode 100644 index 00000000000..84f6664c295 --- /dev/null +++ b/src/plugin-sdk/inbound-envelope.ts @@ -0,0 +1,41 @@ +type RouteLike = { + agentId: string; + sessionKey: string; +}; + +export function createInboundEnvelopeBuilder(params: { + cfg: TConfig; + route: RouteLike; + sessionStore?: string; + resolveStorePath: (store: string | undefined, opts: { agentId: string }) => string; + readSessionUpdatedAt: (params: { storePath: string; sessionKey: string }) => number | undefined; + resolveEnvelopeFormatOptions: (cfg: TConfig) => TEnvelope; + formatAgentEnvelope: (params: { + channel: string; + from: string; + timestamp?: number; + previousTimestamp?: number; + envelope: TEnvelope; + body: string; + }) => string; +}) { + const storePath = params.resolveStorePath(params.sessionStore, { + agentId: params.route.agentId, + }); + const envelopeOptions = params.resolveEnvelopeFormatOptions(params.cfg); + return (input: { channel: string; from: string; body: string; timestamp?: number }) => { + const previousTimestamp = params.readSessionUpdatedAt({ + storePath, + sessionKey: params.route.sessionKey, + }); + const body = params.formatAgentEnvelope({ + channel: input.channel, + from: input.from, + timestamp: input.timestamp, + previousTimestamp, + envelope: envelopeOptions, + body: input.body, + }); + return { storePath, body }; + }; +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 9299eb80532..8ee1467be3b 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -139,11 +139,13 @@ export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, + buildProbeChannelStatusSummary, buildTokenChannelStatusSummary, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; +export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export type { ChannelDock } from "../channels/dock.js"; export { getChatChannelMeta } from "../channels/registry.js"; export type { @@ -223,6 +225,7 @@ export { } from "./group-access.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { createScopedPairingAccess } from "./pairing-access.js"; +export { createInboundEnvelopeBuilder } from "./inbound-envelope.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; export { extractToolSend } from "./tool-send.js"; @@ -242,6 +245,7 @@ export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media- export { createLoggerBackedRuntime } from "./runtime.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; +export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js"; export { applyWindowsSpawnProgramPolicy, @@ -280,6 +284,14 @@ export type { ReplyPayload } from "../auto-reply/types.js"; export type { ChunkMode } from "../auto-reply/chunk.js"; export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js"; export { formatInboundFromLabel } from "../auto-reply/envelope.js"; +export { + formatTrimmedAllowFromEntries, + formatWhatsAppConfigAllowFromEntries, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, +} from "./channel-config-helpers.js"; export { approveDevicePairing, listDevicePairing, @@ -521,6 +533,7 @@ export { resolveServicePrefixedAllowTarget, resolveServicePrefixedTarget, } from "../imessage/target-parsing-helpers.js"; +export type { ParsedChatTarget } from "../imessage/target-parsing-helpers.js"; // Channel: Slack export { diff --git a/src/plugin-sdk/oauth-utils.ts b/src/plugin-sdk/oauth-utils.ts new file mode 100644 index 00000000000..a6465d4d40e --- /dev/null +++ b/src/plugin-sdk/oauth-utils.ts @@ -0,0 +1,13 @@ +import { createHash, randomBytes } from "node:crypto"; + +export function toFormUrlEncoded(data: Record): string { + return Object.entries(data) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join("&"); +} + +export function generatePkceVerifierChallenge(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} diff --git a/src/plugin-sdk/resolution-notes.ts b/src/plugin-sdk/resolution-notes.ts new file mode 100644 index 00000000000..9baf64c21d4 --- /dev/null +++ b/src/plugin-sdk/resolution-notes.ts @@ -0,0 +1,16 @@ +export function formatResolvedUnresolvedNote(params: { + resolved: string[]; + unresolved: string[]; +}): string | undefined { + if (params.resolved.length === 0 && params.unresolved.length === 0) { + return undefined; + } + return [ + params.resolved.length > 0 ? `Resolved: ${params.resolved.join(", ")}` : undefined, + params.unresolved.length > 0 + ? `Unresolved (kept as typed): ${params.unresolved.join(", ")}` + : undefined, + ] + .filter(Boolean) + .join("\n"); +} diff --git a/src/plugin-sdk/status-helpers.ts b/src/plugin-sdk/status-helpers.ts index cbcc8ca57d4..c6abc1d6e54 100644 --- a/src/plugin-sdk/status-helpers.ts +++ b/src/plugin-sdk/status-helpers.ts @@ -45,6 +45,26 @@ export function buildBaseChannelStatusSummary(snapshot: { }; } +export function buildProbeChannelStatusSummary>( + snapshot: { + configured?: boolean | null; + running?: boolean | null; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: unknown; + lastProbeAt?: number | null; + }, + extra?: TExtra, +) { + return { + ...buildBaseChannelStatusSummary(snapshot), + ...(extra ?? ({} as TExtra)), + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }; +} + export function buildBaseAccountStatusSnapshot(params: { account: { accountId: string;