From 544ffbcf7b250965d8debcb50c0c364a22e4aa9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 14:51:55 +0000 Subject: [PATCH] refactor(extensions): dedupe connector helper usage --- extensions/bluebubbles/src/account-resolve.ts | 29 ++ .../bluebubbles/src/attachments.test.ts | 36 +- extensions/bluebubbles/src/attachments.ts | 16 +- extensions/bluebubbles/src/chat.test.ts | 36 +- extensions/bluebubbles/src/chat.ts | 16 +- extensions/bluebubbles/src/monitor.ts | 33 +- extensions/bluebubbles/src/reactions.ts | 16 +- extensions/bluebubbles/src/send.test.ts | 427 +++++------------- extensions/bluebubbles/src/targets.ts | 47 +- extensions/bluebubbles/src/test-harness.ts | 45 ++ extensions/bluebubbles/src/test-mocks.ts | 11 + extensions/device-pair/index.ts | 31 +- extensions/feishu/src/media.ts | 45 +- extensions/feishu/src/send-result.ts | 29 ++ extensions/feishu/src/send.ts | 45 +- extensions/googlechat/src/actions.ts | 12 +- extensions/googlechat/src/monitor.ts | 30 +- .../src/monitor.webhook-routing.test.ts | 25 +- .../googlechat/src/resolve-target.test.ts | 46 +- extensions/irc/src/connect-options.ts | 30 ++ extensions/irc/src/monitor.ts | 151 +++---- extensions/irc/src/probe.ts | 23 +- extensions/irc/src/send.ts | 23 +- .../matrix/src/matrix/actions/client.ts | 28 +- .../matrix/src/matrix/client-bootstrap.ts | 39 ++ .../matrix/src/matrix/monitor/media.test.ts | 30 +- extensions/matrix/src/matrix/send/client.ts | 29 +- .../src/mattermost/monitor-helpers.ts | 24 +- .../msteams/src/conversation-store-fs.test.ts | 17 +- extensions/msteams/src/polls.test.ts | 17 +- extensions/msteams/src/send.ts | 95 ++-- extensions/msteams/src/store-fs.ts | 28 +- extensions/msteams/src/test-runtime.ts | 16 + .../src/monitor.read-body.test.ts | 28 +- extensions/nostr/src/metrics.ts | 60 +-- .../nostr/src/nostr-profile-http.test.ts | 47 +- extensions/nostr/src/seen-tracker.ts | 60 +-- .../shared/resolve-target-test-helpers.ts | 66 +++ extensions/slack/src/channel.ts | 155 +------ extensions/tlon/src/account-fields.ts | 25 + extensions/tlon/src/channel.ts | 15 +- extensions/tlon/src/onboarding.ts | 34 +- extensions/twitch/src/outbound.test.ts | 31 +- extensions/twitch/src/send.test.ts | 31 +- extensions/twitch/src/test-fixtures.ts | 30 ++ .../voice-call/src/providers/telnyx.test.ts | 81 ++-- .../whatsapp/src/resolve-target.test.ts | 46 +- extensions/zalo/src/monitor.ts | 61 +-- extensions/zalouser/src/monitor.ts | 37 +- 49 files changed, 854 insertions(+), 1478 deletions(-) create mode 100644 extensions/bluebubbles/src/account-resolve.ts create mode 100644 extensions/bluebubbles/src/test-harness.ts create mode 100644 extensions/bluebubbles/src/test-mocks.ts create mode 100644 extensions/feishu/src/send-result.ts create mode 100644 extensions/irc/src/connect-options.ts create mode 100644 extensions/matrix/src/matrix/client-bootstrap.ts create mode 100644 extensions/msteams/src/test-runtime.ts create mode 100644 extensions/shared/resolve-target-test-helpers.ts create mode 100644 extensions/tlon/src/account-fields.ts create mode 100644 extensions/twitch/src/test-fixtures.ts diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts new file mode 100644 index 0000000000..0ec539644f --- /dev/null +++ b/extensions/bluebubbles/src/account-resolve.ts @@ -0,0 +1,29 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { resolveBlueBubblesAccount } from "./accounts.js"; + +export type BlueBubblesAccountResolveOpts = { + serverUrl?: string; + password?: string; + accountId?: string; + cfg?: OpenClawConfig; +}; + +export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolveOpts): { + baseUrl: string; + password: string; + accountId: string; +} { + const account = resolveBlueBubblesAccount({ + cfg: params.cfg ?? {}, + accountId: params.accountId, + }); + const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); + const password = params.password?.trim() || account.config.password?.trim(); + if (!baseUrl) { + throw new Error("BlueBubbles serverUrl is required"); + } + if (!password) { + throw new Error("BlueBubbles password is required"); + } + return { baseUrl, password, accountId: account.accountId }; +} diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index ca6f8b92ae..60062a6645 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -1,38 +1,18 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "./test-mocks.js"; import type { BlueBubblesAttachment } from "./types.js"; import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; - -vi.mock("./accounts.js", () => ({ - resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { - const config = cfg?.channels?.bluebubbles ?? {}; - return { - accountId: accountId ?? "default", - enabled: config.enabled !== false, - configured: Boolean(config.serverUrl && config.password), - config, - }; - }), -})); - -vi.mock("./probe.js", () => ({ - getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), -})); +import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; const mockFetch = vi.fn(); +installBlueBubblesFetchTestHooks({ + mockFetch, + privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus), +}); + describe("downloadBlueBubblesAttachment", () => { - beforeEach(() => { - vi.stubGlobal("fetch", mockFetch); - mockFetch.mockReset(); - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - it("throws when guid is missing", async () => { const attachment: BlueBubblesAttachment = {}; await expect( diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index e6d66712e7..ffccac6fb8 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import crypto from "node:crypto"; import path from "node:path"; -import { resolveBlueBubblesAccount } from "./accounts.js"; +import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { postMultipartFormData } from "./multipart.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; @@ -54,19 +54,7 @@ function resolveVoiceInfo(filename: string, contentType?: string) { } function resolveAccount(params: BlueBubblesAttachmentOpts) { - const account = resolveBlueBubblesAccount({ - cfg: params.cfg ?? {}, - accountId: params.accountId, - }); - const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); - const password = params.password?.trim() || account.config.password?.trim(); - if (!baseUrl) { - throw new Error("BlueBubbles serverUrl is required"); - } - if (!password) { - throw new Error("BlueBubbles password is required"); - } - return { baseUrl, password, accountId: account.accountId }; + return resolveBlueBubblesServerAccount(params); } export async function downloadBlueBubblesAttachment( diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index 3f0a8da7e4..b5dd097344 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -1,37 +1,17 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import "./test-mocks.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; - -vi.mock("./accounts.js", () => ({ - resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { - const config = cfg?.channels?.bluebubbles ?? {}; - return { - accountId: accountId ?? "default", - enabled: config.enabled !== false, - configured: Boolean(config.serverUrl && config.password), - config, - }; - }), -})); - -vi.mock("./probe.js", () => ({ - getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), -})); +import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; const mockFetch = vi.fn(); +installBlueBubblesFetchTestHooks({ + mockFetch, + privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus), +}); + describe("chat", () => { - beforeEach(() => { - vi.stubGlobal("fetch", mockFetch); - mockFetch.mockReset(); - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - describe("markBlueBubblesChatRead", () => { it("does nothing when chatGuid is empty", async () => { await markBlueBubblesChatRead("", { diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 7e25c2cec8..4be9e0d05c 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import crypto from "node:crypto"; import path from "node:path"; -import { resolveBlueBubblesAccount } from "./accounts.js"; +import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { postMultipartFormData } from "./multipart.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; @@ -15,19 +15,7 @@ export type BlueBubblesChatOpts = { }; function resolveAccount(params: BlueBubblesChatOpts) { - const account = resolveBlueBubblesAccount({ - cfg: params.cfg ?? {}, - accountId: params.accountId, - }); - const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); - const password = params.password?.trim() || account.config.password?.trim(); - if (!baseUrl) { - throw new Error("BlueBubbles serverUrl is required"); - } - if (!password) { - throw new Error("BlueBubbles password is required"); - } - return { baseUrl, password, accountId: account.accountId }; + return resolveBlueBubblesServerAccount(params); } function assertPrivateApiEnabled(accountId: string, feature: string): void { diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 1ff5896b5a..b8b5bfab22 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1,6 +1,11 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { timingSafeEqual } from "node:crypto"; +import { + registerWebhookTarget, + rejectNonPostWebhookRequest, + resolveWebhookTargets, +} from "openclaw/plugin-sdk"; import { normalizeWebhookMessage, normalizeWebhookReaction, @@ -226,20 +231,11 @@ function removeDebouncer(target: WebhookTarget): void { } export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void { - const key = normalizeWebhookPath(target.path); - const normalizedTarget = { ...target, path: key }; - const existing = webhookTargets.get(key) ?? []; - const next = [...existing, normalizedTarget]; - webhookTargets.set(key, next); + const registered = registerWebhookTarget(webhookTargets, target); return () => { - const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget); - if (updated.length > 0) { - webhookTargets.set(key, updated); - } else { - webhookTargets.delete(key); - } + registered.unregister(); // Clean up debouncer when target is unregistered - removeDebouncer(normalizedTarget); + removeDebouncer(registered.target); }; } @@ -387,17 +383,14 @@ export async function handleBlueBubblesWebhookRequest( req: IncomingMessage, res: ServerResponse, ): Promise { - const url = new URL(req.url ?? "/", "http://localhost"); - const path = normalizeWebhookPath(url.pathname); - const targets = webhookTargets.get(path); - if (!targets || targets.length === 0) { + const resolved = resolveWebhookTargets(req, webhookTargets); + if (!resolved) { return false; } + const { path, targets } = resolved; + const url = new URL(req.url ?? "/", "http://localhost"); - if (req.method !== "POST") { - res.statusCode = 405; - res.setHeader("Allow", "POST"); - res.end("Method Not Allowed"); + if (rejectNonPostWebhookRequest(req, res)) { return true; } diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts index 9fab852089..69d5b2055c 100644 --- a/extensions/bluebubbles/src/reactions.ts +++ b/extensions/bluebubbles/src/reactions.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; -import { resolveBlueBubblesAccount } from "./accounts.js"; +import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; @@ -112,19 +112,7 @@ const REACTION_EMOJIS = new Map([ ]); function resolveAccount(params: BlueBubblesReactionOpts) { - const account = resolveBlueBubblesAccount({ - cfg: params.cfg ?? {}, - accountId: params.accountId, - }); - const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); - const password = params.password?.trim() || account.config.password?.trim(); - if (!baseUrl) { - throw new Error("BlueBubbles serverUrl is required"); - } - if (!password) { - throw new Error("BlueBubbles password is required"); - } - return { baseUrl, password, accountId: account.accountId }; + return resolveBlueBubblesServerAccount(params); } export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string { diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 88b1631ce9..931ba442c9 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1,39 +1,62 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "./test-mocks.js"; import type { BlueBubblesSendTarget } from "./types.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js"; - -vi.mock("./accounts.js", () => ({ - resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { - const config = cfg?.channels?.bluebubbles ?? {}; - return { - accountId: accountId ?? "default", - enabled: config.enabled !== false, - configured: Boolean(config.serverUrl && config.password), - config, - }; - }), -})); - -vi.mock("./probe.js", () => ({ - getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), -})); +import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; const mockFetch = vi.fn(); +installBlueBubblesFetchTestHooks({ + mockFetch, + privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus), +}); + +function mockResolvedHandleTarget( + guid: string = "iMessage;-;+15551234567", + address: string = "+15551234567", +) { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid, + participants: [{ address }], + }, + ], + }), + }); +} + +function mockSendResponse(body: unknown) { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify(body)), + }); +} + describe("send", () => { - beforeEach(() => { - vi.stubGlobal("fetch", mockFetch); - mockFetch.mockReset(); - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - describe("resolveChatGuidForTarget", () => { + const resolveHandleTargetGuid = async (data: Array>) => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data }), + }); + + const target: BlueBubblesSendTarget = { + kind: "handle", + address: "+15551234567", + service: "imessage", + }; + return await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + }; + it("returns chatGuid directly for chat_guid target", async () => { const target: BlueBubblesSendTarget = { kind: "chat_guid", @@ -130,65 +153,31 @@ describe("send", () => { }); it("resolves handle target by matching participant", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15559999999", - participants: [{ address: "+15559999999" }], - }, - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }); - - const target: BlueBubblesSendTarget = { - kind: "handle", - address: "+15551234567", - service: "imessage", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); + const result = await resolveHandleTargetGuid([ + { + guid: "iMessage;-;+15559999999", + participants: [{ address: "+15559999999" }], + }, + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ]); expect(result).toBe("iMessage;-;+15551234567"); }); it("prefers direct chat guid when handle also appears in a group chat", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;+;group-123", - participants: [{ address: "+15551234567" }, { address: "+15550001111" }], - }, - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }); - - const target: BlueBubblesSendTarget = { - kind: "handle", - address: "+15551234567", - service: "imessage", - }; - const result = await resolveChatGuidForTarget({ - baseUrl: "http://localhost:1234", - password: "test", - target, - }); + const result = await resolveHandleTargetGuid([ + { + guid: "iMessage;+;group-123", + participants: [{ address: "+15551234567" }, { address: "+15550001111" }], + }, + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ]); expect(result).toBe("iMessage;-;+15551234567"); }); @@ -416,28 +405,8 @@ describe("send", () => { }); it("sends message successfully", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "msg-uuid-123" }, - }), - ), - }); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-uuid-123" } }); const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", { serverUrl: "http://localhost:1234", @@ -456,28 +425,8 @@ describe("send", () => { }); it("strips markdown formatting from outbound messages", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "msg-uuid-stripped" }, - }), - ), - }); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-uuid-stripped" } }); const result = await sendMessageBlueBubbles( "+15551234567", @@ -578,28 +527,8 @@ describe("send", () => { }); it("uses private-api when reply metadata is present", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "msg-uuid-124" }, - }), - ), - }); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-uuid-124" } }); const result = await sendMessageBlueBubbles("+15551234567", "Replying", { serverUrl: "http://localhost:1234", @@ -620,28 +549,8 @@ describe("send", () => { it("downgrades threaded reply to plain send when private API is disabled", async () => { vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "msg-uuid-plain" }, - }), - ), - }); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-uuid-plain" } }); const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { serverUrl: "http://localhost:1234", @@ -659,28 +568,8 @@ describe("send", () => { }); it("normalizes effect names and uses private-api for effects", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "msg-uuid-125" }, - }), - ), - }); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-uuid-125" } }); const result = await sendMessageBlueBubbles("+15551234567", "Hello", { serverUrl: "http://localhost:1234", @@ -722,24 +611,12 @@ describe("send", () => { }); it("handles send failure", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - text: () => Promise.resolve("Internal server error"), - }); + mockResolvedHandleTarget(); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal server error"), + }); await expect( sendMessageBlueBubbles("+15551234567", "Hello", { @@ -750,23 +627,11 @@ describe("send", () => { }); it("handles empty response body", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); + mockResolvedHandleTarget(); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); const result = await sendMessageBlueBubbles("+15551234567", "Hello", { serverUrl: "http://localhost:1234", @@ -777,23 +642,11 @@ describe("send", () => { }); it("handles invalid JSON response body", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve("not valid json"), - }); + mockResolvedHandleTarget(); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("not valid json"), + }); const result = await sendMessageBlueBubbles("+15551234567", "Hello", { serverUrl: "http://localhost:1234", @@ -804,28 +657,8 @@ describe("send", () => { }); it("extracts messageId from various response formats", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - id: "numeric-id-456", - }), - ), - }); + mockResolvedHandleTarget(); + mockSendResponse({ id: "numeric-id-456" }); const result = await sendMessageBlueBubbles("+15551234567", "Hello", { serverUrl: "http://localhost:1234", @@ -836,28 +669,8 @@ describe("send", () => { }); it("extracts messageGuid from response payload", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { messageGuid: "msg-guid-789" }, - }), - ), - }); + mockResolvedHandleTarget(); + mockSendResponse({ data: { messageGuid: "msg-guid-789" } }); const result = await sendMessageBlueBubbles("+15551234567", "Hello", { serverUrl: "http://localhost:1234", @@ -868,23 +681,8 @@ describe("send", () => { }); it("resolves credentials from config", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })), - }); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-123" } }); const result = await sendMessageBlueBubbles("+15551234567", "Hello", { cfg: { @@ -903,23 +701,8 @@ describe("send", () => { }); it("includes tempGuid in request payload", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - data: [ - { - guid: "iMessage;-;+15551234567", - participants: [{ address: "+15551234567" }], - }, - ], - }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })), - }); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg" } }); await sendMessageBlueBubbles("+15551234567", "Hello", { serverUrl: "http://localhost:1234", diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index 72b25087b6..be9d0fa677 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -1,4 +1,5 @@ import { + isAllowedParsedChatSender, parseChatAllowTargetPrefixes, parseChatTargetPrefixesOrThrow, resolveServicePrefixedAllowTarget, @@ -329,43 +330,15 @@ export function isAllowedBlueBubblesSender(params: { chatGuid?: string | null; chatIdentifier?: string | null; }): boolean { - const allowFrom = params.allowFrom.map((entry) => String(entry).trim()); - if (allowFrom.length === 0) { - return true; - } - if (allowFrom.includes("*")) { - return true; - } - - const senderNormalized = normalizeBlueBubblesHandle(params.sender); - const chatId = params.chatId ?? undefined; - const chatGuid = params.chatGuid?.trim(); - const chatIdentifier = params.chatIdentifier?.trim(); - - for (const entry of allowFrom) { - if (!entry) { - continue; - } - const parsed = parseBlueBubblesAllowTarget(entry); - if (parsed.kind === "chat_id" && chatId !== undefined) { - if (parsed.chatId === chatId) { - return true; - } - } else if (parsed.kind === "chat_guid" && chatGuid) { - if (parsed.chatGuid === chatGuid) { - return true; - } - } else if (parsed.kind === "chat_identifier" && chatIdentifier) { - if (parsed.chatIdentifier === chatIdentifier) { - return true; - } - } else if (parsed.kind === "handle" && senderNormalized) { - if (parsed.handle === senderNormalized) { - return true; - } - } - } - return false; + return isAllowedParsedChatSender({ + allowFrom: params.allowFrom, + sender: params.sender, + chatId: params.chatId, + chatGuid: params.chatGuid, + chatIdentifier: params.chatIdentifier, + normalizeSender: normalizeBlueBubblesHandle, + parseAllowTarget: parseBlueBubblesAllowTarget, + }); } export function formatBlueBubblesChatTarget(params: { diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts new file mode 100644 index 0000000000..a9e3bcb537 --- /dev/null +++ b/extensions/bluebubbles/src/test-harness.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, vi } from "vitest"; + +export function resolveBlueBubblesAccountFromConfig(params: { + cfg?: { channels?: { bluebubbles?: Record } }; + accountId?: string; +}) { + const config = params.cfg?.channels?.bluebubbles ?? {}; + return { + accountId: params.accountId ?? "default", + enabled: config.enabled !== false, + configured: Boolean(config.serverUrl && config.password), + config, + }; +} + +export function createBlueBubblesAccountsMockModule() { + return { + resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig), + }; +} + +export function createBlueBubblesProbeMockModule() { + return { + getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), + }; +} + +export function installBlueBubblesFetchTestHooks(params: { + mockFetch: ReturnType; + privateApiStatusMock: { + mockReset: () => unknown; + mockReturnValue: (value: boolean | null) => unknown; + }; +}) { + beforeEach(() => { + vi.stubGlobal("fetch", params.mockFetch); + params.mockFetch.mockReset(); + params.privateApiStatusMock.mockReset(); + params.privateApiStatusMock.mockReturnValue(null); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); +} diff --git a/extensions/bluebubbles/src/test-mocks.ts b/extensions/bluebubbles/src/test-mocks.ts new file mode 100644 index 0000000000..d0a4801663 --- /dev/null +++ b/extensions/bluebubbles/src/test-mocks.ts @@ -0,0 +1,11 @@ +import { vi } from "vitest"; + +vi.mock("./accounts.js", async () => { + const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); + return createBlueBubblesAccountsMockModule(); +}); + +vi.mock("./probe.js", async () => { + const { createBlueBubblesProbeMockModule } = await import("./test-harness.js"); + return createBlueBubblesProbeMockModule(); +}); diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 3f9049fdc4..862410d09d 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -120,7 +120,7 @@ function isTailnetIPv4(address: string): boolean { return a === 100 && b >= 64 && b <= 127; } -function pickLanIPv4(): string | null { +function pickMatchingIPv4(predicate: (address: string) => boolean): string | null { const nets = os.networkInterfaces(); for (const entries of Object.values(nets)) { if (!entries) { @@ -137,7 +137,7 @@ function pickLanIPv4(): string | null { if (!address) { continue; } - if (isPrivateIPv4(address)) { + if (predicate(address)) { return address; } } @@ -145,29 +145,12 @@ function pickLanIPv4(): string | null { return null; } +function pickLanIPv4(): string | null { + return pickMatchingIPv4(isPrivateIPv4); +} + function pickTailnetIPv4(): string | null { - const nets = os.networkInterfaces(); - for (const entries of Object.values(nets)) { - if (!entries) { - continue; - } - for (const entry of entries) { - const family = entry?.family; - // Check for IPv4 (string "IPv4" on Node 18+, number 4 on older) - const isIpv4 = family === "IPv4" || String(family) === "4"; - if (!entry || entry.internal || !isIpv4) { - continue; - } - const address = entry.address?.trim() ?? ""; - if (!address) { - continue; - } - if (isTailnetIPv4(address)) { - return address; - } - } - } - return null; + return pickMatchingIPv4(isTailnetIPv4); } async function resolveTailnetHost(api: OpenClawPluginApi): Promise { diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index bc69b0926b..4159d00e2d 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -6,6 +6,7 @@ import { Readable } from "stream"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { getFeishuRuntime } from "./runtime.js"; +import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; export type DownloadImageResult = { @@ -283,15 +284,8 @@ export async function sendImageFeishu(params: { msg_type: "image", }, }); - - if (response.code !== 0) { - throw new Error(`Feishu image reply failed: ${response.msg || `code ${response.code}`}`); - } - - return { - messageId: response.data?.message_id ?? "unknown", - chatId: receiveId, - }; + assertFeishuMessageApiSuccess(response, "Feishu image reply failed"); + return toFeishuSendResult(response, receiveId); } const response = await client.im.message.create({ @@ -302,15 +296,8 @@ export async function sendImageFeishu(params: { msg_type: "image", }, }); - - if (response.code !== 0) { - throw new Error(`Feishu image send failed: ${response.msg || `code ${response.code}`}`); - } - - return { - messageId: response.data?.message_id ?? "unknown", - chatId: receiveId, - }; + assertFeishuMessageApiSuccess(response, "Feishu image send failed"); + return toFeishuSendResult(response, receiveId); } /** @@ -349,15 +336,8 @@ export async function sendFileFeishu(params: { msg_type: msgType, }, }); - - if (response.code !== 0) { - throw new Error(`Feishu file reply failed: ${response.msg || `code ${response.code}`}`); - } - - return { - messageId: response.data?.message_id ?? "unknown", - chatId: receiveId, - }; + assertFeishuMessageApiSuccess(response, "Feishu file reply failed"); + return toFeishuSendResult(response, receiveId); } const response = await client.im.message.create({ @@ -368,15 +348,8 @@ export async function sendFileFeishu(params: { msg_type: msgType, }, }); - - if (response.code !== 0) { - throw new Error(`Feishu file send failed: ${response.msg || `code ${response.code}`}`); - } - - return { - messageId: response.data?.message_id ?? "unknown", - chatId: receiveId, - }; + assertFeishuMessageApiSuccess(response, "Feishu file send failed"); + return toFeishuSendResult(response, receiveId); } /** diff --git a/extensions/feishu/src/send-result.ts b/extensions/feishu/src/send-result.ts new file mode 100644 index 0000000000..b9ba39ba0b --- /dev/null +++ b/extensions/feishu/src/send-result.ts @@ -0,0 +1,29 @@ +export type FeishuMessageApiResponse = { + code?: number; + msg?: string; + data?: { + message_id?: string; + }; +}; + +export function assertFeishuMessageApiSuccess( + response: FeishuMessageApiResponse, + errorPrefix: string, +) { + if (response.code !== 0) { + throw new Error(`${errorPrefix}: ${response.msg || `code ${response.code}`}`); + } +} + +export function toFeishuSendResult( + response: FeishuMessageApiResponse, + chatId: string, +): { + messageId: string; + chatId: string; +} { + return { + messageId: response.data?.message_id ?? "unknown", + chatId, + }; +} diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 4ca735361f..cc546c9723 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -5,6 +5,7 @@ import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js"; import { getFeishuRuntime } from "./runtime.js"; +import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; export type FeishuMessageInfo = { @@ -161,15 +162,8 @@ export async function sendMessageFeishu( msg_type: msgType, }, }); - - if (response.code !== 0) { - throw new Error(`Feishu reply failed: ${response.msg || `code ${response.code}`}`); - } - - return { - messageId: response.data?.message_id ?? "unknown", - chatId: receiveId, - }; + assertFeishuMessageApiSuccess(response, "Feishu reply failed"); + return toFeishuSendResult(response, receiveId); } const response = await client.im.message.create({ @@ -180,15 +174,8 @@ export async function sendMessageFeishu( msg_type: msgType, }, }); - - if (response.code !== 0) { - throw new Error(`Feishu send failed: ${response.msg || `code ${response.code}`}`); - } - - return { - messageId: response.data?.message_id ?? "unknown", - chatId: receiveId, - }; + assertFeishuMessageApiSuccess(response, "Feishu send failed"); + return toFeishuSendResult(response, receiveId); } export type SendFeishuCardParams = { @@ -223,15 +210,8 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") { - return null; - } - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) { - return null; - } - const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - return { to, accountId }; + return extractToolSend(args, "sendMessage"); }, handleAction: async ({ action, params, cfg, accountId }) => { const account = resolveGoogleChatAccount({ diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index d4c9aef436..809c65fb23 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -2,9 +2,11 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { createReplyPrefixOptions, - normalizeWebhookPath, readJsonBodyWithLimit, + registerWebhookTarget, + rejectNonPostWebhookRequest, resolveWebhookPath, + resolveWebhookTargets, requestBodyErrorToText, resolveMentionGatingWithBypass, } from "openclaw/plugin-sdk"; @@ -89,19 +91,7 @@ function warnDeprecatedUsersEmailEntries( } export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void { - const key = normalizeWebhookPath(target.path); - const normalizedTarget = { ...target, path: key }; - const existing = webhookTargets.get(key) ?? []; - const next = [...existing, normalizedTarget]; - webhookTargets.set(key, next); - return () => { - const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget); - if (updated.length > 0) { - webhookTargets.set(key, updated); - } else { - webhookTargets.delete(key); - } - }; + return registerWebhookTarget(webhookTargets, target).unregister; } function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined { @@ -123,17 +113,13 @@ export async function handleGoogleChatWebhookRequest( req: IncomingMessage, res: ServerResponse, ): Promise { - const url = new URL(req.url ?? "/", "http://localhost"); - const path = normalizeWebhookPath(url.pathname); - const targets = webhookTargets.get(path); - if (!targets || targets.length === 0) { + const resolved = resolveWebhookTargets(req, webhookTargets); + if (!resolved) { return false; } + const { targets } = resolved; - if (req.method !== "POST") { - res.statusCode = 405; - res.setHeader("Allow", "POST"); - res.end("Method Not Allowed"); + if (rejectNonPostWebhookRequest(req, res)) { return true; } diff --git a/extensions/googlechat/src/monitor.webhook-routing.test.ts b/extensions/googlechat/src/monitor.webhook-routing.test.ts index 16ed7eb3bb..b4325b3cb8 100644 --- a/extensions/googlechat/src/monitor.webhook-routing.test.ts +++ b/extensions/googlechat/src/monitor.webhook-routing.test.ts @@ -1,8 +1,9 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; +import type { IncomingMessage } from "node:http"; import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; +import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js"; import { verifyGoogleChatRequest } from "./auth.js"; import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js"; @@ -37,24 +38,6 @@ function createWebhookRequest(params: { return req; } -function createWebhookResponse(): ServerResponse & { body?: string } { - const headers: Record = {}; - const res = { - headersSent: false, - statusCode: 200, - setHeader: (key: string, value: string) => { - headers[key.toLowerCase()] = value; - return res; - }, - end: (body?: string) => { - res.headersSent = true; - res.body = body; - return res; - }, - } as unknown as ServerResponse & { body?: string }; - return res; -} - const baseAccount = (accountId: string) => ({ accountId, @@ -105,7 +88,7 @@ describe("Google Chat webhook routing", () => { const { sinkA, sinkB, unregister } = registerTwoTargets(); try { - const res = createWebhookResponse(); + const res = createMockServerResponse(); const handled = await handleGoogleChatWebhookRequest( createWebhookRequest({ authorization: "Bearer test-token", @@ -131,7 +114,7 @@ describe("Google Chat webhook routing", () => { const { sinkA, sinkB, unregister } = registerTwoTargets(); try { - const res = createWebhookResponse(); + const res = createMockServerResponse(); const handled = await handleGoogleChatWebhookRequest( createWebhookRequest({ authorization: "Bearer test-token", diff --git a/extensions/googlechat/src/resolve-target.test.ts b/extensions/googlechat/src/resolve-target.test.ts index 1631972bc6..6173ed4be9 100644 --- a/extensions/googlechat/src/resolve-target.test.ts +++ b/extensions/googlechat/src/resolve-target.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { installCommonResolveTargetErrorCases } from "../../shared/resolve-target-test-helpers.js"; vi.mock("openclaw/plugin-sdk", () => ({ getChatChannelMeta: () => ({ id: "googlechat", label: "Google Chat" }), @@ -92,47 +93,8 @@ describe("googlechat resolveTarget", () => { expect(result.to).toBe("users/user@example.com"); }); - it("should error on normalization failure with allowlist (implicit mode)", () => { - const result = resolveTarget({ - to: "invalid-target", - mode: "implicit", - allowFrom: ["spaces/BBB"], - }); - - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); - }); - - it("should error when no target provided with allowlist", () => { - const result = resolveTarget({ - to: undefined, - mode: "implicit", - allowFrom: ["spaces/BBB"], - }); - - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); - }); - - it("should error when no target and no allowlist", () => { - const result = resolveTarget({ - to: undefined, - mode: "explicit", - allowFrom: [], - }); - - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); - }); - - it("should handle whitespace-only target", () => { - const result = resolveTarget({ - to: " ", - mode: "explicit", - allowFrom: [], - }); - - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); + installCommonResolveTargetErrorCases({ + resolveTarget, + implicitAllowFrom: ["spaces/BBB"], }); }); diff --git a/extensions/irc/src/connect-options.ts b/extensions/irc/src/connect-options.ts new file mode 100644 index 0000000000..45d06bf0b6 --- /dev/null +++ b/extensions/irc/src/connect-options.ts @@ -0,0 +1,30 @@ +import type { ResolvedIrcAccount } from "./accounts.js"; +import type { IrcClientOptions } from "./client.js"; + +type IrcConnectOverrides = Omit< + Partial, + "host" | "port" | "tls" | "nick" | "username" | "realname" | "password" | "nickserv" +>; + +export function buildIrcConnectOptions( + account: ResolvedIrcAccount, + overrides: IrcConnectOverrides = {}, +): IrcClientOptions { + return { + host: account.host, + port: account.port, + tls: account.tls, + nick: account.nick, + username: account.username, + realname: account.realname, + password: account.password, + nickserv: { + enabled: account.config.nickserv?.enabled, + service: account.config.nickserv?.service, + password: account.config.nickserv?.password, + register: account.config.nickserv?.register, + registerEmail: account.config.nickserv?.registerEmail, + }, + ...overrides, + }; +} diff --git a/extensions/irc/src/monitor.ts b/extensions/irc/src/monitor.ts index bcfd88138e..0ca783375b 100644 --- a/extensions/irc/src/monitor.ts +++ b/extensions/irc/src/monitor.ts @@ -2,6 +2,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk"; import type { CoreConfig, IrcInboundMessage } from "./types.js"; import { resolveIrcAccount } from "./accounts.js"; import { connectIrcClient, type IrcClient } from "./client.js"; +import { buildIrcConnectOptions } from "./connect-options.js"; import { handleIrcInbound } from "./inbound.js"; import { isChannelTarget } from "./normalize.js"; import { makeIrcMessageId } from "./protocol.js"; @@ -59,91 +60,79 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto let client: IrcClient | null = null; - client = await connectIrcClient({ - host: account.host, - port: account.port, - tls: account.tls, - nick: account.nick, - username: account.username, - realname: account.realname, - password: account.password, - nickserv: { - enabled: account.config.nickserv?.enabled, - service: account.config.nickserv?.service, - password: account.config.nickserv?.password, - register: account.config.nickserv?.register, - registerEmail: account.config.nickserv?.registerEmail, - }, - channels: account.config.channels, - abortSignal: opts.abortSignal, - onLine: (line) => { - if (core.logging.shouldLogVerbose()) { - logger.debug?.(`[${account.accountId}] << ${line}`); - } - }, - onNotice: (text, target) => { - if (core.logging.shouldLogVerbose()) { - logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`); - } - }, - onError: (error) => { - logger.error(`[${account.accountId}] IRC error: ${error.message}`); - }, - onPrivmsg: async (event) => { - if (!client) { - return; - } - if (event.senderNick.toLowerCase() === client.nick.toLowerCase()) { - return; - } + client = await connectIrcClient( + buildIrcConnectOptions(account, { + channels: account.config.channels, + abortSignal: opts.abortSignal, + onLine: (line) => { + if (core.logging.shouldLogVerbose()) { + logger.debug?.(`[${account.accountId}] << ${line}`); + } + }, + onNotice: (text, target) => { + if (core.logging.shouldLogVerbose()) { + logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`); + } + }, + onError: (error) => { + logger.error(`[${account.accountId}] IRC error: ${error.message}`); + }, + onPrivmsg: async (event) => { + if (!client) { + return; + } + if (event.senderNick.toLowerCase() === client.nick.toLowerCase()) { + return; + } - const inboundTarget = resolveIrcInboundTarget({ - target: event.target, - senderNick: event.senderNick, - }); - const message: IrcInboundMessage = { - messageId: makeIrcMessageId(), - target: inboundTarget.target, - rawTarget: inboundTarget.rawTarget, - senderNick: event.senderNick, - senderUser: event.senderUser, - senderHost: event.senderHost, - text: event.text, - timestamp: Date.now(), - isGroup: inboundTarget.isGroup, - }; + const inboundTarget = resolveIrcInboundTarget({ + target: event.target, + senderNick: event.senderNick, + }); + const message: IrcInboundMessage = { + messageId: makeIrcMessageId(), + target: inboundTarget.target, + rawTarget: inboundTarget.rawTarget, + senderNick: event.senderNick, + senderUser: event.senderUser, + senderHost: event.senderHost, + text: event.text, + timestamp: Date.now(), + isGroup: inboundTarget.isGroup, + }; - core.channel.activity.record({ - channel: "irc", - accountId: account.accountId, - direction: "inbound", - at: message.timestamp, - }); + core.channel.activity.record({ + channel: "irc", + accountId: account.accountId, + direction: "inbound", + at: message.timestamp, + }); - if (opts.onMessage) { - await opts.onMessage(message, client); - return; - } + if (opts.onMessage) { + await opts.onMessage(message, client); + return; + } - await handleIrcInbound({ - message, - account, - config: cfg, - runtime, - connectedNick: client.nick, - sendReply: async (target, text) => { - client?.sendPrivmsg(target, text); - opts.statusSink?.({ lastOutboundAt: Date.now() }); - core.channel.activity.record({ - channel: "irc", - accountId: account.accountId, - direction: "outbound", - }); - }, - statusSink: opts.statusSink, - }); - }, - }); + await handleIrcInbound({ + message, + account, + config: cfg, + runtime, + connectedNick: client.nick, + sendReply: async (target, text) => { + client?.sendPrivmsg(target, text); + opts.statusSink?.({ lastOutboundAt: Date.now() }); + core.channel.activity.record({ + channel: "irc", + accountId: account.accountId, + direction: "outbound", + }); + }, + statusSink: opts.statusSink, + }); + }, + }), + ); logger.info( `[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`, diff --git a/extensions/irc/src/probe.ts b/extensions/irc/src/probe.ts index 95f7ea6a52..367741b6b3 100644 --- a/extensions/irc/src/probe.ts +++ b/extensions/irc/src/probe.ts @@ -1,6 +1,7 @@ import type { CoreConfig, IrcProbe } from "./types.js"; import { resolveIrcAccount } from "./accounts.js"; import { connectIrcClient } from "./client.js"; +import { buildIrcConnectOptions } from "./connect-options.js"; function formatError(err: unknown): string { if (err instanceof Error) { @@ -31,23 +32,11 @@ export async function probeIrc( const started = Date.now(); try { - const client = await connectIrcClient({ - host: account.host, - port: account.port, - tls: account.tls, - nick: account.nick, - username: account.username, - realname: account.realname, - password: account.password, - nickserv: { - enabled: account.config.nickserv?.enabled, - service: account.config.nickserv?.service, - password: account.config.nickserv?.password, - register: account.config.nickserv?.register, - registerEmail: account.config.nickserv?.registerEmail, - }, - connectTimeoutMs: opts?.timeoutMs ?? 8000, - }); + const client = await connectIrcClient( + buildIrcConnectOptions(account, { + connectTimeoutMs: opts?.timeoutMs ?? 8000, + }), + ); const elapsed = Date.now() - started; client.quit("probe"); return { diff --git a/extensions/irc/src/send.ts b/extensions/irc/src/send.ts index ebc4856463..70d8cb370b 100644 --- a/extensions/irc/src/send.ts +++ b/extensions/irc/src/send.ts @@ -2,6 +2,7 @@ import type { IrcClient } from "./client.js"; import type { CoreConfig } from "./types.js"; import { resolveIrcAccount } from "./accounts.js"; import { connectIrcClient } from "./client.js"; +import { buildIrcConnectOptions } from "./connect-options.js"; import { normalizeIrcMessagingTarget } from "./normalize.js"; import { makeIrcMessageId } from "./protocol.js"; import { getIrcRuntime } from "./runtime.js"; @@ -65,23 +66,11 @@ export async function sendMessageIrc( if (client?.isReady()) { client.sendPrivmsg(target, payload); } else { - const transient = await connectIrcClient({ - host: account.host, - port: account.port, - tls: account.tls, - nick: account.nick, - username: account.username, - realname: account.realname, - password: account.password, - nickserv: { - enabled: account.config.nickserv?.enabled, - service: account.config.nickserv?.service, - password: account.config.nickserv?.password, - register: account.config.nickserv?.register, - registerEmail: account.config.nickserv?.registerEmail, - }, - connectTimeoutMs: 12000, - }); + const transient = await connectIrcClient( + buildIrcConnectOptions(account, { + connectTimeoutMs: 12000, + }), + ); transient.sendPrivmsg(target, payload); transient.quit("sent"); } diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index fb27dfa9ed..778ab480ac 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -3,12 +3,8 @@ import type { CoreConfig } from "../../types.js"; import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; import { getMatrixRuntime } from "../../runtime.js"; import { getActiveMatrixClient } from "../active-client.js"; -import { - createMatrixClient, - isBunRuntime, - resolveMatrixAuth, - resolveSharedMatrixClient, -} from "../client.js"; +import { createPreparedMatrixClient } from "../client-bootstrap.js"; +import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js"; export function ensureNodeRuntime() { if (isBunRuntime()) { @@ -42,24 +38,10 @@ export async function resolveActionClient( cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, accountId, }); - const client = await createMatrixClient({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - encryption: auth.encryption, - localTimeoutMs: opts.timeoutMs, + const client = await createPreparedMatrixClient({ + auth, + timeoutMs: opts.timeoutMs, accountId, }); - if (auth.encryption && client.crypto) { - try { - const joinedRooms = await client.getJoinedRooms(); - await (client.crypto as { prepare: (rooms?: string[]) => Promise }).prepare( - joinedRooms, - ); - } catch { - // Ignore crypto prep failures for one-off actions. - } - } - await client.start(); return { client, stopOnDone: true }; } diff --git a/extensions/matrix/src/matrix/client-bootstrap.ts b/extensions/matrix/src/matrix/client-bootstrap.ts new file mode 100644 index 0000000000..6651229194 --- /dev/null +++ b/extensions/matrix/src/matrix/client-bootstrap.ts @@ -0,0 +1,39 @@ +import { createMatrixClient } from "./client.js"; + +type MatrixClientBootstrapAuth = { + homeserver: string; + userId: string; + accessToken: string; + encryption?: boolean; +}; + +type MatrixCryptoPrepare = { + prepare: (rooms?: string[]) => Promise; +}; + +type MatrixBootstrapClient = Awaited>; + +export async function createPreparedMatrixClient(opts: { + auth: MatrixClientBootstrapAuth; + timeoutMs?: number; + accountId?: string; +}): Promise { + const client = await createMatrixClient({ + homeserver: opts.auth.homeserver, + userId: opts.auth.userId, + accessToken: opts.auth.accessToken, + encryption: opts.auth.encryption, + localTimeoutMs: opts.timeoutMs, + accountId: opts.accountId, + }); + if (opts.auth.encryption && client.crypto) { + try { + const joinedRooms = await client.getJoinedRooms(); + await (client.crypto as MatrixCryptoPrepare).prepare(joinedRooms); + } catch { + // Ignore crypto prep failures for one-off requests. + } + } + await client.start(); + return client; +} diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts index 590dd5148a..11b045609a 100644 --- a/extensions/matrix/src/matrix/monitor/media.test.ts +++ b/extensions/matrix/src/matrix/monitor/media.test.ts @@ -22,14 +22,12 @@ describe("downloadMatrixMedia", () => { setMatrixRuntime(runtimeStub); }); - it("decrypts encrypted media when file payloads are present", async () => { + function makeEncryptedMediaFixture() { const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); - const client = { crypto: { decryptMedia }, mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; - const file = { url: "mxc://example/file", key: { @@ -43,6 +41,11 @@ describe("downloadMatrixMedia", () => { hashes: { sha256: "hash" }, v: "v2", }; + return { decryptMedia, client, file }; + } + + it("decrypts encrypted media when file payloads are present", async () => { + const { decryptMedia, client, file } = makeEncryptedMediaFixture(); const result = await downloadMatrixMedia({ client, @@ -64,26 +67,7 @@ describe("downloadMatrixMedia", () => { }); it("rejects encrypted media that exceeds maxBytes before decrypting", async () => { - const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); - - const client = { - crypto: { decryptMedia }, - mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), - } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; - - const file = { - url: "mxc://example/file", - key: { - kty: "oct", - key_ops: ["encrypt", "decrypt"], - alg: "A256CTR", - k: "secret", - ext: true, - }, - iv: "iv", - hashes: { sha256: "hash" }, - v: "v2", - }; + const { decryptMedia, client, file } = makeEncryptedMediaFixture(); await expect( downloadMatrixMedia({ diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index 87099a01da..caafbfd458 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -3,12 +3,8 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco import type { CoreConfig } from "../../types.js"; import { getMatrixRuntime } from "../../runtime.js"; import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js"; -import { - createMatrixClient, - isBunRuntime, - resolveMatrixAuth, - resolveSharedMatrixClient, -} from "../client.js"; +import { createPreparedMatrixClient } from "../client-bootstrap.js"; +import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js"; const getCore = () => getMatrixRuntime(); @@ -92,25 +88,10 @@ export async function resolveMatrixClient(opts: { return { client, stopOnDone: false }; } const auth = await resolveMatrixAuth({ accountId }); - const client = await createMatrixClient({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - encryption: auth.encryption, - localTimeoutMs: opts.timeoutMs, + const client = await createPreparedMatrixClient({ + auth, + timeoutMs: opts.timeoutMs, accountId, }); - if (auth.encryption && client.crypto) { - try { - const joinedRooms = await client.getJoinedRooms(); - await (client.crypto as { prepare: (rooms?: string[]) => Promise }).prepare( - joinedRooms, - ); - } catch { - // Ignore crypto prep failures for one-off sends; normal sync will retry. - } - } - // @vector-im/matrix-bot-sdk uses start() instead of startClient() - await client.start(); return { client, stopOnDone: true }; } diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index 7f3d6edf7e..c423513a6a 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -1,8 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; -import type WebSocket from "ws"; -import { Buffer } from "node:buffer"; - -export { createDedupeCache } from "openclaw/plugin-sdk"; +export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk"; export type ResponsePrefixContext = { model?: string; @@ -40,25 +37,6 @@ export function formatInboundFromLabel(params: { return `${directLabel} id:${directId}`; } -export function rawDataToString( - data: WebSocket.RawData, - encoding: BufferEncoding = "utf8", -): string { - if (typeof data === "string") { - return data; - } - if (Buffer.isBuffer(data)) { - return data.toString(encoding); - } - if (Array.isArray(data)) { - return Buffer.concat(data).toString(encoding); - } - if (data instanceof ArrayBuffer) { - return Buffer.from(data).toString(encoding); - } - return Buffer.from(String(data)).toString(encoding); -} - function normalizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); if (!trimmed) { diff --git a/extensions/msteams/src/conversation-store-fs.test.ts b/extensions/msteams/src/conversation-store-fs.test.ts index aa8feb8541..220a6f5dbf 100644 --- a/extensions/msteams/src/conversation-store-fs.test.ts +++ b/extensions/msteams/src/conversation-store-fs.test.ts @@ -1,4 +1,3 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -6,23 +5,11 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { StoredConversationReference } from "./conversation-store.js"; import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js"; import { setMSTeamsRuntime } from "./runtime.js"; - -const runtimeStub = { - state: { - resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => { - const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim(); - if (override) { - return override; - } - const resolvedHome = homedir ? homedir() : os.homedir(); - return path.join(resolvedHome, ".openclaw"); - }, - }, -} as unknown as PluginRuntime; +import { msteamsRuntimeStub } from "./test-runtime.js"; describe("msteams conversation store (fs)", () => { beforeEach(() => { - setMSTeamsRuntime(runtimeStub); + setMSTeamsRuntime(msteamsRuntimeStub); }); it("filters and prunes expired entries (but keeps legacy ones)", async () => { diff --git a/extensions/msteams/src/polls.test.ts b/extensions/msteams/src/polls.test.ts index 0508a25bb0..ab85194619 100644 --- a/extensions/msteams/src/polls.test.ts +++ b/extensions/msteams/src/polls.test.ts @@ -1,27 +1,14 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js"; import { setMSTeamsRuntime } from "./runtime.js"; - -const runtimeStub = { - state: { - resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => { - const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim(); - if (override) { - return override; - } - const resolvedHome = homedir ? homedir() : os.homedir(); - return path.join(resolvedHome, ".openclaw"); - }, - }, -} as unknown as PluginRuntime; +import { msteamsRuntimeStub } from "./test-runtime.js"; describe("msteams polls", () => { beforeEach(() => { - setMSTeamsRuntime(runtimeStub); + setMSTeamsRuntime(msteamsRuntimeStub); }); it("builds poll cards with fallback text", () => { diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index fa5c87ae2c..c4f801b033 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -374,6 +374,45 @@ async function sendTextWithMedia( }; } +type ProactiveActivityParams = { + adapter: MSTeamsProactiveContext["adapter"]; + appId: string; + ref: MSTeamsProactiveContext["ref"]; + activity: Record; + errorPrefix: string; +}; + +async function sendProactiveActivity({ + adapter, + appId, + ref, + activity, + errorPrefix, +}: ProactiveActivityParams): Promise { + const baseRef = buildConversationReference(ref); + const proactiveRef = { + ...baseRef, + activityId: undefined, + }; + + let messageId = "unknown"; + try { + await adapter.continueConversation(appId, proactiveRef, async (ctx) => { + const response = await ctx.sendActivity(activity); + messageId = extractMessageId(response) ?? "unknown"; + }); + return messageId; + } catch (err) { + const classification = classifyMSTeamsSendError(err); + const hint = formatMSTeamsSendErrorHint(classification); + const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : ""; + throw new Error( + `${errorPrefix} failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, + { cause: err }, + ); + } +} + /** * Send a poll (Adaptive Card) to a Teams conversation or user. */ @@ -409,27 +448,13 @@ export async function sendPollMSTeams( }; // Send poll via proactive conversation (Adaptive Cards require direct activity send) - const baseRef = buildConversationReference(ref); - const proactiveRef = { - ...baseRef, - activityId: undefined, - }; - - let messageId = "unknown"; - try { - await adapter.continueConversation(appId, proactiveRef, async (ctx) => { - const response = await ctx.sendActivity(activity); - messageId = extractMessageId(response) ?? "unknown"; - }); - } catch (err) { - const classification = classifyMSTeamsSendError(err); - const hint = formatMSTeamsSendErrorHint(classification); - const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : ""; - throw new Error( - `msteams poll send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, - { cause: err }, - ); - } + const messageId = await sendProactiveActivity({ + adapter, + appId, + ref, + activity, + errorPrefix: "msteams poll send", + }); log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId }); @@ -469,27 +494,13 @@ export async function sendAdaptiveCardMSTeams( }; // Send card via proactive conversation - const baseRef = buildConversationReference(ref); - const proactiveRef = { - ...baseRef, - activityId: undefined, - }; - - let messageId = "unknown"; - try { - await adapter.continueConversation(appId, proactiveRef, async (ctx) => { - const response = await ctx.sendActivity(activity); - messageId = extractMessageId(response) ?? "unknown"; - }); - } catch (err) { - const classification = classifyMSTeamsSendError(err); - const hint = formatMSTeamsSendErrorHint(classification); - const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : ""; - throw new Error( - `msteams card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, - { cause: err }, - ); - } + const messageId = await sendProactiveActivity({ + adapter, + appId, + ref, + activity, + errorPrefix: "msteams card send", + }); log.info("sent adaptive card", { conversationId, messageId }); diff --git a/extensions/msteams/src/store-fs.ts b/extensions/msteams/src/store-fs.ts index c827a955f1..c13c7dd55e 100644 --- a/extensions/msteams/src/store-fs.ts +++ b/extensions/msteams/src/store-fs.ts @@ -1,7 +1,5 @@ -import crypto from "node:crypto"; import fs from "node:fs"; -import path from "node:path"; -import { safeParseJson } from "openclaw/plugin-sdk"; +import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk"; import { withFileLock as withPathLock } from "./file-lock.js"; const STORE_LOCK_OPTIONS = { @@ -19,31 +17,11 @@ export async function readJsonFile( filePath: string, fallback: T, ): Promise<{ value: T; exists: boolean }> { - try { - const raw = await fs.promises.readFile(filePath, "utf-8"); - const parsed = safeParseJson(raw); - if (parsed == null) { - return { value: fallback, exists: true }; - } - return { value: parsed, exists: true }; - } catch (err) { - const code = (err as { code?: string }).code; - if (code === "ENOENT") { - return { value: fallback, exists: false }; - } - return { value: fallback, exists: false }; - } + return await readJsonFileWithFallback(filePath, fallback); } export async function writeJsonFile(filePath: string, value: unknown): Promise { - const dir = path.dirname(filePath); - await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); - const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); - await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { - encoding: "utf-8", - }); - await fs.promises.chmod(tmp, 0o600); - await fs.promises.rename(tmp, filePath); + await writeJsonFileAtomically(filePath, value); } async function ensureJsonFile(filePath: string, fallback: unknown) { diff --git a/extensions/msteams/src/test-runtime.ts b/extensions/msteams/src/test-runtime.ts new file mode 100644 index 0000000000..d190f1a740 --- /dev/null +++ b/extensions/msteams/src/test-runtime.ts @@ -0,0 +1,16 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import os from "node:os"; +import path from "node:path"; + +export const msteamsRuntimeStub = { + state: { + resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => { + const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim(); + if (override) { + return override; + } + const resolvedHome = homedir ? homedir() : os.homedir(); + return path.join(resolvedHome, ".openclaw"); + }, + }, +} as unknown as PluginRuntime; diff --git a/extensions/nextcloud-talk/src/monitor.read-body.test.ts b/extensions/nextcloud-talk/src/monitor.read-body.test.ts index c54096a65d..950ea73f2d 100644 --- a/extensions/nextcloud-talk/src/monitor.read-body.test.ts +++ b/extensions/nextcloud-talk/src/monitor.read-body.test.ts @@ -1,38 +1,16 @@ -import type { IncomingMessage } from "node:http"; -import { EventEmitter } from "node:events"; import { describe, expect, it } from "vitest"; +import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js"; import { readNextcloudTalkWebhookBody } from "./monitor.js"; -function createMockRequest(chunks: string[]): IncomingMessage { - const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void }; - req.destroyed = false; - req.headers = {}; - req.destroy = () => { - req.destroyed = true; - }; - - void Promise.resolve().then(() => { - for (const chunk of chunks) { - req.emit("data", Buffer.from(chunk, "utf-8")); - if (req.destroyed) { - return; - } - } - req.emit("end"); - }); - - return req; -} - describe("readNextcloudTalkWebhookBody", () => { it("reads valid body within max bytes", async () => { - const req = createMockRequest(['{"type":"Create"}']); + const req = createMockIncomingRequest(['{"type":"Create"}']); const body = await readNextcloudTalkWebhookBody(req, 1024); expect(body).toBe('{"type":"Create"}'); }); it("rejects when payload exceeds max bytes", async () => { - const req = createMockRequest(["x".repeat(300)]); + const req = createMockIncomingRequest(["x".repeat(300)]); await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge"); }); }); diff --git a/extensions/nostr/src/metrics.ts b/extensions/nostr/src/metrics.ts index 11030e5bc3..7b648400a8 100644 --- a/extensions/nostr/src/metrics.ts +++ b/extensions/nostr/src/metrics.ts @@ -50,6 +50,24 @@ export type MetricName = | DecryptMetricName | MemoryMetricName; +type RelayMetrics = { + connects: number; + disconnects: number; + reconnects: number; + errors: number; + messagesReceived: { + event: number; + eose: number; + closed: number; + notice: number; + ok: number; + auth: number; + }; + circuitBreakerState: "closed" | "open" | "half_open"; + circuitBreakerOpens: number; + circuitBreakerCloses: number; +}; + // ============================================================================ // Metric Event // ============================================================================ @@ -93,26 +111,7 @@ export interface MetricsSnapshot { }; /** Relay stats by URL */ - relays: Record< - string, - { - connects: number; - disconnects: number; - reconnects: number; - errors: number; - messagesReceived: { - event: number; - eose: number; - closed: number; - notice: number; - ok: number; - auth: number; - }; - circuitBreakerState: "closed" | "open" | "half_open"; - circuitBreakerOpens: number; - circuitBreakerCloses: number; - } - >; + relays: Record; /** Rate limiting stats */ rateLimiting: { @@ -174,26 +173,7 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics { }; // Per-relay stats - const relays = new Map< - string, - { - connects: number; - disconnects: number; - reconnects: number; - errors: number; - messagesReceived: { - event: number; - eose: number; - closed: number; - notice: number; - ok: number; - auth: number; - }; - circuitBreakerState: "closed" | "open" | "half_open"; - circuitBreakerOpens: number; - circuitBreakerCloses: number; - } - >(); + const relays = new Map(); // Rate limiting stats const rateLimiting = { diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index d94d4ec604..c7f93e57a6 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -112,6 +112,23 @@ function createMockContext(overrides?: Partial): NostrP }; } +function mockSuccessfulProfileImport() { + vi.mocked(importProfileFromRelays).mockResolvedValue({ + ok: true, + profile: { + name: "imported", + displayName: "Imported User", + }, + event: { + id: "evt123", + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + created_at: 1234567890, + }, + relaysQueried: ["wss://relay.damus.io"], + sourceRelay: "wss://relay.damus.io", + }); +} + // ============================================================================ // Tests // ============================================================================ @@ -342,20 +359,7 @@ describe("nostr-profile-http", () => { const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {}); const res = createMockResponse(); - vi.mocked(importProfileFromRelays).mockResolvedValue({ - ok: true, - profile: { - name: "imported", - displayName: "Imported User", - }, - event: { - id: "evt123", - pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", - created_at: 1234567890, - }, - relaysQueried: ["wss://relay.damus.io"], - sourceRelay: "wss://relay.damus.io", - }); + mockSuccessfulProfileImport(); await handler(req, res); @@ -406,20 +410,7 @@ describe("nostr-profile-http", () => { }); const res = createMockResponse(); - vi.mocked(importProfileFromRelays).mockResolvedValue({ - ok: true, - profile: { - name: "imported", - displayName: "Imported User", - }, - event: { - id: "evt123", - pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", - created_at: 1234567890, - }, - relaysQueried: ["wss://relay.damus.io"], - sourceRelay: "wss://relay.damus.io", - }); + mockSuccessfulProfileImport(); await handler(req, res); diff --git a/extensions/nostr/src/seen-tracker.ts b/extensions/nostr/src/seen-tracker.ts index 7c9033c491..fc5dc05020 100644 --- a/extensions/nostr/src/seen-tracker.ts +++ b/extensions/nostr/src/seen-tracker.ts @@ -137,6 +137,27 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker { entries.delete(idToEvict); } + function insertAtFront(id: string, seenAt: number): void { + const newEntry: Entry = { + seenAt, + prev: null, + next: head, + }; + + if (head) { + const headEntry = entries.get(head); + if (headEntry) { + headEntry.prev = id; + } + } + + entries.set(id, newEntry); + head = id; + if (!tail) { + tail = id; + } + } + // Prune expired entries function pruneExpired(): void { const now = Date.now(); @@ -180,25 +201,7 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker { evictLRU(); } - // Add new entry at front - const newEntry: Entry = { - seenAt: now, - prev: null, - next: head, - }; - - if (head) { - const headEntry = entries.get(head); - if (headEntry) { - headEntry.prev = id; - } - } - - entries.set(id, newEntry); - head = id; - if (!tail) { - tail = id; - } + insertAtFront(id, now); } function has(id: string): boolean { @@ -268,24 +271,7 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker { for (let i = ids.length - 1; i >= 0; i--) { const id = ids[i]; if (!entries.has(id) && entries.size < maxEntries) { - const newEntry: Entry = { - seenAt: now, - prev: null, - next: head, - }; - - if (head) { - const headEntry = entries.get(head); - if (headEntry) { - headEntry.prev = id; - } - } - - entries.set(id, newEntry); - head = id; - if (!tail) { - tail = id; - } + insertAtFront(id, now); } } } diff --git a/extensions/shared/resolve-target-test-helpers.ts b/extensions/shared/resolve-target-test-helpers.ts new file mode 100644 index 0000000000..282c5e82e5 --- /dev/null +++ b/extensions/shared/resolve-target-test-helpers.ts @@ -0,0 +1,66 @@ +import { expect, it } from "vitest"; + +type ResolveTargetMode = "explicit" | "implicit" | "heartbeat"; + +type ResolveTargetResult = { + ok: boolean; + to?: string; + error?: unknown; +}; + +type ResolveTargetFn = (params: { + to?: string; + mode: ResolveTargetMode; + allowFrom: string[]; +}) => ResolveTargetResult; + +export function installCommonResolveTargetErrorCases(params: { + resolveTarget: ResolveTargetFn; + implicitAllowFrom: string[]; +}) { + const { resolveTarget, implicitAllowFrom } = params; + + it("should error on normalization failure with allowlist (implicit mode)", () => { + const result = resolveTarget({ + to: "invalid-target", + mode: "implicit", + allowFrom: implicitAllowFrom, + }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("should error when no target provided with allowlist", () => { + const result = resolveTarget({ + to: undefined, + mode: "implicit", + allowFrom: implicitAllowFrom, + }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("should error when no target and no allowlist", () => { + const result = resolveTarget({ + to: undefined, + mode: "explicit", + allowFrom: [], + }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("should handle whitespace-only target", () => { + const result = resolveTarget({ + to: " ", + mode: "explicit", + allowFrom: [], + }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); +} diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index ba4ce75f01..d8f40efe3d 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -6,6 +6,7 @@ import { extractSlackToolSend, formatPairingApproveHint, getChatChannelMeta, + handleSlackMessageAction, listSlackMessageActions, listSlackAccountIds, listSlackDirectoryGroupsFromConfig, @@ -15,8 +16,6 @@ import { normalizeAccountId, normalizeSlackMessagingTarget, PAIRING_APPROVED_MESSAGE, - readNumberParam, - readStringParam, resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackReplyToMode, @@ -234,151 +233,13 @@ export const slackPlugin: ChannelPlugin = { actions: { listActions: ({ cfg }) => listSlackMessageActions(cfg), extractToolSend: ({ args }) => extractSlackToolSend(args), - handleAction: async ({ action, params, cfg, accountId, toolContext }) => { - const resolveChannelId = () => - readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }); - - if (action === "send") { - const to = readStringParam(params, "to", { required: true }); - const content = readStringParam(params, "message", { - required: true, - allowEmpty: true, - }); - const mediaUrl = readStringParam(params, "media", { trim: false }); - const threadId = readStringParam(params, "threadId"); - const replyTo = readStringParam(params, "replyTo"); - return await getSlackRuntime().channel.slack.handleSlackAction( - { - action: "sendMessage", - to, - content, - mediaUrl: mediaUrl ?? undefined, - accountId: accountId ?? undefined, - threadTs: threadId ?? replyTo ?? undefined, - }, - cfg, - toolContext, - ); - } - - if (action === "react") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = typeof params.remove === "boolean" ? params.remove : undefined; - return await getSlackRuntime().channel.slack.handleSlackAction( - { - action: "react", - channelId: resolveChannelId(), - messageId, - emoji, - remove, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "reactions") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const limit = readNumberParam(params, "limit", { integer: true }); - return await getSlackRuntime().channel.slack.handleSlackAction( - { - action: "reactions", - channelId: resolveChannelId(), - messageId, - limit, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "read") { - const limit = readNumberParam(params, "limit", { integer: true }); - return await getSlackRuntime().channel.slack.handleSlackAction( - { - action: "readMessages", - channelId: resolveChannelId(), - limit, - before: readStringParam(params, "before"), - after: readStringParam(params, "after"), - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "edit") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const content = readStringParam(params, "message", { required: true }); - return await getSlackRuntime().channel.slack.handleSlackAction( - { - action: "editMessage", - channelId: resolveChannelId(), - messageId, - content, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "delete") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - return await getSlackRuntime().channel.slack.handleSlackAction( - { - action: "deleteMessage", - channelId: resolveChannelId(), - messageId, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "pin" || action === "unpin" || action === "list-pins") { - const messageId = - action === "list-pins" - ? undefined - : readStringParam(params, "messageId", { required: true }); - return await getSlackRuntime().channel.slack.handleSlackAction( - { - action: - action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", - channelId: resolveChannelId(), - messageId, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "member-info") { - const userId = readStringParam(params, "userId", { required: true }); - return await getSlackRuntime().channel.slack.handleSlackAction( - { action: "memberInfo", userId, accountId: accountId ?? undefined }, - cfg, - ); - } - - if (action === "emoji-list") { - const limit = readNumberParam(params, "limit", { integer: true }); - return await getSlackRuntime().channel.slack.handleSlackAction( - { action: "emojiList", limit, accountId: accountId ?? undefined }, - cfg, - ); - } - - throw new Error(`Action ${action} is not supported for provider ${meta.id}.`); - }, + handleAction: async (ctx) => + await handleSlackMessageAction({ + providerId: meta.id, + ctx, + invoke: async (action, cfg, toolContext) => + await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), + }), }, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), diff --git a/extensions/tlon/src/account-fields.ts b/extensions/tlon/src/account-fields.ts new file mode 100644 index 0000000000..6eea0c58af --- /dev/null +++ b/extensions/tlon/src/account-fields.ts @@ -0,0 +1,25 @@ +export type TlonAccountFieldsInput = { + ship?: string; + url?: string; + code?: string; + allowPrivateNetwork?: boolean; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; +}; + +export function buildTlonAccountFields(input: TlonAccountFieldsInput) { + return { + ...(input.ship ? { ship: input.ship } : {}), + ...(input.url ? { url: input.url } : {}), + ...(input.code ? { code: input.code } : {}), + ...(typeof input.allowPrivateNetwork === "boolean" + ? { allowPrivateNetwork: input.allowPrivateNetwork } + : {}), + ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), + ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), + ...(typeof input.autoDiscoverChannels === "boolean" + ? { autoDiscoverChannels: input.autoDiscoverChannels } + : {}), + }; +} diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 323d41d0ce..cc7f14ea3e 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -10,6 +10,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId, } from "openclaw/plugin-sdk"; +import { buildTlonAccountFields } from "./account-fields.js"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { monitorTlonProvider } from "./monitor/index.js"; import { tlonOnboardingAdapter } from "./onboarding.js"; @@ -47,19 +48,7 @@ function applyTlonSetupConfig(params: { }); const base = namedConfig.channels?.tlon ?? {}; - const payload = { - ...(input.ship ? { ship: input.ship } : {}), - ...(input.url ? { url: input.url } : {}), - ...(input.code ? { code: input.code } : {}), - ...(typeof input.allowPrivateNetwork === "boolean" - ? { allowPrivateNetwork: input.allowPrivateNetwork } - : {}), - ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), - ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), - ...(typeof input.autoDiscoverChannels === "boolean" - ? { autoDiscoverChannels: input.autoDiscoverChannels } - : {}), - }; + const payload = buildTlonAccountFields(input); if (useDefault) { return { diff --git a/extensions/tlon/src/onboarding.ts b/extensions/tlon/src/onboarding.ts index 9d2d6e25e0..5d01dd1dae 100644 --- a/extensions/tlon/src/onboarding.ts +++ b/extensions/tlon/src/onboarding.ts @@ -8,6 +8,7 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk"; import type { TlonResolvedAccount } from "./types.js"; +import { buildTlonAccountFields } from "./account-fields.js"; import { listTlonAccountIds, resolveTlonAccount } from "./types.js"; import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js"; @@ -34,6 +35,11 @@ function applyAccountConfig(params: { const { cfg, accountId, input } = params; const useDefault = accountId === DEFAULT_ACCOUNT_ID; const base = cfg.channels?.tlon ?? {}; + const nextValues = { + enabled: true, + ...(input.name ? { name: input.name } : {}), + ...buildTlonAccountFields(input), + }; if (useDefault) { return { @@ -42,19 +48,7 @@ function applyAccountConfig(params: { ...cfg.channels, tlon: { ...base, - enabled: true, - ...(input.name ? { name: input.name } : {}), - ...(input.ship ? { ship: input.ship } : {}), - ...(input.url ? { url: input.url } : {}), - ...(input.code ? { code: input.code } : {}), - ...(typeof input.allowPrivateNetwork === "boolean" - ? { allowPrivateNetwork: input.allowPrivateNetwork } - : {}), - ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), - ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), - ...(typeof input.autoDiscoverChannels === "boolean" - ? { autoDiscoverChannels: input.autoDiscoverChannels } - : {}), + ...nextValues, }, }, }; @@ -73,19 +67,7 @@ function applyAccountConfig(params: { ...(base as { accounts?: Record> }).accounts?.[ accountId ], - enabled: true, - ...(input.name ? { name: input.name } : {}), - ...(input.ship ? { ship: input.ship } : {}), - ...(input.url ? { url: input.url } : {}), - ...(input.code ? { code: input.code } : {}), - ...(typeof input.allowPrivateNetwork === "boolean" - ? { allowPrivateNetwork: input.allowPrivateNetwork } - : {}), - ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), - ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), - ...(typeof input.autoDiscoverChannels === "boolean" - ? { autoDiscoverChannels: input.autoDiscoverChannels } - : {}), + ...nextValues, }, }, }, diff --git a/extensions/twitch/src/outbound.test.ts b/extensions/twitch/src/outbound.test.ts index a807b1a873..bf149f3b5a 100644 --- a/extensions/twitch/src/outbound.test.ts +++ b/extensions/twitch/src/outbound.test.ts @@ -9,9 +9,13 @@ * - Abort signal handling */ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { twitchOutbound } from "./outbound.js"; +import { + BASE_TWITCH_TEST_ACCOUNT, + installTwitchTestHooks, + makeTwitchTestConfig, +} from "./test-fixtures.js"; // Mock dependencies vi.mock("./config.js", () => ({ @@ -35,29 +39,12 @@ vi.mock("./utils/twitch.js", () => ({ describe("outbound", () => { const mockAccount = { - username: "testbot", + ...BASE_TWITCH_TEST_ACCOUNT, accessToken: "oauth:test123", - clientId: "test-client-id", - channel: "#testchannel", }; - const mockConfig = { - channels: { - twitch: { - accounts: { - default: mockAccount, - }, - }, - }, - } as unknown as OpenClawConfig; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); + const mockConfig = makeTwitchTestConfig(mockAccount); + installTwitchTestHooks(); describe("metadata", () => { it("should have direct delivery mode", () => { diff --git a/extensions/twitch/src/send.test.ts b/extensions/twitch/src/send.test.ts index 8afef78202..bfe16dd90b 100644 --- a/extensions/twitch/src/send.test.ts +++ b/extensions/twitch/src/send.test.ts @@ -10,9 +10,13 @@ * - Registry integration */ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { sendMessageTwitchInternal } from "./send.js"; +import { + BASE_TWITCH_TEST_ACCOUNT, + installTwitchTestHooks, + makeTwitchTestConfig, +} from "./test-fixtures.js"; // Mock dependencies vi.mock("./config.js", () => ({ @@ -43,29 +47,12 @@ describe("send", () => { }; const mockAccount = { - username: "testbot", + ...BASE_TWITCH_TEST_ACCOUNT, token: "oauth:test123", - clientId: "test-client-id", - channel: "#testchannel", }; - const mockConfig = { - channels: { - twitch: { - accounts: { - default: mockAccount, - }, - }, - }, - } as unknown as OpenClawConfig; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); + const mockConfig = makeTwitchTestConfig(mockAccount); + installTwitchTestHooks(); describe("sendMessageTwitchInternal", () => { it("should send a message successfully", async () => { diff --git a/extensions/twitch/src/test-fixtures.ts b/extensions/twitch/src/test-fixtures.ts new file mode 100644 index 0000000000..c2eb4df28f --- /dev/null +++ b/extensions/twitch/src/test-fixtures.ts @@ -0,0 +1,30 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { afterEach, beforeEach, vi } from "vitest"; + +export const BASE_TWITCH_TEST_ACCOUNT = { + username: "testbot", + clientId: "test-client-id", + channel: "#testchannel", +}; + +export function makeTwitchTestConfig(account: Record): OpenClawConfig { + return { + channels: { + twitch: { + accounts: { + default: account, + }, + }, + }, + } as unknown as OpenClawConfig; +} + +export function installTwitchTestHooks() { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); +} diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index b931d6b8f1..e1a4524d28 100644 --- a/extensions/voice-call/src/providers/telnyx.test.ts +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -22,6 +22,37 @@ function decodeBase64Url(input: string): Buffer { return Buffer.from(padded, "base64"); } +function expectWebhookVerificationSucceeds(params: { + publicKey: string; + privateKey: crypto.KeyObject; +}) { + const provider = new TelnyxProvider( + { apiKey: "KEY123", connectionId: "CONN456", publicKey: params.publicKey }, + { skipVerification: false }, + ); + + const rawBody = JSON.stringify({ + event_type: "call.initiated", + payload: { call_control_id: "x" }, + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signedPayload = `${timestamp}|${rawBody}`; + const signature = crypto + .sign(null, Buffer.from(signedPayload), params.privateKey) + .toString("base64"); + + const result = provider.verifyWebhook( + createCtx({ + rawBody, + headers: { + "telnyx-signature-ed25519": signature, + "telnyx-timestamp": timestamp, + }, + }), + ); + expect(result.ok).toBe(true); +} + describe("TelnyxProvider.verifyWebhook", () => { it("fails closed when public key is missing and skipVerification is false", () => { const provider = new TelnyxProvider( @@ -63,59 +94,13 @@ describe("TelnyxProvider.verifyWebhook", () => { const rawPublicKey = decodeBase64Url(jwk.x as string); const rawPublicKeyBase64 = rawPublicKey.toString("base64"); - - const provider = new TelnyxProvider( - { apiKey: "KEY123", connectionId: "CONN456", publicKey: rawPublicKeyBase64 }, - { skipVerification: false }, - ); - - const rawBody = JSON.stringify({ - event_type: "call.initiated", - payload: { call_control_id: "x" }, - }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const signedPayload = `${timestamp}|${rawBody}`; - const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); - - const result = provider.verifyWebhook( - createCtx({ - rawBody, - headers: { - "telnyx-signature-ed25519": signature, - "telnyx-timestamp": timestamp, - }, - }), - ); - expect(result.ok).toBe(true); + expectWebhookVerificationSucceeds({ publicKey: rawPublicKeyBase64, privateKey }); }); it("verifies a valid signature with a DER SPKI public key (Base64)", () => { const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer; const spkiDerBase64 = spkiDer.toString("base64"); - - const provider = new TelnyxProvider( - { apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDerBase64 }, - { skipVerification: false }, - ); - - const rawBody = JSON.stringify({ - event_type: "call.initiated", - payload: { call_control_id: "x" }, - }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const signedPayload = `${timestamp}|${rawBody}`; - const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); - - const result = provider.verifyWebhook( - createCtx({ - rawBody, - headers: { - "telnyx-signature-ed25519": signature, - "telnyx-timestamp": timestamp, - }, - }), - ); - expect(result.ok).toBe(true); + expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey }); }); }); diff --git a/extensions/whatsapp/src/resolve-target.test.ts b/extensions/whatsapp/src/resolve-target.test.ts index e4dfc42e41..4a5930abed 100644 --- a/extensions/whatsapp/src/resolve-target.test.ts +++ b/extensions/whatsapp/src/resolve-target.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { installCommonResolveTargetErrorCases } from "../../shared/resolve-target-test-helpers.js"; vi.mock("openclaw/plugin-sdk", () => ({ getChatChannelMeta: () => ({ id: "whatsapp", label: "WhatsApp" }), @@ -147,47 +148,8 @@ describe("whatsapp resolveTarget", () => { expect(result.error).toBeDefined(); }); - it("should error on normalization failure with allowlist (implicit mode)", () => { - const result = resolveTarget({ - to: "invalid-target", - mode: "implicit", - allowFrom: ["5511999999999"], - }); - - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); - }); - - it("should error when no target provided with allowlist", () => { - const result = resolveTarget({ - to: undefined, - mode: "implicit", - allowFrom: ["5511999999999"], - }); - - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); - }); - - it("should error when no target and no allowlist", () => { - const result = resolveTarget({ - to: undefined, - mode: "explicit", - allowFrom: [], - }); - - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); - }); - - it("should handle whitespace-only target", () => { - const result = resolveTarget({ - to: " ", - mode: "explicit", - allowFrom: [], - }); - - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); + installCommonResolveTargetErrorCases({ + resolveTarget, + implicitAllowFrom: ["5511999999999"], }); }); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 1ee2efb531..847e6c3b6f 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -2,9 +2,12 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk"; import { createReplyPrefixOptions, - normalizeWebhookPath, readJsonBodyWithLimit, + registerWebhookTarget, + rejectNonPostWebhookRequest, + resolveSenderCommandAuthorization, resolveWebhookPath, + resolveWebhookTargets, requestBodyErrorToText, } from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; @@ -83,36 +86,20 @@ type WebhookTarget = { const webhookTargets = new Map(); export function registerZaloWebhookTarget(target: WebhookTarget): () => void { - const key = normalizeWebhookPath(target.path); - const normalizedTarget = { ...target, path: key }; - const existing = webhookTargets.get(key) ?? []; - const next = [...existing, normalizedTarget]; - webhookTargets.set(key, next); - return () => { - const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget); - if (updated.length > 0) { - webhookTargets.set(key, updated); - } else { - webhookTargets.delete(key); - } - }; + return registerWebhookTarget(webhookTargets, target).unregister; } export async function handleZaloWebhookRequest( req: IncomingMessage, res: ServerResponse, ): Promise { - const url = new URL(req.url ?? "/", "http://localhost"); - const path = normalizeWebhookPath(url.pathname); - const targets = webhookTargets.get(path); - if (!targets || targets.length === 0) { + const resolved = resolveWebhookTargets(req, webhookTargets); + if (!resolved) { return false; } + const { targets } = resolved; - if (req.method !== "POST") { - res.statusCode = 405; - res.setHeader("Allow", "POST"); - res.end("Method Not Allowed"); + if (rejectNonPostWebhookRequest(req, res)) { return true; } @@ -402,22 +389,20 @@ async function processMessageWithPipeline(params: { const dmPolicy = account.config.dmPolicy ?? "pairing"; const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); const rawBody = text?.trim() || (mediaPath ? "" : ""); - const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); - const storeAllowFrom = - !isGroup && (dmPolicy !== "open" || shouldComputeAuth) - ? await core.channel.pairing.readAllowFromStore("zalo").catch(() => []) - : []; - const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]; - const useAccessGroups = config.commands?.useAccessGroups !== false; - const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom); - const commandAuthorized = shouldComputeAuth - ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, - ], - }) - : undefined; + const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({ + cfg: config, + rawBody, + isGroup, + dmPolicy, + configuredAllowFrom: configAllowFrom, + senderId, + isSenderAllowed, + readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"), + shouldComputeCommandAuthorized: (body, cfg) => + core.channel.commands.shouldComputeCommandAuthorized(body, cfg), + resolveCommandAuthorizedFromAuthorizers: (params) => + core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params), + }); if (!isGroup) { if (dmPolicy === "disabled") { diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 8ef712c8b9..9d341cb67f 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,6 +1,11 @@ import type { ChildProcess } from "node:child_process"; import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk"; -import { createReplyPrefixOptions, mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk"; +import { + createReplyPrefixOptions, + mergeAllowlist, + resolveSenderCommandAuthorization, + summarizeMapping, +} from "openclaw/plugin-sdk"; import type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser } from "./send.js"; @@ -192,22 +197,20 @@ async function processMessage( const dmPolicy = account.config.dmPolicy ?? "pairing"; const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); const rawBody = content.trim(); - const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); - const storeAllowFrom = - !isGroup && (dmPolicy !== "open" || shouldComputeAuth) - ? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => []) - : []; - const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]; - const useAccessGroups = config.commands?.useAccessGroups !== false; - const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom); - const commandAuthorized = shouldComputeAuth - ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, - ], - }) - : undefined; + const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({ + cfg: config, + rawBody, + isGroup, + dmPolicy, + configuredAllowFrom: configAllowFrom, + senderId, + isSenderAllowed, + readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalouser"), + shouldComputeCommandAuthorized: (body, cfg) => + core.channel.commands.shouldComputeCommandAuthorized(body, cfg), + resolveCommandAuthorizedFromAuthorizers: (params) => + core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params), + }); if (!isGroup) { if (dmPolicy === "disabled") {