From 646817dd808b214eab635599a5c4909202f7bbd3 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:20:44 -0600 Subject: [PATCH] fix(outbound): unify resolved cfg threading across send paths (#33987) --- CHANGELOG.md | 1 + extensions/discord/src/channel.ts | 8 +- .../googlechat/src/resolve-target.test.ts | 129 +++++++++++- extensions/imessage/src/channel.ts | 1 + extensions/irc/src/channel.ts | 6 +- extensions/irc/src/send.test.ts | 116 +++++++++++ extensions/irc/src/send.ts | 3 +- .../line/src/channel.sendPayload.test.ts | 7 +- extensions/line/src/channel.ts | 16 +- extensions/matrix/src/matrix/send.test.ts | 86 +++++++- extensions/matrix/src/matrix/send.ts | 6 +- extensions/matrix/src/matrix/send/client.ts | 14 +- extensions/matrix/src/matrix/send/types.ts | 1 + extensions/matrix/src/outbound.test.ts | 159 +++++++++++++++ extensions/matrix/src/outbound.ts | 9 +- extensions/mattermost/src/channel.test.ts | 31 +++ extensions/mattermost/src/channel.ts | 6 +- .../mattermost/src/mattermost/send.test.ts | 62 +++++- extensions/mattermost/src/mattermost/send.ts | 5 +- extensions/msteams/src/outbound.test.ts | 131 ++++++++++++ extensions/nextcloud-talk/src/channel.ts | 6 +- extensions/nextcloud-talk/src/send.test.ts | 104 ++++++++++ extensions/nextcloud-talk/src/send.ts | 5 +- extensions/nostr/src/channel.outbound.test.ts | 88 ++++++++ extensions/nostr/src/channel.ts | 4 +- .../signal/src/channel.outbound.test.ts | 63 ++++++ extensions/signal/src/channel.ts | 1 + extensions/slack/src/channel.ts | 2 + extensions/telegram/src/channel.ts | 8 +- .../whatsapp/src/channel.outbound.test.ts | 46 +++++ extensions/whatsapp/src/channel.ts | 18 +- src/agents/tools/discord-actions-messaging.ts | 29 ++- src/agents/tools/discord-actions.test.ts | 6 +- src/agents/tools/discord-actions.ts | 2 +- src/channels/plugins/actions/actions.test.ts | 38 ++-- src/channels/plugins/actions/signal.ts | 4 + .../plugins/outbound/direct-text-media.ts | 2 + src/channels/plugins/outbound/discord.test.ts | 13 +- src/channels/plugins/outbound/discord.ts | 12 +- src/channels/plugins/outbound/imessage.ts | 6 +- src/channels/plugins/outbound/signal.ts | 6 +- src/channels/plugins/outbound/slack.test.ts | 8 +- src/channels/plugins/outbound/slack.ts | 7 +- src/channels/plugins/outbound/telegram.ts | 20 +- .../plugins/outbound/whatsapp.poll.test.ts | 41 ++++ src/channels/plugins/outbound/whatsapp.ts | 9 +- src/commands/message.test.ts | 193 ++++++++++++++++++ src/discord/send.components.ts | 7 +- src/discord/send.outbound.ts | 19 +- src/discord/send.reactions.ts | 12 +- src/discord/send.shared.ts | 9 +- src/discord/send.types.ts | 2 + src/discord/send.webhook-activity.test.ts | 19 ++ .../outbound/cfg-threading.guard.test.ts | 179 ++++++++++++++++ src/infra/outbound/deliver.ts | 10 +- src/infra/outbound/message.channels.test.ts | 70 +++++++ src/line/send.ts | 8 +- src/signal/send-reactions.ts | 5 +- src/signal/send.ts | 5 +- src/slack/send.ts | 5 +- src/telegram/send.ts | 2 + src/web/outbound.ts | 7 +- 62 files changed, 1780 insertions(+), 117 deletions(-) create mode 100644 extensions/irc/src/send.test.ts create mode 100644 extensions/matrix/src/outbound.test.ts create mode 100644 extensions/msteams/src/outbound.test.ts create mode 100644 extensions/nextcloud-talk/src/send.test.ts create mode 100644 extensions/nostr/src/channel.outbound.test.ts create mode 100644 extensions/signal/src/channel.outbound.test.ts create mode 100644 extensions/whatsapp/src/channel.outbound.test.ts create mode 100644 src/channels/plugins/outbound/whatsapp.poll.test.ts create mode 100644 src/infra/outbound/cfg-threading.guard.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f4d644203..62e54c6d5dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant. - Sessions/subagent attachments: remove `attachments[].content.maxLength` from `sessions_spawn` schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera. - Runtime/tool-state stability: recover from dangling Anthropic `tool_use` after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr. - Extensions/media local-root propagation: consistently forward `mediaLocalRoots` through extension `sendMedia` adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3. diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index bfc2b92db74..3abaa82a956 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -302,10 +302,11 @@ export const discordPlugin: ChannelPlugin = { textChunkLimit: 2000, pollMaxOptions: 10, resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), - sendText: async ({ to, text, accountId, deps, replyToId, silent }) => { + sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, + cfg, replyTo: replyToId ?? undefined, accountId: accountId ?? undefined, silent: silent ?? undefined, @@ -313,6 +314,7 @@ export const discordPlugin: ChannelPlugin = { return { channel: "discord", ...result }; }, sendMedia: async ({ + cfg, to, text, mediaUrl, @@ -325,6 +327,7 @@ export const discordPlugin: ChannelPlugin = { const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, + cfg, mediaUrl, mediaLocalRoots, replyTo: replyToId ?? undefined, @@ -333,8 +336,9 @@ export const discordPlugin: ChannelPlugin = { }); return { channel: "discord", ...result }; }, - sendPoll: async ({ to, poll, accountId, silent }) => + sendPoll: async ({ cfg, to, poll, accountId, silent }) => await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { + cfg, accountId: accountId ?? undefined, silent: silent ?? undefined, }), diff --git a/extensions/googlechat/src/resolve-target.test.ts b/extensions/googlechat/src/resolve-target.test.ts index d4b53036f1f..82e340874df 100644 --- a/extensions/googlechat/src/resolve-target.test.ts +++ b/extensions/googlechat/src/resolve-target.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { installCommonResolveTargetErrorCases } from "../../shared/resolve-target-test-helpers.js"; +const runtimeMocks = vi.hoisted(() => ({ + chunkMarkdownText: vi.fn((text: string) => [text]), + fetchRemoteMedia: vi.fn(), +})); + vi.mock("openclaw/plugin-sdk", () => ({ getChatChannelMeta: () => ({ id: "googlechat", label: "Google Chat" }), missingTargetError: (provider: string, hint: string) => @@ -47,7 +52,8 @@ vi.mock("./onboarding.js", () => ({ vi.mock("./runtime.js", () => ({ getGoogleChatRuntime: vi.fn(() => ({ channel: { - text: { chunkMarkdownText: vi.fn() }, + text: { chunkMarkdownText: runtimeMocks.chunkMarkdownText }, + media: { fetchRemoteMedia: runtimeMocks.fetchRemoteMedia }, }, })), })); @@ -66,7 +72,11 @@ vi.mock("./targets.js", () => ({ resolveGoogleChatOutboundSpace: vi.fn(), })); +import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk"; +import { resolveGoogleChatAccount } from "./accounts.js"; +import { sendGoogleChatMessage, uploadGoogleChatAttachment } from "./api.js"; import { googlechatPlugin } from "./channel.js"; +import { resolveGoogleChatOutboundSpace } from "./targets.js"; const resolveTarget = googlechatPlugin.outbound!.resolveTarget!; @@ -104,3 +114,118 @@ describe("googlechat resolveTarget", () => { implicitAllowFrom: ["spaces/BBB"], }); }); + +describe("googlechat outbound cfg threading", () => { + beforeEach(() => { + runtimeMocks.fetchRemoteMedia.mockReset(); + runtimeMocks.chunkMarkdownText.mockClear(); + vi.mocked(resolveGoogleChatAccount).mockReset(); + vi.mocked(resolveGoogleChatOutboundSpace).mockReset(); + vi.mocked(resolveChannelMediaMaxBytes).mockReset(); + vi.mocked(uploadGoogleChatAttachment).mockReset(); + vi.mocked(sendGoogleChatMessage).mockReset(); + }); + + it("threads resolved cfg into sendText account resolution", async () => { + const cfg = { + channels: { + googlechat: { + serviceAccount: { + type: "service_account", + }, + }, + }, + }; + const account = { + accountId: "default", + config: {}, + credentialSource: "inline", + }; + vi.mocked(resolveGoogleChatAccount).mockReturnValue(account as any); + vi.mocked(resolveGoogleChatOutboundSpace).mockResolvedValue("spaces/AAA"); + vi.mocked(sendGoogleChatMessage).mockResolvedValue({ + messageName: "spaces/AAA/messages/msg-1", + } as any); + + await googlechatPlugin.outbound!.sendText!({ + cfg: cfg as any, + to: "users/123", + text: "hello", + accountId: "default", + }); + + expect(resolveGoogleChatAccount).toHaveBeenCalledWith({ + cfg, + accountId: "default", + }); + expect(sendGoogleChatMessage).toHaveBeenCalledWith( + expect.objectContaining({ + account, + space: "spaces/AAA", + text: "hello", + }), + ); + }); + + it("threads resolved cfg into sendMedia account and media loading path", async () => { + const cfg = { + channels: { + googlechat: { + serviceAccount: { + type: "service_account", + }, + mediaMaxMb: 8, + }, + }, + }; + const account = { + accountId: "default", + config: { mediaMaxMb: 20 }, + credentialSource: "inline", + }; + vi.mocked(resolveGoogleChatAccount).mockReturnValue(account as any); + vi.mocked(resolveGoogleChatOutboundSpace).mockResolvedValue("spaces/AAA"); + vi.mocked(resolveChannelMediaMaxBytes).mockReturnValue(1024); + runtimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("file"), + fileName: "file.png", + contentType: "image/png", + }); + vi.mocked(uploadGoogleChatAttachment).mockResolvedValue({ + attachmentUploadToken: "token-1", + } as any); + vi.mocked(sendGoogleChatMessage).mockResolvedValue({ + messageName: "spaces/AAA/messages/msg-2", + } as any); + + await googlechatPlugin.outbound!.sendMedia!({ + cfg: cfg as any, + to: "users/123", + text: "photo", + mediaUrl: "https://example.com/file.png", + accountId: "default", + }); + + expect(resolveGoogleChatAccount).toHaveBeenCalledWith({ + cfg, + accountId: "default", + }); + expect(runtimeMocks.fetchRemoteMedia).toHaveBeenCalledWith({ + url: "https://example.com/file.png", + maxBytes: 1024, + }); + expect(uploadGoogleChatAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + account, + space: "spaces/AAA", + filename: "file.png", + }), + ); + expect(sendGoogleChatMessage).toHaveBeenCalledWith( + expect.objectContaining({ + account, + attachments: [{ attachmentUploadToken: "token-1", contentName: "file.png" }], + }), + ); + }); +}); diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 1a3eee85102..0835f6734ad 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -69,6 +69,7 @@ async function sendIMessageOutbound(params: { accountId: params.accountId, }); return await send(params.to, params.text, { + config: params.cfg, ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), ...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}), maxBytes, diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 6993baa0ba7..30fd9f9faa5 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -296,16 +296,18 @@ export const ircPlugin: ChannelPlugin = { chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 350, - sendText: async ({ to, text, accountId, replyToId }) => { + sendText: async ({ cfg, to, text, accountId, replyToId }) => { const result = await sendMessageIrc(to, text, { + cfg: cfg as CoreConfig, accountId: accountId ?? undefined, replyTo: replyToId ?? undefined, }); return { channel: "irc", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => { + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; const result = await sendMessageIrc(to, combined, { + cfg: cfg as CoreConfig, accountId: accountId ?? undefined, replyTo: replyToId ?? undefined, }); diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts new file mode 100644 index 00000000000..df7b5e60ddd --- /dev/null +++ b/extensions/irc/src/send.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { IrcClient } from "./client.js"; +import type { CoreConfig } from "./types.js"; + +const hoisted = vi.hoisted(() => { + const loadConfig = vi.fn(); + const resolveMarkdownTableMode = vi.fn(() => "preserve"); + const convertMarkdownTables = vi.fn((text: string) => text); + const record = vi.fn(); + return { + loadConfig, + resolveMarkdownTableMode, + convertMarkdownTables, + record, + resolveIrcAccount: vi.fn(() => ({ + configured: true, + accountId: "default", + host: "irc.example.com", + nick: "openclaw", + port: 6697, + tls: true, + })), + normalizeIrcMessagingTarget: vi.fn((value: string) => value.trim()), + connectIrcClient: vi.fn(), + buildIrcConnectOptions: vi.fn(() => ({})), + }; +}); + +vi.mock("./runtime.js", () => ({ + getIrcRuntime: () => ({ + config: { + loadConfig: hoisted.loadConfig, + }, + channel: { + text: { + resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode, + convertMarkdownTables: hoisted.convertMarkdownTables, + }, + activity: { + record: hoisted.record, + }, + }, + }), +})); + +vi.mock("./accounts.js", () => ({ + resolveIrcAccount: hoisted.resolveIrcAccount, +})); + +vi.mock("./normalize.js", () => ({ + normalizeIrcMessagingTarget: hoisted.normalizeIrcMessagingTarget, +})); + +vi.mock("./client.js", () => ({ + connectIrcClient: hoisted.connectIrcClient, +})); + +vi.mock("./connect-options.js", () => ({ + buildIrcConnectOptions: hoisted.buildIrcConnectOptions, +})); + +vi.mock("./protocol.js", async () => { + const actual = await vi.importActual("./protocol.js"); + return { + ...actual, + makeIrcMessageId: () => "irc-msg-1", + }; +}); + +import { sendMessageIrc } from "./send.js"; + +describe("sendMessageIrc cfg threading", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses explicitly provided cfg without loading runtime config", async () => { + const providedCfg = { source: "provided" } as unknown as CoreConfig; + const client = { + isReady: vi.fn(() => true), + sendPrivmsg: vi.fn(), + } as unknown as IrcClient; + + const result = await sendMessageIrc("#room", "hello", { + cfg: providedCfg, + client, + accountId: "work", + }); + + expect(hoisted.loadConfig).not.toHaveBeenCalled(); + expect(hoisted.resolveIrcAccount).toHaveBeenCalledWith({ + cfg: providedCfg, + accountId: "work", + }); + expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello"); + expect(result).toEqual({ messageId: "irc-msg-1", target: "#room" }); + }); + + it("falls back to runtime config when cfg is omitted", async () => { + const runtimeCfg = { source: "runtime" } as unknown as CoreConfig; + hoisted.loadConfig.mockReturnValueOnce(runtimeCfg); + const client = { + isReady: vi.fn(() => true), + sendPrivmsg: vi.fn(), + } as unknown as IrcClient; + + await sendMessageIrc("#ops", "ping", { client }); + + expect(hoisted.loadConfig).toHaveBeenCalledTimes(1); + expect(hoisted.resolveIrcAccount).toHaveBeenCalledWith({ + cfg: runtimeCfg, + accountId: undefined, + }); + expect(client.sendPrivmsg).toHaveBeenCalledWith("#ops", "ping"); + }); +}); diff --git a/extensions/irc/src/send.ts b/extensions/irc/src/send.ts index e60859d44e9..544f81f3f47 100644 --- a/extensions/irc/src/send.ts +++ b/extensions/irc/src/send.ts @@ -8,6 +8,7 @@ import { getIrcRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; type SendIrcOptions = { + cfg?: CoreConfig; accountId?: string; replyTo?: string; target?: string; @@ -37,7 +38,7 @@ export async function sendMessageIrc( opts: SendIrcOptions = {}, ): Promise { const runtime = getIrcRuntime(); - const cfg = runtime.config.loadConfig() as CoreConfig; + const cfg = (opts.cfg ?? runtime.config.loadConfig()) as CoreConfig; const account = resolveIrcAccount({ cfg, accountId: opts.accountId, diff --git a/extensions/line/src/channel.sendPayload.test.ts b/extensions/line/src/channel.sendPayload.test.ts index e92551538e9..95dd8e2d4ce 100644 --- a/extensions/line/src/channel.sendPayload.test.ts +++ b/extensions/line/src/channel.sendPayload.test.ts @@ -117,6 +117,7 @@ describe("linePlugin outbound.sendPayload", () => { expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", { verbose: false, accountId: "default", + cfg, }); }); @@ -154,6 +155,7 @@ describe("linePlugin outbound.sendPayload", () => { expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", { verbose: false, accountId: "default", + cfg, }); }); @@ -193,7 +195,7 @@ describe("linePlugin outbound.sendPayload", () => { quickReply: { items: ["One", "Two"] }, }, ], - { verbose: false, accountId: "default" }, + { verbose: false, accountId: "default", cfg }, ); expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]); }); @@ -225,12 +227,13 @@ describe("linePlugin outbound.sendPayload", () => { verbose: false, mediaUrl: "https://example.com/img.jpg", accountId: "default", + cfg, }); expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith( "line:user:3", "Hello", ["One", "Two"], - { verbose: false, accountId: "default" }, + { verbose: false, accountId: "default", cfg }, ); const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0]; const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0]; diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index f5a0f9de107..c29046eaaf0 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -372,6 +372,7 @@ export const linePlugin: ChannelPlugin = { const batch = messages.slice(i, i + 5) as unknown as Parameters[1]; const result = await sendBatch(to, batch, { verbose: false, + cfg, accountId: accountId ?? undefined, }); lastResult = { messageId: result.messageId, chatId: result.chatId }; @@ -399,6 +400,7 @@ export const linePlugin: ChannelPlugin = { const flexContents = lineData.flexMessage.contents as Parameters[2]; lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, { verbose: false, + cfg, accountId: accountId ?? undefined, }); } @@ -408,6 +410,7 @@ export const linePlugin: ChannelPlugin = { if (template) { lastResult = await sendTemplate(to, template, { verbose: false, + cfg, accountId: accountId ?? undefined, }); } @@ -416,6 +419,7 @@ export const linePlugin: ChannelPlugin = { if (lineData.location) { lastResult = await sendLocation(to, lineData.location, { verbose: false, + cfg, accountId: accountId ?? undefined, }); } @@ -425,6 +429,7 @@ export const linePlugin: ChannelPlugin = { const flexContents = flexMsg.contents as Parameters[2]; lastResult = await sendFlex(to, flexMsg.altText, flexContents, { verbose: false, + cfg, accountId: accountId ?? undefined, }); } @@ -436,6 +441,7 @@ export const linePlugin: ChannelPlugin = { lastResult = await runtime.channel.line.sendMessageLine(to, "", { verbose: false, mediaUrl: url, + cfg, accountId: accountId ?? undefined, }); } @@ -447,11 +453,13 @@ export const linePlugin: ChannelPlugin = { if (isLast && hasQuickReplies) { lastResult = await sendQuickReplies(to, chunks[i], quickReplies, { verbose: false, + cfg, accountId: accountId ?? undefined, }); } else { lastResult = await sendText(to, chunks[i], { verbose: false, + cfg, accountId: accountId ?? undefined, }); } @@ -513,6 +521,7 @@ export const linePlugin: ChannelPlugin = { lastResult = await runtime.channel.line.sendMessageLine(to, "", { verbose: false, mediaUrl: url, + cfg, accountId: accountId ?? undefined, }); } @@ -523,7 +532,7 @@ export const linePlugin: ChannelPlugin = { } return { channel: "line", messageId: "empty", chatId: to }; }, - sendText: async ({ to, text, accountId }) => { + sendText: async ({ cfg, to, text, accountId }) => { const runtime = getLineRuntime(); const sendText = runtime.channel.line.pushMessageLine; const sendFlex = runtime.channel.line.pushFlexMessage; @@ -536,6 +545,7 @@ export const linePlugin: ChannelPlugin = { if (processed.text.trim()) { result = await sendText(to, processed.text, { verbose: false, + cfg, accountId: accountId ?? undefined, }); } else { @@ -549,17 +559,19 @@ export const linePlugin: ChannelPlugin = { const flexContents = flexMsg.contents as Parameters[2]; await sendFlex(to, flexMsg.altText, flexContents, { verbose: false, + cfg, accountId: accountId ?? undefined, }); } return { channel: "line", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId }) => { + sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => { const send = getLineRuntime().channel.line.sendMessageLine; const result = await send(to, text, { verbose: false, mediaUrl, + cfg, accountId: accountId ?? undefined, }); return { channel: "line", ...result }; diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 8ad67ca2312..234c9950216 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -34,6 +34,7 @@ const loadWebMediaMock = vi.fn().mockResolvedValue({ contentType: "image/png", kind: "image", }); +const runtimeLoadConfigMock = vi.fn(() => ({})); const mediaKindFromMimeMock = vi.fn(() => "image"); const isVoiceCompatibleAudioMock = vi.fn(() => false); const getImageMetadataMock = vi.fn().mockResolvedValue(null); @@ -41,7 +42,7 @@ const resizeToJpegMock = vi.fn(); const runtimeStub = { config: { - loadConfig: () => ({}), + loadConfig: runtimeLoadConfigMock, }, media: { loadWebMedia: loadWebMediaMock as unknown as PluginRuntime["media"]["loadWebMedia"], @@ -65,6 +66,7 @@ const runtimeStub = { } as unknown as PluginRuntime; let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; +let resolveMediaMaxBytes: typeof import("./send/client.js").resolveMediaMaxBytes; const makeClient = () => { const sendMessage = vi.fn().mockResolvedValue("evt1"); @@ -80,11 +82,14 @@ const makeClient = () => { beforeAll(async () => { setMatrixRuntime(runtimeStub); ({ sendMessageMatrix } = await import("./send.js")); + ({ resolveMediaMaxBytes } = await import("./send/client.js")); }); describe("sendMessageMatrix media", () => { beforeEach(() => { vi.clearAllMocks(); + runtimeLoadConfigMock.mockReset(); + runtimeLoadConfigMock.mockReturnValue({}); mediaKindFromMimeMock.mockReturnValue("image"); isVoiceCompatibleAudioMock.mockReturnValue(false); setMatrixRuntime(runtimeStub); @@ -214,6 +219,8 @@ describe("sendMessageMatrix media", () => { describe("sendMessageMatrix threads", () => { beforeEach(() => { vi.clearAllMocks(); + runtimeLoadConfigMock.mockReset(); + runtimeLoadConfigMock.mockReturnValue({}); setMatrixRuntime(runtimeStub); }); @@ -240,3 +247,80 @@ describe("sendMessageMatrix threads", () => { }); }); }); + +describe("sendMessageMatrix cfg threading", () => { + beforeEach(() => { + vi.clearAllMocks(); + runtimeLoadConfigMock.mockReset(); + runtimeLoadConfigMock.mockReturnValue({ + channels: { + matrix: { + mediaMaxMb: 7, + }, + }, + }); + setMatrixRuntime(runtimeStub); + }); + + it("does not call runtime loadConfig when cfg is provided", async () => { + const { client } = makeClient(); + const providedCfg = { + channels: { + matrix: { + mediaMaxMb: 4, + }, + }, + }; + + await sendMessageMatrix("room:!room:example", "hello cfg", { + client, + cfg: providedCfg as any, + }); + + expect(runtimeLoadConfigMock).not.toHaveBeenCalled(); + }); + + it("falls back to runtime loadConfig when cfg is omitted", async () => { + const { client } = makeClient(); + + await sendMessageMatrix("room:!room:example", "hello runtime", { client }); + + expect(runtimeLoadConfigMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("resolveMediaMaxBytes cfg threading", () => { + beforeEach(() => { + runtimeLoadConfigMock.mockReset(); + runtimeLoadConfigMock.mockReturnValue({ + channels: { + matrix: { + mediaMaxMb: 9, + }, + }, + }); + setMatrixRuntime(runtimeStub); + }); + + it("uses provided cfg and skips runtime loadConfig", () => { + const providedCfg = { + channels: { + matrix: { + mediaMaxMb: 3, + }, + }, + }; + + const maxBytes = resolveMediaMaxBytes(undefined, providedCfg as any); + + expect(maxBytes).toBe(3 * 1024 * 1024); + expect(runtimeLoadConfigMock).not.toHaveBeenCalled(); + }); + + it("falls back to runtime loadConfig when cfg is omitted", () => { + const maxBytes = resolveMediaMaxBytes(); + + expect(maxBytes).toBe(9 * 1024 * 1024); + expect(runtimeLoadConfigMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index dd72ec2883b..80c1c120333 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -47,11 +47,12 @@ export async function sendMessageMatrix( client: opts.client, timeoutMs: opts.timeoutMs, accountId: opts.accountId, + cfg: opts.cfg, }); + const cfg = opts.cfg ?? getCore().config.loadConfig(); try { const roomId = await resolveMatrixRoomId(client, to); return await enqueueSend(roomId, async () => { - const cfg = getCore().config.loadConfig(); const tableMode = getCore().channel.text.resolveMarkdownTableMode({ cfg, channel: "matrix", @@ -81,7 +82,7 @@ export async function sendMessageMatrix( let lastMessageId = ""; if (opts.mediaUrl) { - const maxBytes = resolveMediaMaxBytes(opts.accountId); + const maxBytes = resolveMediaMaxBytes(opts.accountId, cfg); const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { contentType: media.contentType, @@ -171,6 +172,7 @@ export async function sendPollMatrix( client: opts.client, timeoutMs: opts.timeoutMs, accountId: opts.accountId, + cfg: opts.cfg, }); try { diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index 9eee35e88ba..e56cf493758 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -32,19 +32,19 @@ function findAccountConfig( return undefined; } -export function resolveMediaMaxBytes(accountId?: string): number | undefined { - const cfg = getCore().config.loadConfig() as CoreConfig; +export function resolveMediaMaxBytes(accountId?: string, cfg?: CoreConfig): number | undefined { + const resolvedCfg = cfg ?? (getCore().config.loadConfig() as CoreConfig); // Check account-specific config first (case-insensitive key matching) const accountConfig = findAccountConfig( - cfg.channels?.matrix?.accounts as Record | undefined, + resolvedCfg.channels?.matrix?.accounts as Record | undefined, accountId ?? "", ); if (typeof accountConfig?.mediaMaxMb === "number") { return (accountConfig.mediaMaxMb as number) * 1024 * 1024; } // Fall back to top-level config - if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") { - return cfg.channels.matrix.mediaMaxMb * 1024 * 1024; + if (typeof resolvedCfg.channels?.matrix?.mediaMaxMb === "number") { + return resolvedCfg.channels.matrix.mediaMaxMb * 1024 * 1024; } return undefined; } @@ -53,6 +53,7 @@ export async function resolveMatrixClient(opts: { client?: MatrixClient; timeoutMs?: number; accountId?: string; + cfg?: CoreConfig; }): Promise<{ client: MatrixClient; stopOnDone: boolean }> { ensureNodeRuntime(); if (opts.client) { @@ -84,10 +85,11 @@ export async function resolveMatrixClient(opts: { const client = await resolveSharedMatrixClient({ timeoutMs: opts.timeoutMs, accountId, + cfg: opts.cfg, }); return { client, stopOnDone: false }; } - const auth = await resolveMatrixAuth({ accountId }); + const auth = await resolveMatrixAuth({ accountId, cfg: opts.cfg }); const client = await createPreparedMatrixClient({ auth, timeoutMs: opts.timeoutMs, diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index 2b91327aadb..e3aec1dcae7 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -85,6 +85,7 @@ export type MatrixSendResult = { }; export type MatrixSendOpts = { + cfg?: import("../../types.js").CoreConfig; client?: import("@vector-im/matrix-bot-sdk").MatrixClient; mediaUrl?: string; accountId?: string; diff --git a/extensions/matrix/src/outbound.test.ts b/extensions/matrix/src/outbound.test.ts new file mode 100644 index 00000000000..cc70d5cd75b --- /dev/null +++ b/extensions/matrix/src/outbound.test.ts @@ -0,0 +1,159 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + sendMessageMatrix: vi.fn(), + sendPollMatrix: vi.fn(), +})); + +vi.mock("./matrix/send.js", () => ({ + sendMessageMatrix: mocks.sendMessageMatrix, + sendPollMatrix: mocks.sendPollMatrix, +})); + +vi.mock("./runtime.js", () => ({ + getMatrixRuntime: () => ({ + channel: { + text: { + chunkMarkdownText: (text: string) => [text], + }, + }, + }), +})); + +import { matrixOutbound } from "./outbound.js"; + +describe("matrixOutbound cfg threading", () => { + beforeEach(() => { + mocks.sendMessageMatrix.mockReset(); + mocks.sendPollMatrix.mockReset(); + mocks.sendMessageMatrix.mockResolvedValue({ messageId: "evt-1", roomId: "!room:example" }); + mocks.sendPollMatrix.mockResolvedValue({ eventId: "$poll", roomId: "!room:example" }); + }); + + it("passes resolved cfg to sendMessageMatrix for text sends", async () => { + const cfg = { + channels: { + matrix: { + accessToken: "resolved-token", + }, + }, + } as OpenClawConfig; + + await matrixOutbound.sendText!({ + cfg, + to: "room:!room:example", + text: "hello", + accountId: "default", + threadId: "$thread", + replyToId: "$reply", + }); + + expect(mocks.sendMessageMatrix).toHaveBeenCalledWith( + "room:!room:example", + "hello", + expect.objectContaining({ + cfg, + accountId: "default", + threadId: "$thread", + replyToId: "$reply", + }), + ); + }); + + it("passes resolved cfg to sendMessageMatrix for media sends", async () => { + const cfg = { + channels: { + matrix: { + accessToken: "resolved-token", + }, + }, + } as OpenClawConfig; + + await matrixOutbound.sendMedia!({ + cfg, + to: "room:!room:example", + text: "caption", + mediaUrl: "file:///tmp/cat.png", + accountId: "default", + }); + + expect(mocks.sendMessageMatrix).toHaveBeenCalledWith( + "room:!room:example", + "caption", + expect.objectContaining({ + cfg, + mediaUrl: "file:///tmp/cat.png", + }), + ); + }); + + it("passes resolved cfg through injected deps.sendMatrix", async () => { + const cfg = { + channels: { + matrix: { + accessToken: "resolved-token", + }, + }, + } as OpenClawConfig; + const sendMatrix = vi.fn(async () => ({ + messageId: "evt-injected", + roomId: "!room:example", + })); + + await matrixOutbound.sendText!({ + cfg, + to: "room:!room:example", + text: "hello via deps", + deps: { sendMatrix }, + accountId: "default", + threadId: "$thread", + replyToId: "$reply", + }); + + expect(sendMatrix).toHaveBeenCalledWith( + "room:!room:example", + "hello via deps", + expect.objectContaining({ + cfg, + accountId: "default", + threadId: "$thread", + replyToId: "$reply", + }), + ); + }); + + it("passes resolved cfg to sendPollMatrix", async () => { + const cfg = { + channels: { + matrix: { + accessToken: "resolved-token", + }, + }, + } as OpenClawConfig; + + await matrixOutbound.sendPoll!({ + cfg, + to: "room:!room:example", + poll: { + question: "Snack?", + options: ["Pizza", "Sushi"], + }, + accountId: "default", + threadId: "$thread", + }); + + expect(mocks.sendPollMatrix).toHaveBeenCalledWith( + "room:!room:example", + expect.objectContaining({ + question: "Snack?", + options: ["Pizza", "Sushi"], + }), + expect.objectContaining({ + cfg, + accountId: "default", + threadId: "$thread", + }), + ); + }); +}); diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 5ad3afbaf03..34d084c609b 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -7,11 +7,12 @@ export const matrixOutbound: ChannelOutboundAdapter = { chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ to, text, deps, replyToId, threadId, accountId }) => { + sendText: async ({ cfg, to, text, deps, replyToId, threadId, accountId }) => { const send = deps?.sendMatrix ?? sendMessageMatrix; const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await send(to, text, { + cfg, replyToId: replyToId ?? undefined, threadId: resolvedThreadId, accountId: accountId ?? undefined, @@ -22,11 +23,12 @@ export const matrixOutbound: ChannelOutboundAdapter = { roomId: result.roomId, }; }, - sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId, accountId }) => { + sendMedia: async ({ cfg, to, text, mediaUrl, deps, replyToId, threadId, accountId }) => { const send = deps?.sendMatrix ?? sendMessageMatrix; const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await send(to, text, { + cfg, mediaUrl, replyToId: replyToId ?? undefined, threadId: resolvedThreadId, @@ -38,10 +40,11 @@ export const matrixOutbound: ChannelOutboundAdapter = { roomId: result.roomId, }; }, - sendPoll: async ({ to, poll, threadId, accountId }) => { + sendPoll: async ({ cfg, to, poll, threadId, accountId }) => { const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await sendPollMatrix(to, poll, { + cfg, threadId: resolvedThreadId, accountId: accountId ?? undefined, }); diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index cafc8190d58..c448438278f 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -240,6 +240,37 @@ describe("mattermostPlugin", () => { }), ); }); + + it("threads resolved cfg on sendText", async () => { + const sendText = mattermostPlugin.outbound?.sendText; + if (!sendText) { + return; + } + const cfg = { + channels: { + mattermost: { + botToken: "resolved-bot-token", + baseUrl: "https://chat.example.com", + }, + }, + } as OpenClawConfig; + + await sendText({ + cfg, + to: "channel:CHAN1", + text: "hello", + accountId: "default", + } as any); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "hello", + expect.objectContaining({ + cfg, + accountId: "default", + }), + ); + }); }); describe("config", () => { diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 0f9ec4c82de..9d28814fc51 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -273,15 +273,17 @@ export const mattermostPlugin: ChannelPlugin = { } return { ok: true, to: trimmed }; }, - sendText: async ({ to, text, accountId, replyToId }) => { + sendText: async ({ cfg, to, text, accountId, replyToId }) => { const result = await sendMessageMattermost(to, text, { + cfg, accountId: accountId ?? undefined, replyToId: replyToId ?? undefined, }); return { channel: "mattermost", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId, replyToId }) => { + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, replyToId }) => { const result = await sendMessageMattermost(to, text, { + cfg, accountId: accountId ?? undefined, mediaUrl, mediaLocalRoots, diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index 1176cbfa7d1..d924529517c 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -2,7 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { sendMessageMattermost } from "./send.js"; const mockState = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), loadOutboundMediaFromUrl: vi.fn(), + resolveMattermostAccount: vi.fn(() => ({ + accountId: "default", + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + })), createMattermostClient: vi.fn(), createMattermostDirectChannel: vi.fn(), createMattermostPost: vi.fn(), @@ -17,11 +23,7 @@ vi.mock("openclaw/plugin-sdk", () => ({ })); vi.mock("./accounts.js", () => ({ - resolveMattermostAccount: () => ({ - accountId: "default", - botToken: "bot-token", - baseUrl: "https://mattermost.example.com", - }), + resolveMattermostAccount: mockState.resolveMattermostAccount, })); vi.mock("./client.js", () => ({ @@ -37,7 +39,7 @@ vi.mock("./client.js", () => ({ vi.mock("../runtime.js", () => ({ getMattermostRuntime: () => ({ config: { - loadConfig: () => ({}), + loadConfig: mockState.loadConfig, }, logging: { shouldLogVerbose: () => false, @@ -57,6 +59,14 @@ vi.mock("../runtime.js", () => ({ describe("sendMessageMattermost", () => { beforeEach(() => { + mockState.loadConfig.mockReset(); + mockState.loadConfig.mockReturnValue({}); + mockState.resolveMattermostAccount.mockReset(); + mockState.resolveMattermostAccount.mockReturnValue({ + accountId: "default", + botToken: "bot-token", + baseUrl: "https://mattermost.example.com", + }); mockState.loadOutboundMediaFromUrl.mockReset(); mockState.createMattermostClient.mockReset(); mockState.createMattermostDirectChannel.mockReset(); @@ -69,6 +79,46 @@ describe("sendMessageMattermost", () => { mockState.uploadMattermostFile.mockResolvedValue({ id: "file-1" }); }); + it("uses provided cfg and skips runtime loadConfig", async () => { + const providedCfg = { + channels: { + mattermost: { + botToken: "provided-token", + }, + }, + }; + + await sendMessageMattermost("channel:town-square", "hello", { + cfg: providedCfg as any, + accountId: "work", + }); + + expect(mockState.loadConfig).not.toHaveBeenCalled(); + expect(mockState.resolveMattermostAccount).toHaveBeenCalledWith({ + cfg: providedCfg, + accountId: "work", + }); + }); + + it("falls back to runtime loadConfig when cfg is omitted", async () => { + const runtimeCfg = { + channels: { + mattermost: { + botToken: "runtime-token", + }, + }, + }; + mockState.loadConfig.mockReturnValueOnce(runtimeCfg); + + await sendMessageMattermost("channel:town-square", "hello"); + + expect(mockState.loadConfig).toHaveBeenCalledTimes(1); + expect(mockState.resolveMattermostAccount).toHaveBeenCalledWith({ + cfg: runtimeCfg, + accountId: undefined, + }); + }); + it("loads outbound media with trusted local roots before upload", async () => { mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({ buffer: Buffer.from("media-bytes"), diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index 8732d2400db..b325895e58d 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -1,4 +1,4 @@ -import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk"; +import { loadOutboundMediaFromUrl, type OpenClawConfig } from "openclaw/plugin-sdk"; import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; import { @@ -13,6 +13,7 @@ import { } from "./client.js"; export type MattermostSendOpts = { + cfg?: OpenClawConfig; botToken?: string; baseUrl?: string; accountId?: string; @@ -146,7 +147,7 @@ export async function sendMessageMattermost( ): Promise { const core = getCore(); const logger = core.logging.getChildLogger({ module: "mattermost" }); - const cfg = core.config.loadConfig(); + const cfg = opts.cfg ?? core.config.loadConfig(); const account = resolveMattermostAccount({ cfg, accountId: opts.accountId, diff --git a/extensions/msteams/src/outbound.test.ts b/extensions/msteams/src/outbound.test.ts new file mode 100644 index 00000000000..950ccd4ece2 --- /dev/null +++ b/extensions/msteams/src/outbound.test.ts @@ -0,0 +1,131 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + sendMessageMSTeams: vi.fn(), + sendPollMSTeams: vi.fn(), + createPoll: vi.fn(), +})); + +vi.mock("./send.js", () => ({ + sendMessageMSTeams: mocks.sendMessageMSTeams, + sendPollMSTeams: mocks.sendPollMSTeams, +})); + +vi.mock("./polls.js", () => ({ + createMSTeamsPollStoreFs: () => ({ + createPoll: mocks.createPoll, + }), +})); + +vi.mock("./runtime.js", () => ({ + getMSTeamsRuntime: () => ({ + channel: { + text: { + chunkMarkdownText: (text: string) => [text], + }, + }, + }), +})); + +import { msteamsOutbound } from "./outbound.js"; + +describe("msteamsOutbound cfg threading", () => { + beforeEach(() => { + mocks.sendMessageMSTeams.mockReset(); + mocks.sendPollMSTeams.mockReset(); + mocks.createPoll.mockReset(); + mocks.sendMessageMSTeams.mockResolvedValue({ + messageId: "msg-1", + conversationId: "conv-1", + }); + mocks.sendPollMSTeams.mockResolvedValue({ + pollId: "poll-1", + messageId: "msg-poll-1", + conversationId: "conv-1", + }); + mocks.createPoll.mockResolvedValue(undefined); + }); + + it("passes resolved cfg to sendMessageMSTeams for text sends", async () => { + const cfg = { + channels: { + msteams: { + appId: "resolved-app-id", + }, + }, + } as OpenClawConfig; + + await msteamsOutbound.sendText!({ + cfg, + to: "conversation:abc", + text: "hello", + }); + + expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({ + cfg, + to: "conversation:abc", + text: "hello", + }); + }); + + it("passes resolved cfg and media roots for media sends", async () => { + const cfg = { + channels: { + msteams: { + appId: "resolved-app-id", + }, + }, + } as OpenClawConfig; + + await msteamsOutbound.sendMedia!({ + cfg, + to: "conversation:abc", + text: "photo", + mediaUrl: "file:///tmp/photo.png", + mediaLocalRoots: ["/tmp"], + }); + + expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({ + cfg, + to: "conversation:abc", + text: "photo", + mediaUrl: "file:///tmp/photo.png", + mediaLocalRoots: ["/tmp"], + }); + }); + + it("passes resolved cfg to sendPollMSTeams and stores poll metadata", async () => { + const cfg = { + channels: { + msteams: { + appId: "resolved-app-id", + }, + }, + } as OpenClawConfig; + + await msteamsOutbound.sendPoll!({ + cfg, + to: "conversation:abc", + poll: { + question: "Snack?", + options: ["Pizza", "Sushi"], + }, + }); + + expect(mocks.sendPollMSTeams).toHaveBeenCalledWith({ + cfg, + to: "conversation:abc", + question: "Snack?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + }); + expect(mocks.createPoll).toHaveBeenCalledWith( + expect.objectContaining({ + id: "poll-1", + question: "Snack?", + options: ["Pizza", "Sushi"], + }), + ); + }); +}); diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index e49f057878c..32f4fc9306c 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -262,18 +262,20 @@ export const nextcloudTalkPlugin: ChannelPlugin = chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ to, text, accountId, replyToId }) => { + sendText: async ({ cfg, to, text, accountId, replyToId }) => { const result = await sendMessageNextcloudTalk(to, text, { accountId: accountId ?? undefined, replyTo: replyToId ?? undefined, + cfg: cfg as CoreConfig, }); return { channel: "nextcloud-talk", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => { + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; const result = await sendMessageNextcloudTalk(to, messageWithMedia, { accountId: accountId ?? undefined, replyTo: replyToId ?? undefined, + cfg: cfg as CoreConfig, }); return { channel: "nextcloud-talk", ...result }; }, diff --git a/extensions/nextcloud-talk/src/send.test.ts b/extensions/nextcloud-talk/src/send.test.ts new file mode 100644 index 00000000000..3933b13de5a --- /dev/null +++ b/extensions/nextcloud-talk/src/send.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ + loadConfig: vi.fn(), + resolveMarkdownTableMode: vi.fn(() => "preserve"), + convertMarkdownTables: vi.fn((text: string) => text), + record: vi.fn(), + resolveNextcloudTalkAccount: vi.fn(() => ({ + accountId: "default", + baseUrl: "https://nextcloud.example.com", + secret: "secret-value", + })), + generateNextcloudTalkSignature: vi.fn(() => ({ + random: "r", + signature: "s", + })), +})); + +vi.mock("./runtime.js", () => ({ + getNextcloudTalkRuntime: () => ({ + config: { + loadConfig: hoisted.loadConfig, + }, + channel: { + text: { + resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode, + convertMarkdownTables: hoisted.convertMarkdownTables, + }, + activity: { + record: hoisted.record, + }, + }, + }), +})); + +vi.mock("./accounts.js", () => ({ + resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount, +})); + +vi.mock("./signature.js", () => ({ + generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature, +})); + +import { sendMessageNextcloudTalk, sendReactionNextcloudTalk } from "./send.js"; + +describe("nextcloud-talk send cfg threading", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => { + const cfg = { source: "provided" } as const; + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ocs: { data: { id: 12345, timestamp: 1_706_000_000 } }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const result = await sendMessageNextcloudTalk("room:abc123", "hello", { + cfg, + accountId: "work", + }); + + expect(hoisted.loadConfig).not.toHaveBeenCalled(); + expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({ + cfg, + accountId: "work", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + messageId: "12345", + roomToken: "abc123", + timestamp: 1_706_000_000, + }); + }); + + it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => { + const runtimeCfg = { source: "runtime" } as const; + hoisted.loadConfig.mockReturnValueOnce(runtimeCfg); + fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 })); + + const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", { + accountId: "default", + }); + + expect(result).toEqual({ ok: true }); + expect(hoisted.loadConfig).toHaveBeenCalledTimes(1); + expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({ + cfg: runtimeCfg, + accountId: "default", + }); + }); +}); diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index 6692f7099e9..7cc8f05658c 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -9,6 +9,7 @@ type NextcloudTalkSendOpts = { accountId?: string; replyTo?: string; verbose?: boolean; + cfg?: CoreConfig; }; function resolveCredentials( @@ -60,7 +61,7 @@ export async function sendMessageNextcloudTalk( text: string, opts: NextcloudTalkSendOpts = {}, ): Promise { - const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig; + const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig; const account = resolveNextcloudTalkAccount({ cfg, accountId: opts.accountId, @@ -175,7 +176,7 @@ export async function sendReactionNextcloudTalk( reaction: string, opts: Omit = {}, ): Promise<{ ok: true }> { - const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig; + const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig; const account = resolveNextcloudTalkAccount({ cfg, accountId: opts.accountId, diff --git a/extensions/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts new file mode 100644 index 00000000000..9b4717136b0 --- /dev/null +++ b/extensions/nostr/src/channel.outbound.test.ts @@ -0,0 +1,88 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createStartAccountContext } from "../../test-utils/start-account-context.js"; +import { nostrPlugin } from "./channel.js"; +import { setNostrRuntime } from "./runtime.js"; + +const mocks = vi.hoisted(() => ({ + normalizePubkey: vi.fn((value: string) => `normalized-${value.toLowerCase()}`), + startNostrBus: vi.fn(), +})); + +vi.mock("./nostr-bus.js", () => ({ + DEFAULT_RELAYS: ["wss://relay.example.com"], + getPublicKeyFromPrivate: vi.fn(() => "pubkey"), + normalizePubkey: mocks.normalizePubkey, + startNostrBus: mocks.startNostrBus, +})); + +describe("nostr outbound cfg threading", () => { + afterEach(() => { + mocks.normalizePubkey.mockClear(); + mocks.startNostrBus.mockReset(); + }); + + it("uses resolved cfg when converting markdown tables before send", async () => { + const resolveMarkdownTableMode = vi.fn(() => "off"); + const convertMarkdownTables = vi.fn((text: string) => `converted:${text}`); + setNostrRuntime({ + channel: { + text: { + resolveMarkdownTableMode, + convertMarkdownTables, + }, + }, + reply: {}, + } as unknown as PluginRuntime); + + const sendDm = vi.fn(async () => {}); + const bus = { + sendDm, + close: vi.fn(), + getMetrics: vi.fn(() => ({ counters: {} })), + publishProfile: vi.fn(), + getProfileState: vi.fn(async () => null), + }; + mocks.startNostrBus.mockResolvedValueOnce(bus as any); + + const cleanup = (await nostrPlugin.gateway!.startAccount!( + createStartAccountContext({ + account: { + accountId: "default", + enabled: true, + configured: true, + privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + publicKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + relays: ["wss://relay.example.com"], + config: {}, + }, + abortSignal: new AbortController().signal, + }), + )) as { stop: () => void }; + + const cfg = { + channels: { + nostr: { + privateKey: "resolved-nostr-private-key", + }, + }, + }; + await nostrPlugin.outbound!.sendText!({ + cfg: cfg as any, + to: "NPUB123", + text: "|a|b|", + accountId: "default", + }); + + expect(resolveMarkdownTableMode).toHaveBeenCalledWith({ + cfg, + channel: "nostr", + accountId: "default", + }); + expect(convertMarkdownTables).toHaveBeenCalledWith("|a|b|", "off"); + expect(mocks.normalizePubkey).toHaveBeenCalledWith("NPUB123"); + expect(sendDm).toHaveBeenCalledWith("normalized-npub123", "converted:|a|b|"); + + cleanup.stop(); + }); +}); diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index a516f2442eb..b7608953fc9 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -135,7 +135,7 @@ export const nostrPlugin: ChannelPlugin = { outbound: { deliveryMode: "direct", textChunkLimit: 4000, - sendText: async ({ to, text, accountId }) => { + sendText: async ({ cfg, to, text, accountId }) => { const core = getNostrRuntime(); const aid = accountId ?? DEFAULT_ACCOUNT_ID; const bus = activeBuses.get(aid); @@ -143,7 +143,7 @@ export const nostrPlugin: ChannelPlugin = { throw new Error(`Nostr bus not running for account ${aid}`); } const tableMode = core.channel.text.resolveMarkdownTableMode({ - cfg: core.config.loadConfig(), + cfg, channel: "nostr", accountId: aid, }); diff --git a/extensions/signal/src/channel.outbound.test.ts b/extensions/signal/src/channel.outbound.test.ts new file mode 100644 index 00000000000..f1ceafbcab2 --- /dev/null +++ b/extensions/signal/src/channel.outbound.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { signalPlugin } from "./channel.js"; + +describe("signal outbound cfg threading", () => { + it("threads provided cfg into sendText deps call", async () => { + const cfg = { + channels: { + signal: { + accounts: { + work: { + mediaMaxMb: 12, + }, + }, + mediaMaxMb: 5, + }, + }, + }; + const sendSignal = vi.fn(async () => ({ messageId: "sig-1" })); + + const result = await signalPlugin.outbound!.sendText!({ + cfg, + to: "+15551230000", + text: "hello", + accountId: "work", + deps: { sendSignal }, + }); + + expect(sendSignal).toHaveBeenCalledWith("+15551230000", "hello", { + cfg, + maxBytes: 12 * 1024 * 1024, + accountId: "work", + }); + expect(result).toEqual({ channel: "signal", messageId: "sig-1" }); + }); + + it("threads cfg + mediaUrl into sendMedia deps call", async () => { + const cfg = { + channels: { + signal: { + mediaMaxMb: 7, + }, + }, + }; + const sendSignal = vi.fn(async () => ({ messageId: "sig-2" })); + + const result = await signalPlugin.outbound!.sendMedia!({ + cfg, + to: "+15559870000", + text: "photo", + mediaUrl: "https://example.com/a.jpg", + accountId: "default", + deps: { sendSignal }, + }); + + expect(sendSignal).toHaveBeenCalledWith("+15559870000", "photo", { + cfg, + mediaUrl: "https://example.com/a.jpg", + maxBytes: 7 * 1024 * 1024, + accountId: "default", + }); + expect(result).toEqual({ channel: "signal", messageId: "sig-2" }); + }); +}); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index ff0623705b7..1dc3bbc15cc 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -80,6 +80,7 @@ async function sendSignalOutbound(params: { accountId: params.accountId, }); return await send(params.to, params.text, { + cfg: params.cfg, ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), ...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}), maxBytes, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 5a1364fe8f2..82e29e95b99 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -365,6 +365,7 @@ export const slackPlugin: ChannelPlugin = { threadId, }); const result = await send(to, text, { + cfg, threadTs: threadTsValue != null ? String(threadTsValue) : undefined, accountId: accountId ?? undefined, ...(tokenOverride ? { token: tokenOverride } : {}), @@ -390,6 +391,7 @@ export const slackPlugin: ChannelPlugin = { threadId, }); const result = await send(to, text, { + cfg, mediaUrl, mediaLocalRoots, threadTs: threadTsValue != null ? String(threadTsValue) : undefined, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 3564a9719ab..bc8b7e1fcaf 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -320,12 +320,13 @@ export const telegramPlugin: ChannelPlugin { + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => { const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const replyToMessageId = parseTelegramReplyToMessageId(replyToId); const messageThreadId = parseTelegramThreadId(threadId); const result = await send(to, text, { verbose: false, + cfg, messageThreadId, replyToMessageId, accountId: accountId ?? undefined, @@ -334,6 +335,7 @@ export const telegramPlugin: ChannelPlugin + sendPoll: async ({ cfg, to, poll, accountId, threadId, silent, isAnonymous }) => await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, { + cfg, accountId: accountId ?? undefined, messageThreadId: parseTelegramThreadId(threadId), silent: silent ?? undefined, diff --git a/extensions/whatsapp/src/channel.outbound.test.ts b/extensions/whatsapp/src/channel.outbound.test.ts new file mode 100644 index 00000000000..3c51e9c1bef --- /dev/null +++ b/extensions/whatsapp/src/channel.outbound.test.ts @@ -0,0 +1,46 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ + sendPollWhatsApp: vi.fn(async () => ({ messageId: "wa-poll-1", toJid: "1555@s.whatsapp.net" })), +})); + +vi.mock("./runtime.js", () => ({ + getWhatsAppRuntime: () => ({ + logging: { + shouldLogVerbose: () => false, + }, + channel: { + whatsapp: { + sendPollWhatsApp: hoisted.sendPollWhatsApp, + }, + }, + }), +})); + +import { whatsappPlugin } from "./channel.js"; + +describe("whatsappPlugin outbound sendPoll", () => { + it("threads cfg into runtime sendPollWhatsApp call", async () => { + const cfg = { marker: "resolved-cfg" } as OpenClawConfig; + const poll = { + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + }; + + const result = await whatsappPlugin.outbound!.sendPoll!({ + cfg, + to: "+1555", + poll, + accountId: "work", + }); + + expect(hoisted.sendPollWhatsApp).toHaveBeenCalledWith("+1555", poll, { + verbose: false, + accountId: "work", + cfg, + }); + expect(result).toEqual({ messageId: "wa-poll-1", toJid: "1555@s.whatsapp.net" }); + }); +}); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index d45cbe113f2..424c1046c87 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -286,19 +286,30 @@ export const whatsappPlugin: ChannelPlugin = { pollMaxOptions: 12, resolveTarget: ({ to, allowFrom, mode }) => resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), - sendText: async ({ to, text, accountId, deps, gifPlayback }) => { + sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { const send = deps?.sendWhatsApp ?? getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp; const result = await send(to, text, { verbose: false, + cfg, accountId: accountId ?? undefined, gifPlayback, }); return { channel: "whatsapp", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => { + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + gifPlayback, + }) => { const send = deps?.sendWhatsApp ?? getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp; const result = await send(to, text, { verbose: false, + cfg, mediaUrl, mediaLocalRoots, accountId: accountId ?? undefined, @@ -306,10 +317,11 @@ export const whatsappPlugin: ChannelPlugin = { }); return { channel: "whatsapp", ...result }; }, - sendPoll: async ({ to, poll, accountId }) => + sendPoll: async ({ cfg, to, poll, accountId }) => await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(to, poll, { verbose: getWhatsAppRuntime().logging.shouldLogVerbose(), accountId: accountId ?? undefined, + cfg, }), }, auth: { diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 9d0b3818334..2846e0879f8 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -1,5 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { DiscordActionConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/config.js"; import { readDiscordComponentSpec } from "../../discord/components.js"; import { createThreadDiscord, @@ -59,6 +60,7 @@ export async function handleDiscordMessagingAction( options?: { mediaLocalRoots?: readonly string[]; }, + cfg?: OpenClawConfig, ): Promise> { const resolveChannelId = () => resolveDiscordChannelId( @@ -67,6 +69,7 @@ export async function handleDiscordMessagingAction( }), ); const accountId = readStringParam(params, "accountId"); + const cfgOptions = cfg ? { cfg } : {}; const normalizeMessage = (message: unknown) => { if (!message || typeof message !== "object") { return message; @@ -90,22 +93,28 @@ export async function handleDiscordMessagingAction( }); if (remove) { if (accountId) { - await removeReactionDiscord(channelId, messageId, emoji, { accountId }); + await removeReactionDiscord(channelId, messageId, emoji, { + ...cfgOptions, + accountId, + }); } else { - await removeReactionDiscord(channelId, messageId, emoji); + await removeReactionDiscord(channelId, messageId, emoji, cfgOptions); } return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { const removed = accountId - ? await removeOwnReactionsDiscord(channelId, messageId, { accountId }) - : await removeOwnReactionsDiscord(channelId, messageId); + ? await removeOwnReactionsDiscord(channelId, messageId, { ...cfgOptions, accountId }) + : await removeOwnReactionsDiscord(channelId, messageId, cfgOptions); return jsonResult({ ok: true, removed: removed.removed }); } if (accountId) { - await reactMessageDiscord(channelId, messageId, emoji, { accountId }); + await reactMessageDiscord(channelId, messageId, emoji, { + ...cfgOptions, + accountId, + }); } else { - await reactMessageDiscord(channelId, messageId, emoji); + await reactMessageDiscord(channelId, messageId, emoji, cfgOptions); } return jsonResult({ ok: true, added: emoji }); } @@ -121,6 +130,7 @@ export async function handleDiscordMessagingAction( const limit = typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined; const reactions = await fetchReactionsDiscord(channelId, messageId, { + ...cfgOptions, ...(accountId ? { accountId } : {}), limit, }); @@ -137,6 +147,7 @@ export async function handleDiscordMessagingAction( label: "stickerIds", }); await sendStickerDiscord(to, stickerIds, { + ...cfgOptions, ...(accountId ? { accountId } : {}), content, }); @@ -165,7 +176,7 @@ export async function handleDiscordMessagingAction( await sendPollDiscord( to, { question, options: answers, maxSelections, durationHours }, - { ...(accountId ? { accountId } : {}), content }, + { ...cfgOptions, ...(accountId ? { accountId } : {}), content }, ); return jsonResult({ ok: true }); } @@ -276,6 +287,7 @@ export async function handleDiscordMessagingAction( ? componentSpec : { ...componentSpec, text: normalizedContent }; const result = await sendDiscordComponentMessage(to, payload, { + ...cfgOptions, ...(accountId ? { accountId } : {}), silent, replyTo: replyTo ?? undefined, @@ -301,6 +313,7 @@ export async function handleDiscordMessagingAction( } assertMediaNotDataUrl(mediaUrl); const result = await sendVoiceMessageDiscord(to, mediaUrl, { + ...cfgOptions, ...(accountId ? { accountId } : {}), replyTo, silent, @@ -309,6 +322,7 @@ export async function handleDiscordMessagingAction( } const result = await sendMessageDiscord(to, content ?? "", { + ...cfgOptions, ...(accountId ? { accountId } : {}), mediaUrl, mediaLocalRoots: options?.mediaLocalRoots, @@ -422,6 +436,7 @@ export async function handleDiscordMessagingAction( const mediaUrl = readStringParam(params, "mediaUrl"); const replyTo = readStringParam(params, "replyTo"); const result = await sendMessageDiscord(`channel:${channelId}`, content, { + ...cfgOptions, ...(accountId ? { accountId } : {}), mediaUrl, mediaLocalRoots: options?.mediaLocalRoots, diff --git a/src/agents/tools/discord-actions.test.ts b/src/agents/tools/discord-actions.test.ts index 87ae04854e9..cbadb77f564 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/src/agents/tools/discord-actions.test.ts @@ -107,7 +107,7 @@ describe("handleDiscordMessagingAction", () => { expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", expectedOptions); return; } - expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅"); + expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {}); }); it("removes reactions on empty emoji", async () => { @@ -120,7 +120,7 @@ describe("handleDiscordMessagingAction", () => { }, enableAllActions, ); - expect(removeOwnReactionsDiscord).toHaveBeenCalledWith("C1", "M1"); + expect(removeOwnReactionsDiscord).toHaveBeenCalledWith("C1", "M1", {}); }); it("removes reactions when remove flag set", async () => { @@ -134,7 +134,7 @@ describe("handleDiscordMessagingAction", () => { }, enableAllActions, ); - expect(removeReactionDiscord).toHaveBeenCalledWith("C1", "M1", "✅"); + expect(removeReactionDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {}); }); it("rejects removes without emoji", async () => { diff --git a/src/agents/tools/discord-actions.ts b/src/agents/tools/discord-actions.ts index 627d14e40e6..d4533517c8a 100644 --- a/src/agents/tools/discord-actions.ts +++ b/src/agents/tools/discord-actions.ts @@ -67,7 +67,7 @@ export async function handleDiscordAction( const isActionEnabled = createDiscordActionGate({ cfg, accountId }); if (messagingActions.has(action)) { - return await handleDiscordMessagingAction(action, params, isActionEnabled, options); + return await handleDiscordMessagingAction(action, params, isActionEnabled, options, cfg); } if (guildActions.has(action)) { return await handleDiscordGuildAction(action, params, isActionEnabled); diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index bd0454bf72d..eda720dfc93 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -847,7 +847,10 @@ describe("signalMessageActions", () => { cfg: createSignalAccountOverrideCfg(), accountId: "work", params: { to: "+15550001111", messageId: "123", emoji: "👍" }, - expectedArgs: ["+15550001111", 123, "👍", { accountId: "work" }], + expectedRecipient: "+15550001111", + expectedTimestamp: 123, + expectedEmoji: "👍", + expectedOptions: { accountId: "work" }, }, { name: "normalizes uuid recipients", @@ -858,7 +861,10 @@ describe("signalMessageActions", () => { messageId: "123", emoji: "🔥", }, - expectedArgs: ["123e4567-e89b-12d3-a456-426614174000", 123, "🔥", { accountId: undefined }], + expectedRecipient: "123e4567-e89b-12d3-a456-426614174000", + expectedTimestamp: 123, + expectedEmoji: "🔥", + expectedOptions: {}, }, { name: "passes groupId and targetAuthor for group reactions", @@ -870,17 +876,13 @@ describe("signalMessageActions", () => { messageId: "123", emoji: "✅", }, - expectedArgs: [ - "", - 123, - "✅", - { - accountId: undefined, - groupId: "group-id", - targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", - targetAuthorUuid: undefined, - }, - ], + expectedRecipient: "", + expectedTimestamp: 123, + expectedEmoji: "✅", + expectedOptions: { + groupId: "group-id", + targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", + }, }, ] as const; @@ -890,7 +892,15 @@ describe("signalMessageActions", () => { cfg: testCase.cfg, accountId: testCase.accountId, }); - expect(sendReactionSignal, testCase.name).toHaveBeenCalledWith(...testCase.expectedArgs); + expect(sendReactionSignal, testCase.name).toHaveBeenCalledWith( + testCase.expectedRecipient, + testCase.expectedTimestamp, + testCase.expectedEmoji, + expect.objectContaining({ + cfg: testCase.cfg, + ...testCase.expectedOptions, + }), + ); } }); diff --git a/src/channels/plugins/actions/signal.ts b/src/channels/plugins/actions/signal.ts index c934a039f99..c93421489fd 100644 --- a/src/channels/plugins/actions/signal.ts +++ b/src/channels/plugins/actions/signal.ts @@ -40,6 +40,7 @@ function resolveSignalReactionTarget(raw: string): { recipient?: string; groupId } async function mutateSignalReaction(params: { + cfg: Parameters[0]["cfg"]; accountId?: string; target: { recipient?: string; groupId?: string }; timestamp: number; @@ -49,6 +50,7 @@ async function mutateSignalReaction(params: { targetAuthorUuid?: string; }) { const options = { + cfg: params.cfg, accountId: params.accountId, groupId: params.target.groupId, targetAuthor: params.targetAuthor, @@ -153,6 +155,7 @@ export const signalMessageActions: ChannelMessageActionAdapter = { throw new Error("Emoji required to remove reaction."); } return await mutateSignalReaction({ + cfg, accountId: accountId ?? undefined, target, timestamp, @@ -167,6 +170,7 @@ export const signalMessageActions: ChannelMessageActionAdapter = { throw new Error("Emoji required to add reaction."); } return await mutateSignalReaction({ + cfg, accountId: accountId ?? undefined, target, timestamp, diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index 3949963dfe8..9617798325d 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -5,6 +5,7 @@ import { resolveChannelMediaMaxBytes } from "../media-limits.js"; import type { ChannelOutboundAdapter } from "../types.js"; type DirectSendOptions = { + cfg: OpenClawConfig; accountId?: string | null; replyToId?: string | null; mediaUrl?: string; @@ -121,6 +122,7 @@ export function createDirectTextMediaOutbound< sendParams.to, sendParams.text, sendParams.buildOptions({ + cfg: sendParams.cfg, mediaUrl: sendParams.mediaUrl, mediaLocalRoots: sendParams.mediaLocalRoots, accountId: sendParams.accountId, diff --git a/src/channels/plugins/outbound/discord.test.ts b/src/channels/plugins/outbound/discord.test.ts index 70e74da0da5..b6a618f4b5f 100644 --- a/src/channels/plugins/outbound/discord.test.ts +++ b/src/channels/plugins/outbound/discord.test.ts @@ -143,9 +143,16 @@ describe("discordOutbound", () => { it("uses webhook persona delivery for bound thread text replies", async () => { mockBoundThreadManager(); + const cfg = { + channels: { + discord: { + token: "resolved-token", + }, + }, + }; const result = await discordOutbound.sendText?.({ - cfg: {}, + cfg, to: "channel:parent-1", text: "hello from persona", accountId: "default", @@ -169,6 +176,10 @@ describe("discordOutbound", () => { avatarUrl: "https://example.com/avatar.png", }), ); + expect( + (hoisted.sendWebhookMessageDiscordMock.mock.calls[0]?.[1] as { cfg?: unknown } | undefined) + ?.cfg, + ).toBe(cfg); expect(hoisted.sendMessageDiscordMock).not.toHaveBeenCalled(); expect(result).toEqual({ channel: "discord", diff --git a/src/channels/plugins/outbound/discord.ts b/src/channels/plugins/outbound/discord.ts index 4f959d23e38..b88f3cc09ef 100644 --- a/src/channels/plugins/outbound/discord.ts +++ b/src/channels/plugins/outbound/discord.ts @@ -1,3 +1,4 @@ +import type { OpenClawConfig } from "../../../config/config.js"; import { getThreadBindingManager, type ThreadBindingRecord, @@ -38,6 +39,7 @@ function resolveDiscordWebhookIdentity(params: { } async function maybeSendDiscordWebhookText(params: { + cfg?: OpenClawConfig; text: string; threadId?: string | number | null; accountId?: string | null; @@ -68,6 +70,7 @@ async function maybeSendDiscordWebhookText(params: { webhookToken: binding.webhookToken, accountId: binding.accountId, threadId: binding.threadId, + cfg: params.cfg, replyTo: params.replyToId ?? undefined, username: persona.username, avatarUrl: persona.avatarUrl, @@ -83,9 +86,10 @@ export const discordOutbound: ChannelOutboundAdapter = { resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), sendPayload: async (ctx) => await sendTextMediaPayload({ channel: "discord", ctx, adapter: discordOutbound }), - sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity, silent }) => { + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => { if (!silent) { const webhookResult = await maybeSendDiscordWebhookText({ + cfg, text, threadId, accountId, @@ -103,10 +107,12 @@ export const discordOutbound: ChannelOutboundAdapter = { replyTo: replyToId ?? undefined, accountId: accountId ?? undefined, silent: silent ?? undefined, + cfg, }); return { channel: "discord", ...result }; }, sendMedia: async ({ + cfg, to, text, mediaUrl, @@ -126,14 +132,16 @@ export const discordOutbound: ChannelOutboundAdapter = { replyTo: replyToId ?? undefined, accountId: accountId ?? undefined, silent: silent ?? undefined, + cfg, }); return { channel: "discord", ...result }; }, - sendPoll: async ({ to, poll, accountId, threadId, silent }) => { + sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => { const target = resolveDiscordOutboundTarget({ to, threadId }); return await sendPollDiscord(target, poll, { accountId: accountId ?? undefined, silent: silent ?? undefined, + cfg, }); }, }; diff --git a/src/channels/plugins/outbound/imessage.ts b/src/channels/plugins/outbound/imessage.ts index 6a419bc2796..20c92754d28 100644 --- a/src/channels/plugins/outbound/imessage.ts +++ b/src/channels/plugins/outbound/imessage.ts @@ -13,12 +13,14 @@ export const imessageOutbound = createDirectTextMediaOutbound({ channel: "imessage", resolveSender: resolveIMessageSender, resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("imessage"), - buildTextOptions: ({ maxBytes, accountId, replyToId }) => ({ + buildTextOptions: ({ cfg, maxBytes, accountId, replyToId }) => ({ + config: cfg, maxBytes, accountId: accountId ?? undefined, replyToId: replyToId ?? undefined, }), - buildMediaOptions: ({ mediaUrl, maxBytes, accountId, replyToId, mediaLocalRoots }) => ({ + buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, replyToId, mediaLocalRoots }) => ({ + config: cfg, mediaUrl, maxBytes, accountId: accountId ?? undefined, diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index e91feacad64..0ebf8e57670 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -13,11 +13,13 @@ export const signalOutbound = createDirectTextMediaOutbound({ channel: "signal", resolveSender: resolveSignalSender, resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("signal"), - buildTextOptions: ({ maxBytes, accountId }) => ({ + buildTextOptions: ({ cfg, maxBytes, accountId }) => ({ + cfg, maxBytes, accountId: accountId ?? undefined, }), - buildMediaOptions: ({ mediaUrl, maxBytes, accountId, mediaLocalRoots }) => ({ + buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, mediaLocalRoots }) => ({ + cfg, mediaUrl, maxBytes, accountId: accountId ?? undefined, diff --git a/src/channels/plugins/outbound/slack.test.ts b/src/channels/plugins/outbound/slack.test.ts index 42583a25b06..18635f0e4a2 100644 --- a/src/channels/plugins/outbound/slack.test.ts +++ b/src/channels/plugins/outbound/slack.test.ts @@ -58,11 +58,13 @@ const expectSlackSendCalledWith = ( }; }, ) => { - expect(sendMessageSlack).toHaveBeenCalledWith("C123", text, { + const expected = { threadTs: "1111.2222", accountId: "default", - ...options, - }); + cfg: expect.any(Object), + ...(options?.identity ? { identity: expect.objectContaining(options.identity) } : {}), + }; + expect(sendMessageSlack).toHaveBeenCalledWith("C123", text, expect.objectContaining(expected)); }; describe("slack outbound hook wiring", () => { diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index 562336776c9..1c14cc3743d 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -48,6 +48,7 @@ async function applySlackMessageSendingHooks(params: { } async function sendSlackOutboundMessage(params: { + cfg: NonNullable[2]>["cfg"]; to: string; text: string; mediaUrl?: string; @@ -80,6 +81,7 @@ async function sendSlackOutboundMessage(params: { const slackIdentity = resolveSlackSendIdentity(params.identity); const result = await send(params.to, hookResult.text, { + cfg: params.cfg, threadTs, accountId: params.accountId ?? undefined, ...(params.mediaUrl @@ -96,8 +98,9 @@ export const slackOutbound: ChannelOutboundAdapter = { textChunkLimit: 4000, sendPayload: async (ctx) => await sendTextMediaPayload({ channel: "slack", ctx, adapter: slackOutbound }), - sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity }) => { + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => { return await sendSlackOutboundMessage({ + cfg, to, text, accountId, @@ -108,6 +111,7 @@ export const slackOutbound: ChannelOutboundAdapter = { }); }, sendMedia: async ({ + cfg, to, text, mediaUrl, @@ -119,6 +123,7 @@ export const slackOutbound: ChannelOutboundAdapter = { identity, }) => { return await sendSlackOutboundMessage({ + cfg, to, text, mediaUrl, diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 32aadb8fbc1..2a079a6014e 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -9,6 +9,7 @@ import { sendMessageTelegram } from "../../../telegram/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; function resolveTelegramSendContext(params: { + cfg: NonNullable[2]>["cfg"]; deps?: OutboundSendDeps; accountId?: string | null; replyToId?: string | null; @@ -16,6 +17,7 @@ function resolveTelegramSendContext(params: { }): { send: typeof sendMessageTelegram; baseOpts: { + cfg: NonNullable[2]>["cfg"]; verbose: false; textMode: "html"; messageThreadId?: number; @@ -29,6 +31,7 @@ function resolveTelegramSendContext(params: { baseOpts: { verbose: false, textMode: "html", + cfg: params.cfg, messageThreadId: parseTelegramThreadId(params.threadId), replyToMessageId: parseTelegramReplyToMessageId(params.replyToId), accountId: params.accountId ?? undefined, @@ -41,8 +44,9 @@ export const telegramOutbound: ChannelOutboundAdapter = { chunker: markdownToTelegramHtmlChunks, chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { const { send, baseOpts } = resolveTelegramSendContext({ + cfg, deps, accountId, replyToId, @@ -54,6 +58,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { return { channel: "telegram", ...result }; }, sendMedia: async ({ + cfg, to, text, mediaUrl, @@ -64,6 +69,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { threadId, }) => { const { send, baseOpts } = resolveTelegramSendContext({ + cfg, deps, accountId, replyToId, @@ -76,8 +82,18 @@ export const telegramOutbound: ChannelOutboundAdapter = { }); return { channel: "telegram", ...result }; }, - sendPayload: async ({ to, payload, mediaLocalRoots, accountId, deps, replyToId, threadId }) => { + sendPayload: async ({ + cfg, + to, + payload, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + }) => { const { send, baseOpts: contextOpts } = resolveTelegramSendContext({ + cfg, deps, accountId, replyToId, diff --git a/src/channels/plugins/outbound/whatsapp.poll.test.ts b/src/channels/plugins/outbound/whatsapp.poll.test.ts new file mode 100644 index 00000000000..7164a6b152e --- /dev/null +++ b/src/channels/plugins/outbound/whatsapp.poll.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; + +const hoisted = vi.hoisted(() => ({ + sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })), +})); + +vi.mock("../../../globals.js", () => ({ + shouldLogVerbose: () => false, +})); + +vi.mock("../../../web/outbound.js", () => ({ + sendPollWhatsApp: hoisted.sendPollWhatsApp, +})); + +import { whatsappOutbound } from "./whatsapp.js"; + +describe("whatsappOutbound sendPoll", () => { + it("threads cfg through poll send options", async () => { + const cfg = { marker: "resolved-cfg" } as OpenClawConfig; + const poll = { + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + }; + + const result = await whatsappOutbound.sendPoll!({ + cfg, + to: "+1555", + poll, + accountId: "work", + }); + + expect(hoisted.sendPollWhatsApp).toHaveBeenCalledWith("+1555", poll, { + verbose: false, + accountId: "work", + cfg, + }); + expect(result).toEqual({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" }); + }); +}); diff --git a/src/channels/plugins/outbound/whatsapp.ts b/src/channels/plugins/outbound/whatsapp.ts index a314b372e70..e5de15241ae 100644 --- a/src/channels/plugins/outbound/whatsapp.ts +++ b/src/channels/plugins/outbound/whatsapp.ts @@ -15,21 +15,23 @@ export const whatsappOutbound: ChannelOutboundAdapter = { resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), sendPayload: async (ctx) => await sendTextMediaPayload({ channel: "whatsapp", ctx, adapter: whatsappOutbound }), - sendText: async ({ to, text, accountId, deps, gifPlayback }) => { + sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { const send = deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp; const result = await send(to, text, { verbose: false, + cfg, accountId: accountId ?? undefined, gifPlayback, }); return { channel: "whatsapp", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => { + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => { const send = deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp; const result = await send(to, text, { verbose: false, + cfg, mediaUrl, mediaLocalRoots, accountId: accountId ?? undefined, @@ -37,9 +39,10 @@ export const whatsappOutbound: ChannelOutboundAdapter = { }); return { channel: "whatsapp", ...result }; }, - sendPoll: async ({ to, poll, accountId }) => + sendPoll: async ({ cfg, to, poll, accountId }) => await sendPollWhatsApp(to, poll, { verbose: shouldLogVerbose(), accountId: accountId ?? undefined, + cfg, }), }; diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 6c805574778..f5a23298b1a 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -169,6 +169,199 @@ const createTelegramSendPluginRegistration = () => ({ const { messageCommand } = await import("./message.js"); describe("messageCommand", () => { + it("threads resolved SecretRef config into outbound send actions", async () => { + const rawConfig = { + channels: { + telegram: { + token: { $secret: "vault://telegram/token" }, + }, + }, + }; + const resolvedConfig = { + channels: { + telegram: { + token: "12345:resolved-token", + }, + }, + }; + testConfig = rawConfig; + resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + resolvedConfig: resolvedConfig as unknown as Record, + diagnostics: ["resolved channels.telegram.token"], + }); + await setRegistry( + createTestRegistry([ + { + ...createTelegramSendPluginRegistration(), + }, + ]), + ); + + const deps = makeDeps(); + await messageCommand( + { + action: "send", + channel: "telegram", + target: "123456", + message: "hi", + }, + deps, + runtime, + ); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + config: rawConfig, + commandName: "message", + }), + ); + expect(handleTelegramAction).toHaveBeenCalledWith( + expect.objectContaining({ action: "send", to: "123456", accountId: undefined }), + resolvedConfig, + ); + }); + + it("threads resolved SecretRef config into outbound adapter sends", async () => { + const rawConfig = { + channels: { + telegram: { + token: { $secret: "vault://telegram/token" }, + }, + }, + }; + const resolvedConfig = { + channels: { + telegram: { + token: "12345:resolved-token", + }, + }, + }; + testConfig = rawConfig; + resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + resolvedConfig: resolvedConfig as unknown as Record, + diagnostics: ["resolved channels.telegram.token"], + }); + const sendText = vi.fn(async (_ctx: { cfg?: unknown; to: string; text: string }) => ({ + channel: "telegram" as const, + messageId: "msg-1", + chatId: "123456", + })); + const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({ + channel: "telegram" as const, + messageId: "msg-2", + chatId: "123456", + })); + await setRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: createStubPlugin({ + id: "telegram", + label: "Telegram", + outbound: { + deliveryMode: "direct", + sendText, + sendMedia, + }, + }), + }, + ]), + ); + + const deps = makeDeps(); + await messageCommand( + { + action: "send", + channel: "telegram", + target: "123456", + message: "hi", + }, + deps, + runtime, + ); + + expect(sendText).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: resolvedConfig, + to: "123456", + text: "hi", + }), + ); + expect(sendText.mock.calls[0]?.[0]?.cfg).not.toBe(rawConfig); + }); + + it("keeps local-fallback resolved cfg in outbound adapter sends", async () => { + const rawConfig = { + channels: { + telegram: { + token: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }, + }, + }, + }; + const locallyResolvedConfig = { + channels: { + telegram: { + token: "12345:local-fallback-token", + }, + }, + }; + testConfig = rawConfig; + resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + resolvedConfig: locallyResolvedConfig as unknown as Record, + diagnostics: ["gateway secrets.resolve unavailable; used local resolver fallback."], + }); + const sendText = vi.fn(async (_ctx: { cfg?: unknown }) => ({ + channel: "telegram" as const, + messageId: "msg-3", + chatId: "123456", + })); + const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({ + channel: "telegram" as const, + messageId: "msg-4", + chatId: "123456", + })); + await setRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: createStubPlugin({ + id: "telegram", + label: "Telegram", + outbound: { + deliveryMode: "direct", + sendText, + sendMedia, + }, + }), + }, + ]), + ); + + const deps = makeDeps(); + await messageCommand( + { + action: "send", + channel: "telegram", + target: "123456", + message: "hi", + }, + deps, + runtime, + ); + + expect(sendText).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: locallyResolvedConfig, + }), + ); + expect(sendText.mock.calls[0]?.[0]?.cfg).not.toBe(rawConfig); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("[secrets] gateway secrets.resolve unavailable"), + ); + }); + it("defaults channel when only one configured", async () => { process.env.TELEGRAM_BOT_TOKEN = "token-abc"; await setRegistry( diff --git a/src/discord/send.components.ts b/src/discord/send.components.ts index e2c87fd5f3f..5cdbee1b90c 100644 --- a/src/discord/send.components.ts +++ b/src/discord/send.components.ts @@ -5,7 +5,7 @@ import { type RequestClient, } from "@buape/carbon"; import { ChannelType, Routes } from "discord-api-types/v10"; -import { loadConfig } from "../config/config.js"; +import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { loadWebMedia } from "../web/media.js"; import { resolveDiscordAccount } from "./accounts.js"; @@ -41,6 +41,7 @@ function extractComponentAttachmentNames(spec: DiscordComponentMessageSpec): str } type DiscordComponentSendOpts = { + cfg?: OpenClawConfig; accountId?: string; token?: string; rest?: RequestClient; @@ -58,10 +59,10 @@ export async function sendDiscordComponentMessage( spec: DiscordComponentMessageSpec, opts: DiscordComponentSendOpts = {}, ): Promise { - const cfg = loadConfig(); + const cfg = opts.cfg ?? loadConfig(); const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId }); const { token, rest, request } = createDiscordClient(opts, cfg); - const recipient = await parseAndResolveRecipient(to, opts.accountId); + const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg); const { channelId } = await resolveChannelId(rest, recipient, request); const channelType = await resolveDiscordChannelType(rest, channelId); diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index 3e261f4a278..533d4060ed5 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { serializePayload, type MessagePayloadObject, type RequestClient } from "@buape/carbon"; import { ChannelType, Routes } from "discord-api-types/v10"; import { resolveChunkMode } from "../auto-reply/chunk.js"; -import { loadConfig } from "../config/config.js"; +import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import type { RetryConfig } from "../infra/retry.js"; @@ -44,6 +44,7 @@ import { } from "./voice-message.js"; type DiscordSendOpts = { + cfg?: OpenClawConfig; token?: string; accountId?: string; mediaUrl?: string; @@ -121,9 +122,9 @@ async function resolveDiscordSendTarget( to: string, opts: DiscordSendOpts, ): Promise<{ rest: RequestClient; request: DiscordClientRequest; channelId: string }> { - const cfg = loadConfig(); + const cfg = opts.cfg ?? loadConfig(); const { rest, request } = createDiscordClient(opts, cfg); - const recipient = await parseAndResolveRecipient(to, opts.accountId); + const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg); const { channelId } = await resolveChannelId(rest, recipient, request); return { rest, request, channelId }; } @@ -133,7 +134,7 @@ export async function sendMessageDiscord( text: string, opts: DiscordSendOpts = {}, ): Promise { - const cfg = loadConfig(); + const cfg = opts.cfg ?? loadConfig(); const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId, @@ -149,7 +150,7 @@ export async function sendMessageDiscord( accountId: accountInfo.accountId, }); const { token, rest, request } = createDiscordClient(opts, cfg); - const recipient = await parseAndResolveRecipient(to, opts.accountId); + const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg); const { channelId } = await resolveChannelId(rest, recipient, request); // Forum/Media channels reject POST /messages; auto-create a thread post instead. @@ -310,6 +311,7 @@ export async function sendMessageDiscord( } type DiscordWebhookSendOpts = { + cfg?: OpenClawConfig; webhookId: string; webhookToken: string; accountId?: string; @@ -385,7 +387,7 @@ export async function sendWebhookMessageDiscord( }; try { const account = resolveDiscordAccount({ - cfg: loadConfig(), + cfg: opts.cfg ?? loadConfig(), accountId: opts.accountId, }); recordChannelActivity({ @@ -464,6 +466,7 @@ export async function sendPollDiscord( } type VoiceMessageOpts = { + cfg?: OpenClawConfig; token?: string; accountId?: string; verbose?: boolean; @@ -509,7 +512,7 @@ export async function sendVoiceMessageDiscord( let channelId: string | undefined; try { - const cfg = loadConfig(); + const cfg = opts.cfg ?? loadConfig(); const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId, @@ -518,7 +521,7 @@ export async function sendVoiceMessageDiscord( token = client.token; rest = client.rest; const request = client.request; - const recipient = await parseAndResolveRecipient(to, opts.accountId); + const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg); channelId = (await resolveChannelId(rest, recipient, request)).channelId; // Convert to OGG/Opus if needed diff --git a/src/discord/send.reactions.ts b/src/discord/send.reactions.ts index 89dd9b9070e..436d64ac5b2 100644 --- a/src/discord/send.reactions.ts +++ b/src/discord/send.reactions.ts @@ -5,7 +5,6 @@ import { createDiscordClient, formatReactionEmoji, normalizeReactionEmoji, - resolveDiscordRest, } from "./send.shared.js"; import type { DiscordReactionSummary, DiscordReactOpts } from "./send.types.js"; @@ -15,7 +14,7 @@ export async function reactMessageDiscord( emoji: string, opts: DiscordReactOpts = {}, ) { - const cfg = loadConfig(); + const cfg = opts.cfg ?? loadConfig(); const { rest, request } = createDiscordClient(opts, cfg); const encoded = normalizeReactionEmoji(emoji); await request( @@ -31,7 +30,8 @@ export async function removeReactionDiscord( emoji: string, opts: DiscordReactOpts = {}, ) { - const rest = resolveDiscordRest(opts); + const cfg = opts.cfg ?? loadConfig(); + const { rest } = createDiscordClient(opts, cfg); const encoded = normalizeReactionEmoji(emoji); await rest.delete(Routes.channelMessageOwnReaction(channelId, messageId, encoded)); return { ok: true }; @@ -42,7 +42,8 @@ export async function removeOwnReactionsDiscord( messageId: string, opts: DiscordReactOpts = {}, ): Promise<{ ok: true; removed: string[] }> { - const rest = resolveDiscordRest(opts); + const cfg = opts.cfg ?? loadConfig(); + const { rest } = createDiscordClient(opts, cfg); const message = (await rest.get(Routes.channelMessage(channelId, messageId))) as { reactions?: Array<{ emoji: { id?: string | null; name?: string | null } }>; }; @@ -73,7 +74,8 @@ export async function fetchReactionsDiscord( messageId: string, opts: DiscordReactOpts & { limit?: number } = {}, ): Promise { - const rest = resolveDiscordRest(opts); + const cfg = opts.cfg ?? loadConfig(); + const { rest } = createDiscordClient(opts, cfg); const message = (await rest.get(Routes.channelMessage(channelId, messageId))) as { reactions?: Array<{ count: number; diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index 3a5d71f03e4..fddc276fccf 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -10,7 +10,7 @@ import { PollLayoutType } from "discord-api-types/payloads/v10"; import type { RESTAPIPoll } from "discord-api-types/rest/v10"; import { Routes, type APIChannel, type APIEmbed } from "discord-api-types/v10"; import type { ChunkMode } from "../auto-reply/chunk.js"; -import { loadConfig } from "../config/config.js"; +import { loadConfig, type OpenClawConfig } from "../config/config.js"; import type { RetryRunner } from "../infra/retry-policy.js"; import { buildOutboundMediaLoadOptions } from "../media/load-options.js"; import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js"; @@ -80,9 +80,10 @@ function parseRecipient(raw: string): DiscordRecipient { export async function parseAndResolveRecipient( raw: string, accountId?: string, + cfg?: OpenClawConfig, ): Promise { - const cfg = loadConfig(); - const accountInfo = resolveDiscordAccount({ cfg, accountId }); + const resolvedCfg = cfg ?? loadConfig(); + const accountInfo = resolveDiscordAccount({ cfg: resolvedCfg, accountId }); // First try to resolve using directory lookup (handles usernames) const trimmed = raw.trim(); @@ -93,7 +94,7 @@ export async function parseAndResolveRecipient( const resolved = await resolveDiscordTarget( raw, { - cfg, + cfg: resolvedCfg, accountId: accountInfo.accountId, }, parseOptions, diff --git a/src/discord/send.types.ts b/src/discord/send.types.ts index c69058f8687..2dc29921f7e 100644 --- a/src/discord/send.types.ts +++ b/src/discord/send.types.ts @@ -1,4 +1,5 @@ import type { RequestClient } from "@buape/carbon"; +import type { OpenClawConfig } from "../config/config.js"; import type { RetryConfig } from "../infra/retry.js"; export class DiscordSendError extends Error { @@ -28,6 +29,7 @@ export type DiscordSendResult = { }; export type DiscordReactOpts = { + cfg?: OpenClawConfig; token?: string; accountId?: string; rest?: RequestClient; diff --git a/src/discord/send.webhook-activity.test.ts b/src/discord/send.webhook-activity.test.ts index 0d92e16de3f..c51ba3b814d 100644 --- a/src/discord/send.webhook-activity.test.ts +++ b/src/discord/send.webhook-activity.test.ts @@ -2,6 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sendWebhookMessageDiscord } from "./send.js"; const recordChannelActivityMock = vi.hoisted(() => vi.fn()); +const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ channels: { discord: {} } }))); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => loadConfigMock(), + }; +}); vi.mock("../infra/channel-activity.js", async (importOriginal) => { const actual = await importOriginal(); @@ -14,6 +23,7 @@ vi.mock("../infra/channel-activity.js", async (importOriginal) => { describe("sendWebhookMessageDiscord activity", () => { beforeEach(() => { recordChannelActivityMock.mockClear(); + loadConfigMock.mockClear(); vi.stubGlobal( "fetch", vi.fn(async () => { @@ -30,7 +40,15 @@ describe("sendWebhookMessageDiscord activity", () => { }); it("records outbound channel activity for webhook sends", async () => { + const cfg = { + channels: { + discord: { + token: "resolved-token", + }, + }, + }; const result = await sendWebhookMessageDiscord("hello world", { + cfg, webhookId: "wh-1", webhookToken: "tok-1", accountId: "runtime", @@ -46,5 +64,6 @@ describe("sendWebhookMessageDiscord activity", () => { accountId: "runtime", direction: "outbound", }); + expect(loadConfigMock).not.toHaveBeenCalled(); }); }); diff --git a/src/infra/outbound/cfg-threading.guard.test.ts b/src/infra/outbound/cfg-threading.guard.test.ts new file mode 100644 index 00000000000..306170281c8 --- /dev/null +++ b/src/infra/outbound/cfg-threading.guard.test.ts @@ -0,0 +1,179 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const thisFilePath = fileURLToPath(import.meta.url); +const thisDir = path.dirname(thisFilePath); +const repoRoot = path.resolve(thisDir, "../../.."); +const loadConfigPattern = /\b(?:loadConfig|config\.loadConfig)\s*\(/; + +function toPosix(relativePath: string): string { + return relativePath.split(path.sep).join("/"); +} + +function readRepoFile(relativePath: string): string { + const absolute = path.join(repoRoot, relativePath); + return readFileSync(absolute, "utf8"); +} + +function listCoreOutboundEntryFiles(): string[] { + const outboundDir = path.join(repoRoot, "src/channels/plugins/outbound"); + return readdirSync(outboundDir) + .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) + .map((name) => toPosix(path.join("src/channels/plugins/outbound", name))) + .toSorted(); +} + +function listExtensionFiles(): { + adapterEntrypoints: string[]; + inlineChannelEntrypoints: string[]; +} { + const extensionsRoot = path.join(repoRoot, "extensions"); + const adapterEntrypoints: string[] = []; + const inlineChannelEntrypoints: string[] = []; + + for (const entry of readdirSync(extensionsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const srcDir = path.join(extensionsRoot, entry.name, "src"); + const outboundPath = path.join(srcDir, "outbound.ts"); + if (existsSync(outboundPath)) { + adapterEntrypoints.push(toPosix(path.join("extensions", entry.name, "src/outbound.ts"))); + } + + const channelPath = path.join(srcDir, "channel.ts"); + if (!existsSync(channelPath)) { + continue; + } + const source = readFileSync(channelPath, "utf8"); + if (source.includes("outbound:")) { + inlineChannelEntrypoints.push(toPosix(path.join("extensions", entry.name, "src/channel.ts"))); + } + } + + return { + adapterEntrypoints: adapterEntrypoints.toSorted(), + inlineChannelEntrypoints: inlineChannelEntrypoints.toSorted(), + }; +} + +function extractOutboundBlock(source: string, file: string): string { + const outboundKeyIndex = source.indexOf("outbound:"); + expect(outboundKeyIndex, `${file} should define outbound:`).toBeGreaterThanOrEqual(0); + const braceStart = source.indexOf("{", outboundKeyIndex); + expect(braceStart, `${file} should define outbound object`).toBeGreaterThanOrEqual(0); + + let depth = 0; + let state: "code" | "single" | "double" | "template" | "lineComment" | "blockComment" = "code"; + for (let i = braceStart; i < source.length; i += 1) { + const current = source[i]; + const next = source[i + 1]; + + if (state === "lineComment") { + if (current === "\n") { + state = "code"; + } + continue; + } + if (state === "blockComment") { + if (current === "*" && next === "/") { + state = "code"; + i += 1; + } + continue; + } + if (state === "single") { + if (current === "\\" && next) { + i += 1; + continue; + } + if (current === "'") { + state = "code"; + } + continue; + } + if (state === "double") { + if (current === "\\" && next) { + i += 1; + continue; + } + if (current === '"') { + state = "code"; + } + continue; + } + if (state === "template") { + if (current === "\\" && next) { + i += 1; + continue; + } + if (current === "`") { + state = "code"; + } + continue; + } + + if (current === "/" && next === "/") { + state = "lineComment"; + i += 1; + continue; + } + if (current === "/" && next === "*") { + state = "blockComment"; + i += 1; + continue; + } + if (current === "'") { + state = "single"; + continue; + } + if (current === '"') { + state = "double"; + continue; + } + if (current === "`") { + state = "template"; + continue; + } + if (current === "{") { + depth += 1; + continue; + } + if (current === "}") { + depth -= 1; + if (depth === 0) { + return source.slice(braceStart, i + 1); + } + } + } + + throw new Error(`Unable to parse outbound block in ${file}`); +} + +describe("outbound cfg-threading guard", () => { + it("keeps outbound adapter entrypoints free of loadConfig calls", () => { + const coreAdapterFiles = listCoreOutboundEntryFiles(); + const extensionAdapterFiles = listExtensionFiles().adapterEntrypoints; + const adapterFiles = [...coreAdapterFiles, ...extensionAdapterFiles]; + + for (const file of adapterFiles) { + const source = readRepoFile(file); + expect(source, `${file} must not call loadConfig in outbound entrypoint`).not.toMatch( + loadConfigPattern, + ); + } + }); + + it("keeps inline channel outbound blocks free of loadConfig calls", () => { + const inlineFiles = listExtensionFiles().inlineChannelEntrypoints; + for (const file of inlineFiles) { + const source = readRepoFile(file); + const outboundBlock = extractOutboundBlock(source, file); + expect(outboundBlock, `${file} outbound block must not call loadConfig`).not.toMatch( + loadConfigPattern, + ); + } + }); +}); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index ac1e957c73d..45bff297065 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -53,7 +53,13 @@ const TELEGRAM_TEXT_LIMIT = 4096; type SendMatrixMessage = ( to: string, text: string, - opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number }, + opts?: { + cfg?: OpenClawConfig; + mediaUrl?: string; + replyToId?: string; + threadId?: string; + timeoutMs?: number; + }, ) => Promise<{ messageId: string; roomId: string }>; export type OutboundSendDeps = { @@ -600,6 +606,7 @@ async function deliverOutboundPayloadsCore( return { channel: "signal" as const, ...(await sendSignal(to, text, { + cfg, maxBytes: signalMaxBytes, accountId: accountId ?? undefined, textMode: "plain", @@ -636,6 +643,7 @@ async function deliverOutboundPayloadsCore( return { channel: "signal" as const, ...(await sendSignal(to, formatted.text, { + cfg, mediaUrl, maxBytes: signalMaxBytes, accountId: accountId ?? undefined, diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index af10cb9faf3..0a21264b43e 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -27,6 +27,76 @@ afterEach(() => { }); describe("sendMessage channel normalization", () => { + it("threads resolved cfg through alias + target normalization in outbound dispatch", async () => { + const resolvedCfg = { + __resolvedCfgMarker: "cfg-from-secret-resolution", + channels: {}, + } as Record; + const seen: { + resolveCfg?: unknown; + sendCfg?: unknown; + to?: string; + } = {}; + const imessageAliasPlugin: ChannelPlugin = { + id: "imessage", + meta: { + id: "imessage", + label: "iMessage", + selectionLabel: "iMessage", + docsPath: "/channels/imessage", + blurb: "iMessage test stub.", + aliases: ["imsg"], + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + outbound: { + deliveryMode: "direct", + resolveTarget: ({ to, cfg }) => { + seen.resolveCfg = cfg; + const normalized = String(to ?? "") + .trim() + .replace(/^imessage:/i, ""); + return { ok: true, to: normalized }; + }, + sendText: async ({ cfg, to }) => { + seen.sendCfg = cfg; + seen.to = to; + return { channel: "imessage", messageId: "i-resolved" }; + }, + sendMedia: async ({ cfg, to }) => { + seen.sendCfg = cfg; + seen.to = to; + return { channel: "imessage", messageId: "i-resolved-media" }; + }, + }, + }; + + setRegistry( + createTestRegistry([ + { + pluginId: "imessage", + source: "test", + plugin: imessageAliasPlugin, + }, + ]), + ); + + const result = await sendMessage({ + cfg: resolvedCfg, + to: " imessage:+15551234567 ", + content: "hi", + channel: "imsg", + }); + + expect(result.channel).toBe("imessage"); + expect(seen.resolveCfg).toBe(resolvedCfg); + expect(seen.sendCfg).toBe(resolvedCfg); + expect(seen.to).toBe("+15551234567"); + }); + it("normalizes Teams alias", async () => { const sendMSTeams = vi.fn(async () => ({ messageId: "m1", diff --git a/src/line/send.ts b/src/line/send.ts index 7b6f4ac936e..1e97f247f70 100644 --- a/src/line/send.ts +++ b/src/line/send.ts @@ -1,5 +1,6 @@ import { messagingApi } from "@line/bot-sdk"; import { loadConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveLineAccount } from "./accounts.js"; @@ -25,6 +26,7 @@ const userProfileCache = new Map< const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes interface LineSendOpts { + cfg?: OpenClawConfig; channelAccessToken?: string; accountId?: string; verbose?: boolean; @@ -32,8 +34,8 @@ interface LineSendOpts { replyToken?: string; } -type LineClientOpts = Pick; -type LinePushOpts = Pick; +type LineClientOpts = Pick; +type LinePushOpts = Pick; interface LinePushBehavior { errorContext?: string; @@ -68,7 +70,7 @@ function createLineMessagingClient(opts: LineClientOpts): { account: ReturnType; client: messagingApi.MessagingApiClient; } { - const cfg = loadConfig(); + const cfg = opts.cfg ?? loadConfig(); const account = resolveLineAccount({ cfg, accountId: opts.accountId, diff --git a/src/signal/send-reactions.ts b/src/signal/send-reactions.ts index 3f252635da7..dba41bb8b7d 100644 --- a/src/signal/send-reactions.ts +++ b/src/signal/send-reactions.ts @@ -3,11 +3,13 @@ */ import { loadConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/config.js"; import { resolveSignalAccount } from "./accounts.js"; import { signalRpcRequest } from "./client.js"; import { resolveSignalRpcContext } from "./rpc-context.js"; export type SignalReactionOpts = { + cfg?: OpenClawConfig; baseUrl?: string; account?: string; accountId?: string; @@ -75,8 +77,9 @@ async function sendReactionSignalCore(params: { opts: SignalReactionOpts; errors: SignalReactionErrorMessages; }): Promise { + const cfg = params.opts.cfg ?? loadConfig(); const accountInfo = resolveSignalAccount({ - cfg: loadConfig(), + cfg, accountId: params.opts.accountId, }); const { baseUrl, account } = resolveSignalRpcContext(params.opts, accountInfo); diff --git a/src/signal/send.ts b/src/signal/send.ts index 8bcd385e2e8..9dc4ef97917 100644 --- a/src/signal/send.ts +++ b/src/signal/send.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../config/config.js"; +import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { kindFromMime } from "../media/mime.js"; import { resolveOutboundAttachmentFromUrl } from "../media/outbound-attachment.js"; @@ -8,6 +8,7 @@ import { markdownToSignalText, type SignalTextStyleRange } from "./format.js"; import { resolveSignalRpcContext } from "./rpc-context.js"; export type SignalSendOpts = { + cfg?: OpenClawConfig; baseUrl?: string; account?: string; accountId?: string; @@ -100,7 +101,7 @@ export async function sendMessageSignal( text: string, opts: SignalSendOpts = {}, ): Promise { - const cfg = loadConfig(); + const cfg = opts.cfg ?? loadConfig(); const accountInfo = resolveSignalAccount({ cfg, accountId: opts.accountId, diff --git a/src/slack/send.ts b/src/slack/send.ts index fcfe230f7dc..8ce7fd3c3f3 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -5,7 +5,7 @@ import { resolveTextChunkLimit, } from "../auto-reply/chunk.js"; import { isSilentReplyText } from "../auto-reply/tokens.js"; -import { loadConfig } from "../config/config.js"; +import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { logVerbose } from "../globals.js"; import { @@ -45,6 +45,7 @@ export type SlackSendIdentity = { }; type SlackSendOpts = { + cfg?: OpenClawConfig; token?: string; accountId?: string; mediaUrl?: string; @@ -262,7 +263,7 @@ export async function sendMessageSlack( if (!trimmedMessage && !opts.mediaUrl && !blocks) { throw new Error("Slack send requires text, blocks, or media"); } - const cfg = loadConfig(); + const cfg = opts.cfg ?? loadConfig(); const account = resolveSlackAccount({ cfg, accountId: opts.accountId, diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 6fa00740572..b04bd792529 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -42,6 +42,7 @@ type TelegramApi = Bot["api"]; type TelegramApiOverride = Partial; type TelegramSendOpts = { + cfg?: ReturnType; token?: string; accountId?: string; verbose?: boolean; @@ -1038,6 +1039,7 @@ export async function sendStickerTelegram( } type TelegramPollOpts = { + cfg?: ReturnType; token?: string; accountId?: string; verbose?: boolean; diff --git a/src/web/outbound.ts b/src/web/outbound.ts index da1428a6980..95cc84b1f11 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../config/config.js"; +import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { generateSecureUuid } from "../infra/secure-random.js"; import { getChildLogger } from "../logging/logger.js"; @@ -18,6 +18,7 @@ export async function sendMessageWhatsApp( body: string, options: { verbose: boolean; + cfg?: OpenClawConfig; mediaUrl?: string; mediaLocalRoots?: readonly string[]; gifPlayback?: boolean; @@ -30,7 +31,7 @@ export async function sendMessageWhatsApp( const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener( options.accountId, ); - const cfg = loadConfig(); + const cfg = options.cfg ?? loadConfig(); const tableMode = resolveMarkdownTableMode({ cfg, channel: "whatsapp", @@ -150,7 +151,7 @@ export async function sendReactionWhatsApp( export async function sendPollWhatsApp( to: string, poll: PollInput, - options: { verbose: boolean; accountId?: string }, + options: { verbose: boolean; accountId?: string; cfg?: OpenClawConfig }, ): Promise<{ messageId: string; toJid: string }> { const correlationId = generateSecureUuid(); const startedAt = Date.now();