From 95331e5cc52d35e9c46997a30e11ba94328b569b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 06:14:12 +0100 Subject: [PATCH] fix(channels): thread runtime config through sends --- .../discord/src/actions/runtime.messaging.ts | 14 +- .../discord/src/actions/runtime.test.ts | 83 +++++---- extensions/discord/src/channel.ts | 9 +- extensions/discord/src/client.ts | 29 ++-- .../discord/src/monitor/agent-components.ts | 1 + .../src/monitor/message-handler.preflight.ts | 14 +- .../discord/src/monitor/monitor.test.ts | 2 +- .../monitor/thread-bindings.discord-api.ts | 2 +- .../monitor/thread-bindings.lifecycle.test.ts | 2 + .../src/monitor/thread-bindings.manager.ts | 16 +- extensions/discord/src/outbound-adapter.ts | 2 +- .../discord/src/recipient-resolution.ts | 9 +- .../discord/src/send.components.test.ts | 17 ++ extensions/discord/src/send.components.ts | 14 +- .../discord/src/send.creates-thread.test.ts | 65 ++++--- extensions/discord/src/send.outbound.ts | 31 ++-- extensions/discord/src/send.reactions.ts | 9 +- .../send.sends-basic-channel-messages.test.ts | 70 ++++++-- extensions/discord/src/send.shared.ts | 3 + extensions/imessage/src/channel.runtime.ts | 7 +- extensions/imessage/src/channel.ts | 4 +- .../imessage/src/monitor/deliver.runtime.ts | 2 +- .../imessage/src/monitor/deliver.test.ts | 10 +- extensions/imessage/src/monitor/deliver.ts | 7 +- .../imessage/src/monitor/monitor-provider.ts | 2 + extensions/imessage/src/send.ts | 8 +- extensions/irc/src/channel.ts | 6 +- extensions/irc/src/inbound.ts | 4 + extensions/irc/src/send.test.ts | 25 +-- extensions/irc/src/send.ts | 9 +- .../line/src/auto-reply-delivery.test.ts | 13 +- extensions/line/src/auto-reply-delivery.ts | 7 +- extensions/line/src/bot-handlers.ts | 2 + .../line/src/channel.sendPayload.test.ts | 1 + extensions/line/src/channel.ts | 1 + extensions/line/src/monitor.ts | 18 +- extensions/line/src/reply-chunks.test.ts | 19 ++- extensions/line/src/reply-chunks.ts | 19 ++- extensions/line/src/rich-menu.ts | 47 +++-- extensions/line/src/send.test.ts | 44 +++-- extensions/line/src/send.ts | 39 ++--- .../matrix/src/channel-account-paths.ts | 9 +- .../matrix/src/channel.account-paths.test.ts | 3 +- .../matrix/src/matrix/actions/client.test.ts | 44 +++-- .../src/matrix/actions/messages.test.ts | 14 +- .../matrix/src/matrix/actions/messages.ts | 6 + .../src/matrix/actions/verification.test.ts | 16 +- .../matrix/src/matrix/actions/verification.ts | 9 +- .../src/matrix/client-bootstrap.test.ts | 4 + .../matrix/src/matrix/client-bootstrap.ts | 9 +- .../matrix/client-resolver.test-helpers.ts | 15 ++ extensions/matrix/src/matrix/client/config.ts | 19 ++- .../matrix/src/matrix/client/shared.test.ts | 80 ++++++--- extensions/matrix/src/matrix/client/shared.ts | 22 ++- extensions/matrix/src/matrix/monitor/index.ts | 1 + extensions/matrix/src/matrix/send.test.ts | 42 +++++ extensions/matrix/src/matrix/send.ts | 29 ++-- .../matrix/src/matrix/send/client.test.ts | 28 +-- extensions/matrix/src/matrix/send/client.ts | 11 +- extensions/matrix/src/matrix/send/types.ts | 2 +- .../matrix/src/matrix/thread-bindings.test.ts | 10 ++ .../matrix/src/matrix/thread-bindings.ts | 9 + extensions/mattermost/src/channel.ts | 1 + .../src/mattermost/reply-delivery.ts | 2 +- .../mattermost/src/mattermost/send.test.ts | 63 +++---- extensions/mattermost/src/mattermost/send.ts | 17 +- extensions/nextcloud-talk/src/inbound.ts | 10 +- .../src/send.cfg-threading.test.ts | 28 +-- extensions/nextcloud-talk/src/send.runtime.ts | 2 +- extensions/nextcloud-talk/src/send.ts | 9 +- extensions/signal/src/channel.ts | 8 +- extensions/signal/src/monitor.ts | 5 +- .../event-handler.inbound-context.test.ts | 19 ++- .../signal/src/monitor/event-handler.ts | 4 + .../signal/src/monitor/event-handler.types.ts | 1 + extensions/signal/src/send-reactions.test.ts | 17 +- extensions/signal/src/send-reactions.ts | 19 +-- extensions/signal/src/send.ts | 23 +-- extensions/slack/src/accounts.test.ts | 160 ++++++++---------- extensions/slack/src/accounts.ts | 65 ++----- extensions/slack/src/action-runtime.test.ts | 140 ++++++++++----- extensions/slack/src/action-runtime.ts | 4 +- extensions/slack/src/actions.ts | 30 +++- extensions/slack/src/channel.test.ts | 5 + extensions/slack/src/channel.ts | 24 +-- extensions/slack/src/draft-stream.test.ts | 4 + extensions/slack/src/draft-stream.ts | 4 + .../src/monitor/message-handler/dispatch.ts | 2 + .../src/monitor/message-handler/prepare.ts | 1 + extensions/slack/src/monitor/replies.test.ts | 3 + extensions/slack/src/monitor/replies.ts | 6 +- extensions/slack/src/send.blocks.test.ts | 14 ++ extensions/slack/src/send.ts | 19 +-- extensions/slack/src/send.upload.test.ts | 12 ++ .../telegram/src/bot-handlers.runtime.ts | 2 +- .../telegram/src/bot-native-commands.ts | 2 +- extensions/telegram/src/bot.ts | 1 + extensions/telegram/src/channel.ts | 5 +- extensions/telegram/src/send.proxy.test.ts | 40 ++++- extensions/telegram/src/send.runtime.ts | 3 +- extensions/telegram/src/send.test-harness.ts | 11 ++ extensions/telegram/src/send.ts | 75 ++++---- .../telegram/src/thread-bindings.test.ts | 26 ++- extensions/telegram/src/thread-bindings.ts | 24 ++- .../whatsapp/src/action-runtime.test.ts | 136 +++++++++------ extensions/whatsapp/src/action-runtime.ts | 1 + .../whatsapp/src/active-listener.test.ts | 8 +- extensions/whatsapp/src/active-listener.ts | 14 +- .../src/auto-reply/heartbeat-runner.test.ts | 12 +- .../src/auto-reply/heartbeat-runner.ts | 6 +- extensions/whatsapp/src/auto-reply/monitor.ts | 1 + .../auto-reply/monitor/ack-reaction.test.ts | 4 +- .../src/auto-reply/monitor/ack-reaction.ts | 1 + extensions/whatsapp/src/inbound.media.test.ts | 8 + .../inbound/access-control.test-harness.ts | 4 + .../src/inbound/access-control.test.ts | 6 + .../whatsapp/src/inbound/access-control.ts | 6 +- extensions/whatsapp/src/inbound/monitor.ts | 3 + extensions/whatsapp/src/media.test.ts | 2 +- ...unauthorized-senders-not-allowfrom.test.ts | 1 + ...captures-media-path-image-messages.test.ts | 1 + .../src/monitor-inbox.test-harness.ts | 1 + extensions/whatsapp/src/outbound-base.ts | 4 +- extensions/whatsapp/src/send.test.ts | 49 +++++- extensions/whatsapp/src/send.ts | 19 ++- 125 files changed, 1461 insertions(+), 804 deletions(-) diff --git a/extensions/discord/src/actions/runtime.messaging.ts b/extensions/discord/src/actions/runtime.messaging.ts index 51248a23ddd..b867b4dff29 100644 --- a/extensions/discord/src/actions/runtime.messaging.ts +++ b/extensions/discord/src/actions/runtime.messaging.ts @@ -109,15 +109,17 @@ export async function handleDiscordMessagingAction( }), ); const accountId = readStringParam(params, "accountId"); - const cfgOptions = cfg ? { cfg } : {}; - const reactionRuntimeOptions = cfg + if (!cfg) { + throw new Error("Discord messaging actions require a resolved runtime config."); + } + const cfgOptions = { cfg }; + const resolvedReactionAccountId = accountId ?? resolveDefaultDiscordAccountId(cfg); + const reactionRuntimeOptions = resolvedReactionAccountId ? createDiscordRuntimeAccountContext({ cfg, - accountId: accountId ?? resolveDefaultDiscordAccountId(cfg), + accountId: resolvedReactionAccountId, }) - : accountId - ? { accountId } - : undefined; + : cfgOptions; const withReactionRuntimeOptions = (extra?: Record) => ({ ...(reactionRuntimeOptions ?? cfgOptions), ...extra, diff --git a/extensions/discord/src/actions/runtime.test.ts b/extensions/discord/src/actions/runtime.test.ts index 8dd6006685a..7206169727c 100644 --- a/extensions/discord/src/actions/runtime.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -82,6 +82,20 @@ const { } = discordSendMocks; const enableAllActions = () => true; +const DISCORD_TEST_CFG = {} as OpenClawConfig; + +function handleMessagingAction( + action: string, + params: Record, + isActionEnabled: (key: keyof DiscordActionConfig) => boolean, + options?: { + mediaLocalRoots?: readonly string[]; + mediaReadFile?: (filePath: string) => Promise; + }, + cfg: OpenClawConfig = DISCORD_TEST_CFG, +) { + return handleDiscordMessagingAction(action, params, isActionEnabled, options, cfg); +} const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions"; const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo"; @@ -112,7 +126,7 @@ describe("handleDiscordMessagingAction", () => { messageId: "M1", emoji: "✅", }, - expectedOptions: undefined, + expectedOptions: { cfg: DISCORD_TEST_CFG, accountId: "default" }, }, { name: "with accountId", @@ -122,19 +136,21 @@ describe("handleDiscordMessagingAction", () => { emoji: "✅", accountId: "ops", }, - expectedOptions: { accountId: "ops" }, + expectedOptions: { cfg: DISCORD_TEST_CFG, accountId: "ops" }, }, ])("adds reactions $name", async ({ params, expectedOptions }) => { - await handleDiscordMessagingAction("react", params, enableAllActions); + await handleMessagingAction("react", params, enableAllActions); if (expectedOptions) { expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", expectedOptions); return; } - expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {}); + expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", { + cfg: DISCORD_TEST_CFG, + }); }); it("uses configured defaultAccount when cfg is provided and accountId is omitted", async () => { - await handleDiscordMessagingAction( + await handleMessagingAction( "react", { channelId: "C1", @@ -164,7 +180,7 @@ describe("handleDiscordMessagingAction", () => { }); it("removes reactions on empty emoji", async () => { - await handleDiscordMessagingAction( + await handleMessagingAction( "react", { channelId: "C1", @@ -173,11 +189,14 @@ describe("handleDiscordMessagingAction", () => { }, enableAllActions, ); - expect(removeOwnReactionsDiscord).toHaveBeenCalledWith("C1", "M1", {}); + expect(removeOwnReactionsDiscord).toHaveBeenCalledWith("C1", "M1", { + cfg: DISCORD_TEST_CFG, + accountId: "default", + }); }); it("removes reactions when remove flag set", async () => { - await handleDiscordMessagingAction( + await handleMessagingAction( "react", { channelId: "C1", @@ -187,12 +206,15 @@ describe("handleDiscordMessagingAction", () => { }, enableAllActions, ); - expect(removeReactionDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {}); + expect(removeReactionDiscord).toHaveBeenCalledWith("C1", "M1", "✅", { + cfg: DISCORD_TEST_CFG, + accountId: "default", + }); }); it("rejects removes without emoji", async () => { await expect( - handleDiscordMessagingAction( + handleMessagingAction( "react", { channelId: "C1", @@ -207,7 +229,7 @@ describe("handleDiscordMessagingAction", () => { it("respects reaction gating", async () => { await expect( - handleDiscordMessagingAction( + handleMessagingAction( "react", { channelId: "C1", @@ -220,7 +242,7 @@ describe("handleDiscordMessagingAction", () => { }); it("parses string booleans for poll options", async () => { - await handleDiscordMessagingAction( + await handleMessagingAction( "poll", { to: "channel:123", @@ -249,7 +271,7 @@ describe("handleDiscordMessagingAction", () => { { id: "1", timestamp: "2026-01-15T10:00:00.000Z" }, ] as never); - const result = await handleDiscordMessagingAction( + const result = await handleMessagingAction( "readMessages", { channelId: "C1" }, enableAllActions, @@ -271,13 +293,7 @@ describe("handleDiscordMessagingAction", () => { }, }, } as OpenClawConfig; - await handleDiscordMessagingAction( - "readMessages", - { channelId: "C1" }, - enableAllActions, - {}, - cfg, - ); + await handleMessagingAction("readMessages", { channelId: "C1" }, enableAllActions, {}, cfg); expect(readMessagesDiscord).toHaveBeenCalledWith("C1", expect.any(Object), { cfg }); }); @@ -287,7 +303,7 @@ describe("handleDiscordMessagingAction", () => { timestamp: "2026-01-15T11:00:00.000Z", }); - const result = await handleDiscordMessagingAction( + const result = await handleMessagingAction( "fetchMessage", { guildId: "G1", channelId: "C1", messageId: "M1" }, enableAllActions, @@ -307,7 +323,7 @@ describe("handleDiscordMessagingAction", () => { }, }, } as OpenClawConfig; - await handleDiscordMessagingAction( + await handleMessagingAction( "fetchMessage", { guildId: "G1", channelId: "C1", messageId: "M1" }, enableAllActions, @@ -320,11 +336,7 @@ describe("handleDiscordMessagingAction", () => { it("adds normalized timestamps to listPins payloads", async () => { listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]); - const result = await handleDiscordMessagingAction( - "listPins", - { channelId: "C1" }, - enableAllActions, - ); + const result = await handleMessagingAction("listPins", { channelId: "C1" }, enableAllActions); const payload = result.details as { pins: Array<{ timestampMs?: number; timestampUtc?: string }>; }; @@ -340,7 +352,7 @@ describe("handleDiscordMessagingAction", () => { messages: [[{ id: "1", timestamp: "2026-01-15T13:00:00.000Z" }]], }); - const result = await handleDiscordMessagingAction( + const result = await handleMessagingAction( "searchMessages", { guildId: "G1", content: "hi" }, enableAllActions, @@ -360,7 +372,7 @@ describe("handleDiscordMessagingAction", () => { sendVoiceMessageDiscord.mockClear(); sendMessageDiscord.mockClear(); - await handleDiscordMessagingAction( + await handleMessagingAction( "sendMessage", { to: "channel:123", @@ -372,6 +384,7 @@ describe("handleDiscordMessagingAction", () => { ); expect(sendVoiceMessageDiscord).toHaveBeenCalledWith("channel:123", "/tmp/voice.mp3", { + cfg: DISCORD_TEST_CFG, replyTo: undefined, silent: true, }); @@ -380,7 +393,7 @@ describe("handleDiscordMessagingAction", () => { it("forwards trusted mediaLocalRoots into sendMessageDiscord", async () => { sendMessageDiscord.mockClear(); - await handleDiscordMessagingAction( + await handleMessagingAction( "sendMessage", { to: "channel:123", @@ -404,7 +417,7 @@ describe("handleDiscordMessagingAction", () => { sendMessageDiscord.mockClear(); sendDiscordComponentMessage.mockClear(); - await handleDiscordMessagingAction( + await handleMessagingAction( "sendMessage", { to: "channel:123", @@ -429,7 +442,7 @@ describe("handleDiscordMessagingAction", () => { it("forwards the optional filename into sendMessageDiscord", async () => { sendMessageDiscord.mockClear(); - await handleDiscordMessagingAction( + await handleMessagingAction( "sendMessage", { to: "channel:123", @@ -451,7 +464,7 @@ describe("handleDiscordMessagingAction", () => { it("rejects voice messages that include content", async () => { await expect( - handleDiscordMessagingAction( + handleMessagingAction( "sendMessage", { to: "channel:123", @@ -466,7 +479,7 @@ describe("handleDiscordMessagingAction", () => { it("forwards optional thread content", async () => { createThreadDiscord.mockClear(); - await handleDiscordMessagingAction( + await handleMessagingAction( "threadCreate", { channelId: "C1", @@ -484,7 +497,7 @@ describe("handleDiscordMessagingAction", () => { content: "Initial forum post body", appliedTags: undefined, }, - {}, + { cfg: DISCORD_TEST_CFG }, ); }); }); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index d193dca68fe..1b8b69834d4 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -778,8 +778,13 @@ export const discordPlugin: ChannelPlugin idLabel: "discordUserId", message: PAIRING_APPROVED_MESSAGE, normalizeAllowEntry: createPairingPrefixStripper(/^(discord|user):/i), - notify: async ({ id, message }) => { - await (await loadDiscordSendModule()).sendMessageDiscord(`user:${id}`, message); + notify: async ({ cfg, id, message, accountId }) => { + await ( + await loadDiscordSendModule() + ).sendMessageDiscord(`user:${id}`, message, { + cfg, + ...(accountId ? { accountId } : {}), + }); }, }, }, diff --git a/extensions/discord/src/client.ts b/extensions/discord/src/client.ts index ff29dc19b94..1aa6e0a9a95 100644 --- a/extensions/discord/src/client.ts +++ b/extensions/discord/src/client.ts @@ -1,5 +1,5 @@ import { RequestClient } from "@buape/carbon"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { RetryConfig, RetryRunner } from "openclaw/plugin-sdk/retry-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; @@ -16,7 +16,7 @@ import type { DiscordRuntimeAccountContext } from "./send.types.js"; import { normalizeDiscordToken } from "./token.js"; export type DiscordClientOpts = { - cfg?: ReturnType; + cfg?: OpenClawConfig; token?: string; accountId?: string; rest?: RequestClient; @@ -25,7 +25,7 @@ export type DiscordClientOpts = { }; export function createDiscordRuntimeAccountContext(params: { - cfg: ReturnType; + cfg: OpenClawConfig; accountId: string; }): DiscordRuntimeAccountContext { return { @@ -36,10 +36,16 @@ export function createDiscordRuntimeAccountContext(params: { export function resolveDiscordClientAccountContext( opts: Pick, - cfg?: ReturnType, + cfg?: OpenClawConfig, runtime?: Pick, ) { - const resolvedCfg = opts.cfg ?? cfg ?? loadConfig(); + const config = opts.cfg ?? cfg; + if (!config) { + throw new Error( + "Discord client requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", + ); + } + const resolvedCfg = requireRuntimeConfig(config, "Discord client"); const account = resolveAccountWithoutToken({ cfg: resolvedCfg, accountId: opts.accountId, @@ -63,7 +69,7 @@ function resolveToken(params: { accountId: string; fallbackToken?: string }) { export function resolveDiscordProxyFetch( opts: Pick, - cfg?: ReturnType, + cfg?: OpenClawConfig, runtime?: Pick, ): typeof fetch | undefined { return resolveDiscordClientAccountContext(opts, cfg, runtime).proxyFetch; @@ -72,7 +78,7 @@ export function resolveDiscordProxyFetch( function resolveRest( token: string, account: ResolvedDiscordAccount, - cfg: ReturnType, + cfg: OpenClawConfig, rest?: RequestClient, proxyFetch?: typeof fetch, ) { @@ -87,7 +93,7 @@ function resolveRest( } function resolveAccountWithoutToken(params: { - cfg: ReturnType; + cfg: OpenClawConfig; accountId?: string; }): ResolvedDiscordAccount { const accountId = normalizeAccountId(params.accountId); @@ -104,10 +110,7 @@ function resolveAccountWithoutToken(params: { }; } -export function createDiscordRestClient( - opts: DiscordClientOpts, - cfg?: ReturnType, -) { +export function createDiscordRestClient(opts: DiscordClientOpts, cfg?: OpenClawConfig) { const explicitToken = normalizeDiscordToken(opts.token, "channels.discord.token"); const proxyContext = resolveDiscordClientAccountContext(opts, cfg); const resolvedCfg = proxyContext.cfg; @@ -126,7 +129,7 @@ export function createDiscordRestClient( export function createDiscordClient( opts: DiscordClientOpts, - cfg?: ReturnType, + cfg?: OpenClawConfig, ): { token: string; rest: RequestClient; request: RetryRunner } { const { token, rest, account } = createDiscordRestClient(opts, opts.cfg ?? cfg); const request = createDiscordRetryRunner({ diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index e1923cae0d2..62ccd1fee50 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -272,6 +272,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: { text: buildPluginBindingResolvedText(resolved), }, { + cfg: params.ctx.cfg, accountId: params.ctx.accountId, }, ); diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index e3ab1e38255..86730022e85 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -11,7 +11,7 @@ import { import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native"; import { hasControlCommand } from "openclaw/plugin-sdk/command-detection"; import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface"; -import { isDangerousNameMatchingEnabled, loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-binding-runtime"; import { enqueueSystemEvent, recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { @@ -536,6 +536,7 @@ export async function preflightDiscordMessage( code, }), { + cfg: params.cfg, token: params.token, rest: params.client.rest, accountId: params.accountId, @@ -602,15 +603,14 @@ export async function preflightDiscordMessage( earlyThreadParentType = parentInfo.type; } - // Use the active runtime snapshot for bindings lookup; routing inputs are - // still payload-derived, but this path should not reparse config from disk. + // Routing inputs are payload-derived, but config must come from the boundary + // snapshot already threaded into the monitor path. const memberRoleIds = Array.isArray(params.data.rawMember?.roles) ? params.data.rawMember.roles : []; - const freshCfg = loadConfig(); const conversationRuntime = await loadConversationRuntime(); const route = resolveDiscordConversationRoute({ - cfg: freshCfg, + cfg: params.cfg, accountId: params.accountId, guildId: params.data.guild_id ?? undefined, memberRoleIds, @@ -639,7 +639,7 @@ export async function preflightDiscordMessage( const configuredRoute = threadBinding == null ? conversationRuntime.resolveConfiguredBindingRoute({ - cfg: freshCfg, + cfg: params.cfg, route, conversation: { channel: "discord", @@ -1047,7 +1047,7 @@ export async function preflightDiscordMessage( } if (configuredBinding) { const ensured = await conversationRuntime.ensureConfiguredBindingRouteReady({ - cfg: freshCfg, + cfg: params.cfg, bindingResolution: configuredBinding, }); if (!ensured.ok) { diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index da672c6c724..14df72f7b5a 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -842,7 +842,7 @@ describe("discord component interactions", () => { "user:123456789", "msg-1", { text: expect.any(String) }, - { accountId: "default" }, + expect.objectContaining({ accountId: "default" }), ); expect(dispatchReplyMock).not.toHaveBeenCalled(); }); diff --git a/extensions/discord/src/monitor/thread-bindings.discord-api.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.ts index 0fff80bd663..9ac8e58ed1b 100644 --- a/extensions/discord/src/monitor/thread-bindings.discord-api.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.ts @@ -124,7 +124,7 @@ export function isDiscordThreadGoneError(err: unknown): boolean { } export async function maybeSendBindingMessage(params: { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; record: ThreadBindingRecord; text: string; preferWebhook?: boolean; diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index 60e05db5d48..695332578a8 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -259,6 +259,7 @@ describe("thread binding lifecycle", () => { try { const manager = createThreadBindingManager({ accountId: "default", + cfg: {} as OpenClawConfig, persist: false, enableSweeper: false, idleTimeoutMs: 60_000, @@ -298,6 +299,7 @@ describe("thread binding lifecycle", () => { try { const manager = createThreadBindingManager({ accountId: "default", + cfg: {} as OpenClawConfig, persist: false, enableSweeper: false, idleTimeoutMs: 0, diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts index a2cc0d1db21..2245b05fe1f 100644 --- a/extensions/discord/src/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -491,7 +491,7 @@ export function createThreadBindingManager( } const introText = bindParams.introText?.trim(); - if (introText) { + if (introText && cfg) { void maybeSendBindingMessage({ cfg, record, text: introText }); } return record; @@ -532,12 +532,14 @@ export function createThreadBindingManager( }); // Use bot send path for farewell messages so unbound threads don't process // webhook echoes as fresh inbound turns when allowBots is enabled. - void maybeSendBindingMessage({ - cfg, - record: removed, - text: farewell, - preferWebhook: false, - }); + if (cfg) { + void maybeSendBindingMessage({ + cfg, + record: removed, + text: farewell, + preferWebhook: false, + }); + } } return removed; }, diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 156ff51becf..1ee70643ce1 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -113,7 +113,7 @@ function resolveDiscordWebhookIdentity(params: { } async function maybeSendDiscordWebhookText(params: { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; text: string; threadId?: string | number | null; accountId?: string | null; diff --git a/extensions/discord/src/recipient-resolution.ts b/extensions/discord/src/recipient-resolution.ts index 4f366c20eab..2b325828eca 100644 --- a/extensions/discord/src/recipient-resolution.ts +++ b/extensions/discord/src/recipient-resolution.ts @@ -1,4 +1,4 @@ -import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveDiscordAccount } from "./accounts.js"; import { parseAndResolveDiscordTarget } from "./target-resolver.js"; @@ -17,7 +17,12 @@ export async function parseAndResolveRecipient( accountId?: string, cfg?: OpenClawConfig, ): Promise { - const resolvedCfg = cfg ?? loadConfig(); + if (!cfg) { + throw new Error( + "Discord recipient resolution requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", + ); + } + const resolvedCfg = requireRuntimeConfig(cfg, "Discord recipient resolution"); const accountInfo = resolveDiscordAccount({ cfg: resolvedCfg, accountId }); const trimmed = raw.trim(); const parseOptions = { diff --git a/extensions/discord/src/send.components.test.ts b/extensions/discord/src/send.components.test.ts index 26cd3f43c68..484b4162ce9 100644 --- a/extensions/discord/src/send.components.test.ts +++ b/extensions/discord/src/send.components.test.ts @@ -4,6 +4,17 @@ import { makeDiscordRest } from "./send.test-harness.js"; const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } }))); +const DISCORD_TEST_CFG = { + channels: { + discord: { + accounts: { + default: {}, + }, + }, + }, + session: { dmScope: "main" }, +} as const; + vi.mock("openclaw/plugin-sdk/config-runtime", async () => { const actual = await vi.importActual( "openclaw/plugin-sdk/config-runtime", @@ -75,6 +86,7 @@ describe("sendDiscordComponentMessage", () => { blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }], }, { + cfg: DISCORD_TEST_CFG, rest, token: "t", sessionKey: "agent:main:discord:channel:dm-1", @@ -103,6 +115,7 @@ describe("sendDiscordComponentMessage", () => { blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }], }, { + cfg: DISCORD_TEST_CFG, rest, token: "t", sessionKey: "agent:main:discord:channel:chan-1", @@ -153,6 +166,7 @@ describe("sendDiscordComponentMessage classic message downgrade", () => { "channel:chan-1", { blocks: [{ type: "text", text: "report" }] }, { + cfg: DISCORD_TEST_CFG, token: "t", mediaUrl: "https://example.com/report.pdf", mediaReadFile: readFileMock, @@ -189,6 +203,7 @@ describe("sendDiscordComponentMessage classic message downgrade", () => { }, }, { + cfg: DISCORD_TEST_CFG, rest, token: "t", mediaUrl: "https://example.com/report.pdf", @@ -219,6 +234,7 @@ describe("sendDiscordComponentMessage classic message downgrade", () => { blocks: [{ type: "file", file: "attachment://report.pdf", spoiler: true }], }, { + cfg: DISCORD_TEST_CFG, rest, token: "t", mediaUrl: "https://example.com/report.pdf", @@ -246,6 +262,7 @@ describe("sendDiscordComponentMessage classic message downgrade", () => { }, }, { + cfg: DISCORD_TEST_CFG, rest, token: "t", mediaUrl: "https://example.com/report.pdf", diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index 8f11c6e99a3..35f19c20ba7 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -5,7 +5,7 @@ import { type RequestClient, } from "@buape/carbon"; import { ChannelType, Routes } from "discord-api-types/v10"; -import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { resolveDiscordAccount } from "./accounts.js"; import { registerDiscordComponentEntries } from "./components-registry.js"; @@ -141,7 +141,7 @@ function collapseClassicComponentText(spec: DiscordComponentMessageSpec): string } type DiscordComponentSendOpts = { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; accountId?: string; token?: string; rest?: RequestClient; @@ -244,7 +244,7 @@ async function buildDiscordComponentPayload(params: { export async function sendDiscordComponentMessage( to: string, spec: DiscordComponentMessageSpec, - opts: DiscordComponentSendOpts = {}, + opts: DiscordComponentSendOpts, ): Promise { const classicDecision = getClassicDiscordMessageDecision(spec); if (opts.mediaUrl && classicDecision.mode === "classic") { @@ -263,7 +263,7 @@ export async function sendDiscordComponentMessage( }); } - const cfg = opts.cfg ?? loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "Discord component send"); const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId }); const { token, rest, request } = createDiscordClient(opts, cfg); const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg); @@ -293,6 +293,7 @@ export async function sendDiscordComponentMessage( } catch (err) { throw await buildDiscordSendError(err, { channelId, + cfg, rest, token, hasMedia: Boolean(opts.mediaUrl), @@ -320,9 +321,9 @@ export async function editDiscordComponentMessage( to: string, messageId: string, spec: DiscordComponentMessageSpec, - opts: DiscordComponentSendOpts = {}, + opts: DiscordComponentSendOpts, ): Promise { - const cfg = opts.cfg ?? loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "Discord component edit"); const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId }); const { token, rest, request } = createDiscordClient(opts, cfg); const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg); @@ -345,6 +346,7 @@ export async function editDiscordComponentMessage( } catch (err) { throw await buildDiscordSendError(err, { channelId, + cfg, rest, token, hasMedia: Boolean(opts.mediaUrl), diff --git a/extensions/discord/src/send.creates-thread.test.ts b/extensions/discord/src/send.creates-thread.test.ts index 1b399cc091f..c7202fc0846 100644 --- a/extensions/discord/src/send.creates-thread.test.ts +++ b/extensions/discord/src/send.creates-thread.test.ts @@ -23,6 +23,20 @@ let timeoutMemberDiscord: typeof import("./send.js").timeoutMemberDiscord; let uploadEmojiDiscord: typeof import("./send.js").uploadEmojiDiscord; let uploadStickerDiscord: typeof import("./send.js").uploadStickerDiscord; +const DISCORD_TEST_CFG = { + channels: { + discord: { + accounts: { + default: {}, + }, + }, + }, +}; + +function discordClientOpts(rest: ReturnType["rest"]) { + return { cfg: DISCORD_TEST_CFG, rest, token: "t" }; +} + function createCompatRateLimitError( response: Response, body: { message: string; retry_after: number; global: boolean }, @@ -75,7 +89,11 @@ describe("sendMessageDiscord", () => { it("creates a thread", async () => { const { rest, getMock, postMock } = makeDiscordRest(); postMock.mockResolvedValue({ id: "t1" }); - await createThreadDiscord("chan1", { name: "thread", messageId: "m1" }, { rest, token: "t" }); + await createThreadDiscord( + "chan1", + { name: "thread", messageId: "m1" }, + discordClientOpts(rest), + ); expect(getMock).not.toHaveBeenCalled(); expect(postMock).toHaveBeenCalledWith( Routes.threads("chan1", "m1"), @@ -87,7 +105,7 @@ describe("sendMessageDiscord", () => { const { rest, getMock, postMock } = makeDiscordRest(); getMock.mockResolvedValue({ type: ChannelType.GuildForum }); postMock.mockResolvedValue({ id: "t1" }); - await createThreadDiscord("chan1", { name: "thread" }, { rest, token: "t" }); + await createThreadDiscord("chan1", { name: "thread" }, discordClientOpts(rest)); expect(getMock).toHaveBeenCalledWith(Routes.channel("chan1")); expect(postMock).toHaveBeenCalledWith( Routes.threads("chan1"), @@ -107,7 +125,7 @@ describe("sendMessageDiscord", () => { await createThreadDiscord( "chan1", { name: "thread", content: "initial forum post" }, - { rest, token: "t" }, + discordClientOpts(rest), ); expect(postMock).toHaveBeenCalledWith( Routes.threads("chan1"), @@ -127,7 +145,7 @@ describe("sendMessageDiscord", () => { await createThreadDiscord( "chan1", { name: "tagged post", appliedTags: ["tag1", "tag2"] }, - { rest, token: "t" }, + discordClientOpts(rest), ); expect(postMock).toHaveBeenCalledWith( Routes.threads("chan1"), @@ -148,7 +166,7 @@ describe("sendMessageDiscord", () => { await createThreadDiscord( "chan1", { name: "thread", appliedTags: ["tag1"] }, - { rest, token: "t" }, + discordClientOpts(rest), ); expect(postMock).toHaveBeenCalledWith( Routes.threads("chan1"), @@ -162,7 +180,7 @@ describe("sendMessageDiscord", () => { const { rest, getMock, postMock } = makeDiscordRest(); getMock.mockRejectedValue(new Error("lookup failed")); postMock.mockResolvedValue({ id: "t1" }); - await createThreadDiscord("chan1", { name: "thread" }, { rest, token: "t" }); + await createThreadDiscord("chan1", { name: "thread" }, discordClientOpts(rest)); expect(postMock).toHaveBeenCalledWith( Routes.threads("chan1"), expect.objectContaining({ @@ -178,7 +196,7 @@ describe("sendMessageDiscord", () => { await createThreadDiscord( "chan1", { name: "thread", type: ChannelType.PrivateThread }, - { rest, token: "t" }, + discordClientOpts(rest), ); expect(getMock).toHaveBeenCalledWith(Routes.channel("chan1")); expect(postMock).toHaveBeenCalledWith( @@ -196,7 +214,7 @@ describe("sendMessageDiscord", () => { await createThreadDiscord( "chan1", { name: "thread", content: "Hello thread!" }, - { rest, token: "t" }, + discordClientOpts(rest), ); expect(postMock).toHaveBeenCalledTimes(2); // First call: create thread @@ -223,7 +241,7 @@ describe("sendMessageDiscord", () => { await createThreadDiscord( "chan1", { name: "thread", messageId: "m1", content: "Discussion here" }, - { rest, token: "t" }, + discordClientOpts(rest), ); // Should not detect channel type for message-attached threads expect(getMock).not.toHaveBeenCalled(); @@ -247,7 +265,7 @@ describe("sendMessageDiscord", () => { it("lists active threads by guild", async () => { const { rest, getMock } = makeDiscordRest(); getMock.mockResolvedValue({ threads: [] }); - await listThreadsDiscord({ guildId: "g1" }, { rest, token: "t" }); + await listThreadsDiscord({ guildId: "g1" }, discordClientOpts(rest)); expect(getMock).toHaveBeenCalledWith(Routes.guildActiveThreads("g1")); }); @@ -256,7 +274,7 @@ describe("sendMessageDiscord", () => { patchMock.mockResolvedValue({ id: "m1" }); await timeoutMemberDiscord( { guildId: "g1", userId: "u1", durationMinutes: 10 }, - { rest, token: "t" }, + discordClientOpts(rest), ); expect(patchMock).toHaveBeenCalledWith( Routes.guildMember("g1", "u1"), @@ -272,8 +290,8 @@ describe("sendMessageDiscord", () => { const { rest, putMock, deleteMock } = makeDiscordRest(); putMock.mockResolvedValue({}); deleteMock.mockResolvedValue({}); - await addRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, { rest, token: "t" }); - await removeRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, { rest, token: "t" }); + await addRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, discordClientOpts(rest)); + await removeRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, discordClientOpts(rest)); expect(putMock).toHaveBeenCalledWith(Routes.guildMemberRole("g1", "u1", "r1")); expect(deleteMock).toHaveBeenCalledWith(Routes.guildMemberRole("g1", "u1", "r1")); }); @@ -283,7 +301,7 @@ describe("sendMessageDiscord", () => { putMock.mockResolvedValue({}); await banMemberDiscord( { guildId: "g1", userId: "u1", deleteMessageDays: 2 }, - { rest, token: "t" }, + discordClientOpts(rest), ); expect(putMock).toHaveBeenCalledWith( Routes.guildBan("g1", "u1"), @@ -300,7 +318,7 @@ describe("listGuildEmojisDiscord", () => { it("lists emojis for a guild", async () => { const { rest, getMock } = makeDiscordRest(); getMock.mockResolvedValue([{ id: "e1", name: "party" }]); - await listGuildEmojisDiscord("g1", { rest, token: "t" }); + await listGuildEmojisDiscord("g1", discordClientOpts(rest)); expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1")); }); }); @@ -320,7 +338,7 @@ describe("uploadEmojiDiscord", () => { mediaUrl: "file:///tmp/party.png", roleIds: ["r1"], }, - { rest, token: "t" }, + discordClientOpts(rest), ); expect(postMock).toHaveBeenCalledWith( Routes.guildEmojis("g1"), @@ -352,7 +370,7 @@ describe("uploadStickerDiscord", () => { tags: "👋", mediaUrl: "file:///tmp/wave.png", }, - { rest, token: "t" }, + discordClientOpts(rest), ); expect(postMock).toHaveBeenCalledWith( Routes.guildStickers("g1"), @@ -383,6 +401,7 @@ describe("sendStickerDiscord", () => { const { rest, postMock } = makeDiscordRest(); postMock.mockResolvedValue({ id: "msg1", channel_id: "789" }); const res = await sendStickerDiscord("channel:789", ["123"], { + cfg: DISCORD_TEST_CFG, rest, token: "t", content: "hiya", @@ -415,6 +434,7 @@ describe("sendPollDiscord", () => { options: ["Pizza", "Sushi"], }, { + cfg: DISCORD_TEST_CFG, rest, token: "t", }, @@ -473,6 +493,7 @@ describe("retry rate limits", () => { .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); const res = await sendMessageDiscord("channel:789", "hello", { + cfg: DISCORD_TEST_CFG, rest, token: "t", retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, @@ -493,6 +514,7 @@ describe("retry rate limits", () => { .mockResolvedValueOnce({ id: "msg1", channel_id: "789" }); const promise = sendMessageDiscord("channel:789", "hello", { + cfg: DISCORD_TEST_CFG, rest, token: "t", retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 }, @@ -516,6 +538,7 @@ describe("retry rate limits", () => { await expect( sendMessageDiscord("channel:789", "hello", { + cfg: DISCORD_TEST_CFG, rest, token: "t", retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, @@ -528,9 +551,9 @@ describe("retry rate limits", () => { const { rest, postMock } = makeDiscordRest(); postMock.mockRejectedValueOnce(new Error("network error")); - await expect(sendMessageDiscord("channel:789", "hello", { rest, token: "t" })).rejects.toThrow( - "network error", - ); + await expect( + sendMessageDiscord("channel:789", "hello", discordClientOpts(rest)), + ).rejects.toThrow("network error"); expect(postMock).toHaveBeenCalledTimes(1); }); @@ -541,6 +564,7 @@ describe("retry rate limits", () => { putMock.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce(undefined); const res = await reactMessageDiscord("chan1", "msg1", "ok", { + cfg: DISCORD_TEST_CFG, rest, token: "t", retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, @@ -561,6 +585,7 @@ describe("retry rate limits", () => { .mockResolvedValueOnce({ id: "msg2", channel_id: "789" }); const res = await sendMessageDiscord("channel:789", text, { + cfg: DISCORD_TEST_CFG, rest, token: "t", mediaUrl: "https://example.com/photo.jpg", diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index f45049bdd45..d21f3692392 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { serializePayload, type MessagePayloadObject, type RequestClient } from "@buape/carbon"; import { ChannelType, Routes } from "discord-api-types/v10"; -import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { maxBytesForKind } from "openclaw/plugin-sdk/media-runtime"; @@ -45,7 +45,7 @@ import { } from "./voice-message.js"; type DiscordSendOpts = { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; token?: string; accountId?: string; mediaUrl?: string; @@ -128,7 +128,7 @@ async function resolveDiscordSendTarget( to: string, opts: DiscordSendOpts, ): Promise<{ rest: RequestClient; request: DiscordClientRequest; channelId: string }> { - const cfg = opts.cfg ?? loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "Discord send target resolution"); const { rest, request } = createDiscordClient(opts, cfg); const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg); const { channelId } = await resolveChannelId(rest, recipient, request); @@ -138,9 +138,9 @@ async function resolveDiscordSendTarget( export async function sendMessageDiscord( to: string, text: string, - opts: DiscordSendOpts = {}, + opts: DiscordSendOpts, ): Promise { - const cfg = opts.cfg ?? loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "Discord send"); const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId, @@ -201,6 +201,7 @@ export async function sendMessageDiscord( } catch (err) { throw await buildDiscordSendError(err, { channelId, + cfg, rest, token, hasMedia: Boolean(opts.mediaUrl), @@ -255,6 +256,7 @@ export async function sendMessageDiscord( } catch (err) { throw await buildDiscordSendError(err, { channelId: threadId, + cfg, rest, token, hasMedia: Boolean(opts.mediaUrl), @@ -312,6 +314,7 @@ export async function sendMessageDiscord( } catch (err) { throw await buildDiscordSendError(err, { channelId, + cfg, rest, token, hasMedia: Boolean(opts.mediaUrl), @@ -327,7 +330,7 @@ export async function sendMessageDiscord( } type DiscordWebhookSendOpts = { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; webhookId: string; webhookToken: string; accountId?: string; @@ -423,7 +426,7 @@ export async function sendWebhookMessageDiscord( export async function sendStickerDiscord( to: string, stickerIds: string[], - opts: DiscordSendOpts & { content?: string } = {}, + opts: DiscordSendOpts & { content?: string }, ): Promise { const { rest, request, channelId, rewrittenContent } = await resolveDiscordStructuredSendContext( to, @@ -446,7 +449,7 @@ export async function sendStickerDiscord( export async function sendPollDiscord( to: string, poll: PollInput, - opts: DiscordSendOpts & { content?: string } = {}, + opts: DiscordSendOpts & { content?: string }, ): Promise { const { rest, request, channelId, rewrittenContent } = await resolveDiscordStructuredSendContext( to, @@ -480,7 +483,7 @@ async function resolveDiscordStructuredSendContext( channelId: string; rewrittenContent?: string; }> { - const cfg = opts.cfg ?? loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "Discord structured send"); const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId, @@ -496,7 +499,7 @@ async function resolveDiscordStructuredSendContext( } type VoiceMessageOpts = { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; token?: string; accountId?: string; verbose?: boolean; @@ -532,7 +535,7 @@ async function materializeVoiceMessageInput(mediaUrl: string): Promise<{ filePat export async function sendVoiceMessageDiscord( to: string, audioPath: string, - opts: VoiceMessageOpts = {}, + opts: VoiceMessageOpts, ): Promise { const { filePath: localInputPath } = await materializeVoiceMessageInput(audioPath); let oggPath: string | null = null; @@ -540,9 +543,10 @@ export async function sendVoiceMessageDiscord( let token: string | undefined; let rest: RequestClient | undefined; let channelId: string | undefined; + let cfg: OpenClawConfig | undefined; try { - const cfg = opts.cfg ?? loadConfig(); + cfg = requireRuntimeConfig(opts.cfg, "Discord voice send"); const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId, @@ -585,9 +589,10 @@ export async function sendVoiceMessageDiscord( return toDiscordSendResult(result, channelId); } catch (err) { - if (channelId && rest && token) { + if (channelId && rest && token && cfg) { throw await buildDiscordSendError(err, { channelId, + cfg, rest, token, hasMedia: true, diff --git a/extensions/discord/src/send.reactions.ts b/extensions/discord/src/send.reactions.ts index 7b9bf9d7880..b68629b97ab 100644 --- a/extensions/discord/src/send.reactions.ts +++ b/extensions/discord/src/send.reactions.ts @@ -1,5 +1,5 @@ import { Routes } from "discord-api-types/v10"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { buildReactionIdentifier, createDiscordClient, @@ -17,7 +17,12 @@ function createDiscordReactionRuntimeClient(opts: DiscordReactionRuntimeContext) } function resolveDiscordReactionClient(opts: DiscordReactOpts) { - const cfg = opts.cfg ?? loadConfig(); + if (!opts.cfg) { + throw new Error( + "Discord reactions requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", + ); + } + const cfg = requireRuntimeConfig(opts.cfg, "Discord reactions"); return createDiscordClient(opts, cfg); } diff --git a/extensions/discord/src/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts index 8adfd877967..7c82e8fae8b 100644 --- a/extensions/discord/src/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -20,6 +20,10 @@ let loadWebMedia: typeof import("openclaw/plugin-sdk/web-media").loadWebMedia; let __resetDiscordDirectoryCacheForTest: typeof import("./directory-cache.js").__resetDiscordDirectoryCacheForTest; let rememberDiscordDirectoryUser: typeof import("./directory-cache.js").rememberDiscordDirectoryUser; +const DISCORD_TEST_CFG = { + channels: { discord: { token: "t" } }, +}; + beforeAll(async () => { ({ deleteMessageDiscord, @@ -62,6 +66,7 @@ describe("sendMessageDiscord", () => { await sendMessageDiscord("channel:789", params.text, { rest, token: "t", + cfg: DISCORD_TEST_CFG, replyTo: "orig-123", ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), }); @@ -95,6 +100,7 @@ describe("sendMessageDiscord", () => { const res = await sendMessageDiscord("channel:789", "hello world", { rest, token: "t", + cfg: DISCORD_TEST_CFG, }); expect(res).toEqual({ messageId: "msg1", channelId: "789" }); expect(postMock).toHaveBeenCalledWith( @@ -118,6 +124,7 @@ describe("sendMessageDiscord", () => { await sendMessageDiscord("channel:789", "ping @Alice", { rest, token: "t", + cfg: DISCORD_TEST_CFG, accountId: "default", }); expect(postMock).toHaveBeenCalledWith( @@ -171,6 +178,7 @@ describe("sendMessageDiscord", () => { const res = await sendMessageDiscord("channel:forum1", "Discussion topic\nBody of the post", { rest, token: "t", + cfg: DISCORD_TEST_CFG, }); expect(res).toEqual({ messageId: "starter1", channelId: "thread1" }); // Should POST to threads route, not channelMessages. @@ -190,6 +198,7 @@ describe("sendMessageDiscord", () => { const res = await sendMessageDiscord("channel:forum1", "Topic", { rest, token: "t", + cfg: DISCORD_TEST_CFG, mediaUrl: "file:///tmp/photo.jpg", }); expect(res).toEqual({ messageId: "starter1", channelId: "thread1" }); @@ -220,6 +229,7 @@ describe("sendMessageDiscord", () => { await sendMessageDiscord("channel:forum1", longText, { rest, token: "t", + cfg: DISCORD_TEST_CFG, }); const firstBody = postMock.mock.calls[0]?.[1]?.body as { message?: { content?: string }; @@ -237,6 +247,7 @@ describe("sendMessageDiscord", () => { const res = await sendMessageDiscord("user:123", "hiya", { rest, token: "t", + cfg: DISCORD_TEST_CFG, }); expect(postMock).toHaveBeenNthCalledWith( 1, @@ -254,13 +265,25 @@ describe("sendMessageDiscord", () => { it("rejects bare numeric IDs as ambiguous", async () => { const { rest } = makeDiscordRest(); await expect( - sendMessageDiscord("273512430271856640", "hello", { rest, token: "t" }), + sendMessageDiscord("273512430271856640", "hello", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + }), ).rejects.toThrow(/Ambiguous Discord recipient/); await expect( - sendMessageDiscord("273512430271856640", "hello", { rest, token: "t" }), + sendMessageDiscord("273512430271856640", "hello", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + }), ).rejects.toThrow(/user:273512430271856640/); await expect( - sendMessageDiscord("273512430271856640", "hello", { rest, token: "t" }), + sendMessageDiscord("273512430271856640", "hello", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + }), ).rejects.toThrow(/channel:273512430271856640/); }); @@ -289,7 +312,7 @@ describe("sendMessageDiscord", () => { let error: unknown; try { - await sendMessageDiscord("channel:789", "hello", { rest, token: "t" }); + await sendMessageDiscord("channel:789", "hello", { rest, token: "t", cfg: DISCORD_TEST_CFG }); } catch (err) { error = err; } @@ -303,6 +326,7 @@ describe("sendMessageDiscord", () => { const res = await sendMessageDiscord("channel:789", "photo", { rest, token: "t", + cfg: DISCORD_TEST_CFG, mediaUrl: "file:///tmp/photo.jpg", }); expect(res.messageId).toBe("msg"); @@ -327,6 +351,7 @@ describe("sendMessageDiscord", () => { await sendMessageDiscord("channel:789", "photo", { rest, token: "t", + cfg: DISCORD_TEST_CFG, mediaUrl: "file:///tmp/generated-image", filename: "renderable.png", }); @@ -370,6 +395,7 @@ describe("sendMessageDiscord", () => { const res = await sendMessageDiscord("channel:789", "", { rest, token: "t", + cfg: DISCORD_TEST_CFG, mediaUrl: "file:///tmp/photo.jpg", }); expect(res.messageId).toBe("msg"); @@ -384,6 +410,7 @@ describe("sendMessageDiscord", () => { await sendMessageDiscord("channel:789", " spaced ", { rest, token: "t", + cfg: DISCORD_TEST_CFG, mediaUrl: "file:///tmp/photo.jpg", }); const body = postMock.mock.calls[0]?.[1]?.body; @@ -396,6 +423,7 @@ describe("sendMessageDiscord", () => { await sendMessageDiscord("channel:789", "hello", { rest, token: "t", + cfg: DISCORD_TEST_CFG, replyTo: "orig-123", }); const body = postMock.mock.calls[0]?.[1]?.body; @@ -430,7 +458,7 @@ describe("reactMessageDiscord", () => { it("reacts with unicode emoji", async () => { const { rest, putMock } = makeDiscordRest(); - await reactMessageDiscord("chan1", "msg1", "✅", { rest, token: "t" }); + await reactMessageDiscord("chan1", "msg1", "✅", { rest, token: "t", cfg: DISCORD_TEST_CFG }); expect(putMock).toHaveBeenCalledWith( Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"), ); @@ -438,7 +466,7 @@ describe("reactMessageDiscord", () => { it("normalizes variation selectors in unicode emoji", async () => { const { rest, putMock } = makeDiscordRest(); - await reactMessageDiscord("chan1", "msg1", "⭐️", { rest, token: "t" }); + await reactMessageDiscord("chan1", "msg1", "⭐️", { rest, token: "t", cfg: DISCORD_TEST_CFG }); expect(putMock).toHaveBeenCalledWith( Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%AD%90"), ); @@ -449,6 +477,7 @@ describe("reactMessageDiscord", () => { await reactMessageDiscord("chan1", "msg1", "<:party_blob:123>", { rest, token: "t", + cfg: DISCORD_TEST_CFG, }); expect(putMock).toHaveBeenCalledWith( Routes.channelMessageOwnReaction("chan1", "msg1", "party_blob%3A123"), @@ -463,7 +492,7 @@ describe("removeReactionDiscord", () => { it("removes a unicode emoji reaction", async () => { const { rest, deleteMock } = makeDiscordRest(); - await removeReactionDiscord("chan1", "msg1", "✅", { rest, token: "t" }); + await removeReactionDiscord("chan1", "msg1", "✅", { rest, token: "t", cfg: DISCORD_TEST_CFG }); expect(deleteMock).toHaveBeenCalledWith( Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"), ); @@ -486,6 +515,7 @@ describe("removeOwnReactionsDiscord", () => { const res = await removeOwnReactionsDiscord("chan1", "msg1", { rest, token: "t", + cfg: DISCORD_TEST_CFG, }); expect(res).toEqual({ ok: true, removed: ["✅", "party_blob:123"] }); expect(deleteMock).toHaveBeenCalledWith( @@ -516,6 +546,7 @@ describe("fetchReactionsDiscord", () => { const res = await fetchReactionsDiscord("chan1", "msg1", { rest, token: "t", + cfg: DISCORD_TEST_CFG, }); expect(res).toEqual([ { @@ -558,6 +589,7 @@ describe("fetchChannelPermissionsDiscord", () => { const res = await fetchChannelPermissionsDiscord("chan1", { rest, token: "t", + cfg: DISCORD_TEST_CFG, }); expect(res.guildId).toBe("guild1"); expect(res.permissions).toContain("ViewChannel"); @@ -588,6 +620,7 @@ describe("fetchChannelPermissionsDiscord", () => { const res = await fetchChannelPermissionsDiscord("chan1", { rest, token: "t", + cfg: DISCORD_TEST_CFG, }); expect(res.permissions).toContain("Administrator"); expect(res.permissions).toContain("ViewChannel"); @@ -602,7 +635,11 @@ describe("readMessagesDiscord", () => { it("passes query params as an object", async () => { const { rest, getMock } = makeDiscordRest(); getMock.mockResolvedValue([]); - await readMessagesDiscord("chan1", { limit: 5, before: "10" }, { rest, token: "t" }); + await readMessagesDiscord( + "chan1", + { limit: 5, before: "10" }, + { rest, token: "t", cfg: DISCORD_TEST_CFG }, + ); const call = getMock.mock.calls[0]; const options = call?.[1] as Record; expect(options).toEqual({ limit: 5, before: "10" }); @@ -617,7 +654,12 @@ describe("edit/delete message helpers", () => { it("edits message content", async () => { const { rest, patchMock } = makeDiscordRest(); patchMock.mockResolvedValue({ id: "m1" }); - await editMessageDiscord("chan1", "m1", { content: "hello" }, { rest, token: "t" }); + await editMessageDiscord( + "chan1", + "m1", + { content: "hello" }, + { rest, token: "t", cfg: DISCORD_TEST_CFG }, + ); expect(patchMock).toHaveBeenCalledWith( Routes.channelMessage("chan1", "m1"), expect.objectContaining({ body: { content: "hello" } }), @@ -627,7 +669,7 @@ describe("edit/delete message helpers", () => { it("deletes message", async () => { const { rest, deleteMock } = makeDiscordRest(); deleteMock.mockResolvedValue({}); - await deleteMessageDiscord("chan1", "m1", { rest, token: "t" }); + await deleteMessageDiscord("chan1", "m1", { rest, token: "t", cfg: DISCORD_TEST_CFG }); expect(deleteMock).toHaveBeenCalledWith(Routes.channelMessage("chan1", "m1")); }); }); @@ -641,8 +683,8 @@ describe("pin helpers", () => { const { rest, putMock, deleteMock } = makeDiscordRest(); putMock.mockResolvedValue({}); deleteMock.mockResolvedValue({}); - await pinMessageDiscord("chan1", "m1", { rest, token: "t" }); - await unpinMessageDiscord("chan1", "m1", { rest, token: "t" }); + await pinMessageDiscord("chan1", "m1", { rest, token: "t", cfg: DISCORD_TEST_CFG }); + await unpinMessageDiscord("chan1", "m1", { rest, token: "t", cfg: DISCORD_TEST_CFG }); expect(putMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1")); expect(deleteMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1")); }); @@ -658,7 +700,7 @@ describe("searchMessagesDiscord", () => { getMock.mockResolvedValue({ total_results: 0, messages: [] }); await searchMessagesDiscord( { guildId: "g1", content: "hello", limit: 5 }, - { rest, token: "t" }, + { rest, token: "t", cfg: DISCORD_TEST_CFG }, ); const call = getMock.mock.calls[0]; expect(call?.[0]).toBe("/guilds/g1/messages/search?content=hello&limit=5"); @@ -675,7 +717,7 @@ describe("searchMessagesDiscord", () => { authorIds: ["u1"], limit: 99, }, - { rest, token: "t" }, + { rest, token: "t", cfg: DISCORD_TEST_CFG }, ); const call = getMock.mock.calls[0]; expect(call?.[0]).toBe( diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index f3b7ef9d6d8..baa35a4b60e 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -9,6 +9,7 @@ import { 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 { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime"; import { extensionForMime } from "openclaw/plugin-sdk/media-runtime"; import { @@ -118,6 +119,7 @@ async function buildDiscordSendError( err: unknown, ctx: { channelId: string; + cfg: OpenClawConfig; rest: RequestClient; token: string; hasMedia: boolean; @@ -142,6 +144,7 @@ async function buildDiscordSendError( const permissions = await fetchChannelPermissionsDiscord(ctx.channelId, { rest: ctx.rest, token: ctx.token, + cfg: ctx.cfg, }); const current = new Set(permissions.permissions); const required = ["ViewChannel", "SendMessages"]; diff --git a/extensions/imessage/src/channel.runtime.ts b/extensions/imessage/src/channel.runtime.ts index b4b3f70f46a..6630ed5f8ca 100644 --- a/extensions/imessage/src/channel.runtime.ts +++ b/extensions/imessage/src/channel.runtime.ts @@ -41,8 +41,11 @@ export async function sendIMessageOutbound(params: { }); } -export async function notifyIMessageApproval(id: string): Promise { - await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); +export async function notifyIMessageApproval(params: { + cfg: Parameters[0]["cfg"]; + id: string; +}): Promise { + await sendMessageIMessage(params.id, PAIRING_APPROVED_MESSAGE, { config: params.cfg }); } export async function probeIMessageAccount(params?: { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index d9c5ac40de9..5b9aff82bc2 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -233,8 +233,8 @@ export const imessagePlugin: ChannelPlugin - await (await loadIMessageChannelRuntime()).notifyIMessageApproval(id), + notify: async ({ id, cfg }) => + await (await loadIMessageChannelRuntime()).notifyIMessageApproval({ id, cfg }), }, }, security: imessageSecurityAdapter, diff --git a/extensions/imessage/src/monitor/deliver.runtime.ts b/extensions/imessage/src/monitor/deliver.runtime.ts index 268462c94fe..e1fbcb5de6f 100644 --- a/extensions/imessage/src/monitor/deliver.runtime.ts +++ b/extensions/imessage/src/monitor/deliver.runtime.ts @@ -1,3 +1,3 @@ -export { loadConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; export { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; export { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/imessage/src/monitor/deliver.test.ts b/extensions/imessage/src/monitor/deliver.test.ts index 8526fee2b3c..8d2d7844e55 100644 --- a/extensions/imessage/src/monitor/deliver.test.ts +++ b/extensions/imessage/src/monitor/deliver.test.ts @@ -18,7 +18,6 @@ vi.mock("../send.js", () => ({ })); vi.mock("./deliver.runtime.js", () => ({ - loadConfig: vi.fn(() => ({})), resolveMarkdownTableMode: vi.fn(() => resolveMarkdownTableModeMock()), chunkTextWithMode: (text: string) => chunkTextWithModeMock(text), resolveChunkMode: vi.fn(() => resolveChunkModeMock()), @@ -28,6 +27,7 @@ vi.mock("./deliver.runtime.js", () => ({ let deliverReplies: typeof import("./deliver.js").deliverReplies; describe("deliverReplies", () => { + const IMESSAGE_TEST_CFG = { channels: { imessage: { accounts: { default: {} } } } }; const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv; const client = {} as Awaited>; @@ -44,6 +44,7 @@ describe("deliverReplies", () => { chunkTextWithModeMock.mockImplementation((text: string) => text.split("|")); await deliverReplies({ + cfg: IMESSAGE_TEST_CFG, replies: [{ text: "first|second", replyToId: "reply-1" }], target: "chat_id:10", client, @@ -60,6 +61,7 @@ describe("deliverReplies", () => { "first", expect.objectContaining({ client, + config: IMESSAGE_TEST_CFG, maxBytes: 4096, accountId: "default", replyToId: "reply-1", @@ -71,6 +73,7 @@ describe("deliverReplies", () => { "second", expect.objectContaining({ client, + config: IMESSAGE_TEST_CFG, maxBytes: 4096, accountId: "default", replyToId: "reply-1", @@ -80,6 +83,7 @@ describe("deliverReplies", () => { it("propagates payload replyToId through media sends", async () => { await deliverReplies({ + cfg: IMESSAGE_TEST_CFG, replies: [ { text: "caption", @@ -103,6 +107,7 @@ describe("deliverReplies", () => { expect.objectContaining({ mediaUrl: "https://example.com/a.jpg", client, + config: IMESSAGE_TEST_CFG, maxBytes: 8192, accountId: "acct-2", replyToId: "reply-2", @@ -115,6 +120,7 @@ describe("deliverReplies", () => { expect.objectContaining({ mediaUrl: "https://example.com/b.jpg", client, + config: IMESSAGE_TEST_CFG, maxBytes: 8192, accountId: "acct-2", replyToId: "reply-2", @@ -133,6 +139,7 @@ describe("deliverReplies", () => { .mockResolvedValueOnce({ messageId: "imsg-2", sentText: "second" }); await deliverReplies({ + cfg: IMESSAGE_TEST_CFG, replies: [{ text: "first|second" }], target: "chat_id:30", client, @@ -163,6 +170,7 @@ describe("deliverReplies", () => { }); await deliverReplies({ + cfg: IMESSAGE_TEST_CFG, replies: [{ mediaUrls: ["https://example.com/a.jpg"] }], target: "chat_id:40", client, diff --git a/extensions/imessage/src/monitor/deliver.ts b/extensions/imessage/src/monitor/deliver.ts index b92b969240f..7fbd51ada0c 100644 --- a/extensions/imessage/src/monitor/deliver.ts +++ b/extensions/imessage/src/monitor/deliver.ts @@ -1,3 +1,4 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { deliverTextOrMediaReply, resolveSendableOutboundReplyParts, @@ -9,7 +10,6 @@ import { sendMessageIMessage } from "../send.js"; import { chunkTextWithMode, convertMarkdownTables, - loadConfig, resolveChunkMode, resolveMarkdownTableMode, } from "./deliver.runtime.js"; @@ -17,6 +17,7 @@ import type { SentMessageCache } from "./echo-cache.js"; import { sanitizeOutboundText } from "./sanitize-outbound.js"; export async function deliverReplies(params: { + cfg: OpenClawConfig; replies: ReplyPayload[]; target: string; client: Awaited>; @@ -29,7 +30,7 @@ export async function deliverReplies(params: { const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params; const scope = `${accountId ?? ""}:${target}`; - const cfg = loadConfig(); + const { cfg } = params; const tableMode = resolveMarkdownTableMode({ cfg, channel: "imessage", @@ -47,6 +48,7 @@ export async function deliverReplies(params: { chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), sendText: async (chunk) => { const sent = await sendMessageIMessage(target, chunk, { + config: params.cfg, maxBytes, client, accountId, @@ -60,6 +62,7 @@ export async function deliverReplies(params: { }, sendMedia: async ({ mediaUrl, caption }) => { const sent = await sendMessageIMessage(target, caption ?? "", { + config: params.cfg, mediaUrl, maxBytes, client, diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 4669d42aec1..0bb3711dd01 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -352,6 +352,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }, sendPairingReply: async (text) => { await sendMessageIMessage(sender, text, { + config: cfg, client: getActiveClient(), maxBytes: mediaMaxBytes, accountId: accountInfo.accountId, @@ -450,6 +451,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P return; } await deliverReplies({ + cfg, replies: [payload], target, client: getActiveClient(), diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index dcc6d5cad1c..26864e0d9b4 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime"; @@ -22,7 +22,7 @@ export type IMessageSendOpts = { timeoutMs?: number; chatId?: number; client?: IMessageRpcClient; - config?: ReturnType; + config: OpenClawConfig; account?: ResolvedIMessageAccount; resolveAttachmentImpl?: ( mediaUrl: string, @@ -97,9 +97,9 @@ function resolveDeliveredIMessageText(text: string, mediaContentType?: string): export async function sendMessageIMessage( to: string, text: string, - opts: IMessageSendOpts = {}, + opts: IMessageSendOpts, ): Promise { - const cfg = opts.config ?? loadConfig(); + const cfg = requireRuntimeConfig(opts.config, "iMessage send"); const account = opts.account ?? resolveIMessageAccount({ diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index e0e8a5e2bee..bcfa5b8bec6 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -322,13 +322,15 @@ export const ircPlugin: ChannelPlugin = createChat idLabel: "ircUser", message: PAIRING_APPROVED_MESSAGE, normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry), - notify: async ({ id, message }) => { + notify: async ({ cfg, id, message }) => { const target = normalizePairingTarget(id); if (!target) { throw new Error(`invalid IRC pairing id: ${id}`); } const { sendMessageIrc } = await loadIrcChannelRuntime(); - await sendMessageIrc(target, message); + await sendMessageIrc(target, message, { + cfg: cfg as CoreConfig, + }); }, }, }, diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index ac6c1113b1a..5694d6a4e3e 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -58,6 +58,7 @@ function resolveIrcEffectiveAllowlists(params: { async function deliverIrcReply(params: { payload: OutboundReplyPayload; + cfg: CoreConfig; target: string; accountId: string; sendReply?: (target: string, text: string, replyToId?: string) => Promise; @@ -70,6 +71,7 @@ async function deliverIrcReply(params: { await params.sendReply(params.target, text, replyToId); } else { await sendMessageIrc(params.target, text, { + cfg: params.cfg, accountId: params.accountId, replyTo: replyToId, }); @@ -218,6 +220,7 @@ export async function handleIrcInbound(params: { sendPairingReply: async (text) => { await deliverIrcReply({ payload: { text }, + cfg: config, target: message.senderNick, accountId: account.accountId, sendReply: params.sendReply, @@ -340,6 +343,7 @@ export async function handleIrcInbound(params: { deliver: async (payload) => { await deliverIrcReply({ payload, + cfg: config, target: peerId, accountId: account.accountId, sendReply: params.sendReply, diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts index 46f499325a5..d5b926abd96 100644 --- a/extensions/irc/src/send.test.ts +++ b/extensions/irc/src/send.test.ts @@ -108,30 +108,19 @@ describe("sendMessageIrc cfg threading", () => { expect(result.messageId.length).toBeGreaterThan(0); }); - it("falls back to runtime config when cfg is omitted", async () => { - const runtimeCfg = { - channels: { - irc: { - host: "irc.example.com", - nick: "openclaw", - }, - }, - } as unknown as CoreConfig; - hoisted.loadConfig.mockReturnValueOnce(runtimeCfg); + it("fails hard when cfg is omitted", async () => { const client = { isReady: vi.fn(() => true), sendPrivmsg: vi.fn(), } as unknown as IrcClient; - await sendMessageIrc("#ops", "ping", { client }); + await expect(sendMessageIrc("#ops", "ping", { client } as never)).rejects.toThrow( + "IRC send requires a resolved runtime config", + ); - expect(hoisted.loadConfig).toHaveBeenCalledTimes(1); - expect(client.sendPrivmsg).toHaveBeenCalledWith("#ops", "ping"); - expect(hoisted.record).toHaveBeenCalledWith({ - channel: "irc", - accountId: "default", - direction: "outbound", - }); + expect(hoisted.loadConfig).not.toHaveBeenCalled(); + expect(client.sendPrivmsg).not.toHaveBeenCalled(); + expect(hoisted.record).not.toHaveBeenCalled(); }); it("sends with provided cfg even when the runtime store is not initialized", async () => { diff --git a/extensions/irc/src/send.ts b/extensions/irc/src/send.ts index c207246e4e7..654e0a0a1b3 100644 --- a/extensions/irc/src/send.ts +++ b/extensions/irc/src/send.ts @@ -1,4 +1,4 @@ -import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; import { resolveIrcAccount } from "./accounts.js"; import type { IrcClient } from "./client.js"; @@ -10,7 +10,7 @@ import { getIrcRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; type SendIrcOptions = { - cfg?: CoreConfig; + cfg: CoreConfig; accountId?: string; replyTo?: string; target?: string; @@ -51,10 +51,9 @@ function resolveTarget(to: string, opts?: SendIrcOptions): string { export async function sendMessageIrc( to: string, text: string, - opts: SendIrcOptions = {}, + opts: SendIrcOptions, ): Promise { - const runtime = getIrcRuntime(); - const cfg = (opts.cfg ?? runtime.config.loadConfig()) as CoreConfig; + const cfg = requireRuntimeConfig(opts.cfg, "IRC send") as CoreConfig; const account = resolveIrcAccount({ cfg, accountId: opts.accountId, diff --git a/extensions/line/src/auto-reply-delivery.test.ts b/extensions/line/src/auto-reply-delivery.test.ts index 40371393a2b..5f0c1c68013 100644 --- a/extensions/line/src/auto-reply-delivery.test.ts +++ b/extensions/line/src/auto-reply-delivery.test.ts @@ -26,7 +26,9 @@ const createLocationMessage = (location: { }); describe("deliverLineAutoReply", () => { + const LINE_TEST_CFG = { channels: { line: { accounts: { acc: {} } } } }; const baseDeliveryParams = { + cfg: LINE_TEST_CFG, to: "line:user:1", replyToken: "token", replyTokenUsed: false, @@ -89,13 +91,14 @@ describe("deliverLineAutoReply", () => { expect(result.replyTokenUsed).toBe(true); expect(replyMessageLine).toHaveBeenCalledTimes(1); expect(replyMessageLine).toHaveBeenCalledWith("token", [{ type: "text", text: "hello" }], { + cfg: LINE_TEST_CFG, accountId: "acc", }); expect(pushMessagesLine).toHaveBeenCalledTimes(1); expect(pushMessagesLine).toHaveBeenCalledWith( "line:user:1", [createFlexMessage("Card", { type: "bubble" })], - { accountId: "acc" }, + { cfg: LINE_TEST_CFG, accountId: "acc" }, ); expect(createQuickReplyItems).not.toHaveBeenCalled(); }); @@ -128,7 +131,7 @@ describe("deliverLineAutoReply", () => { quickReply: { items: ["A"] }, }, ], - { accountId: "acc" }, + { cfg: LINE_TEST_CFG, accountId: "acc" }, ); expect(pushMessagesLine).not.toHaveBeenCalled(); expect(createQuickReplyItems).toHaveBeenCalledWith(["A"]); @@ -160,7 +163,7 @@ describe("deliverLineAutoReply", () => { expect(pushMessagesLine).toHaveBeenCalledWith( "line:user:1", [createFlexMessage("Card", { type: "bubble" })], - { accountId: "acc" }, + { cfg: LINE_TEST_CFG, accountId: "acc" }, ); expect(replyMessageLine).toHaveBeenCalledWith( "token", @@ -171,7 +174,7 @@ describe("deliverLineAutoReply", () => { quickReply: { items: ["A"] }, }, ], - { accountId: "acc" }, + { cfg: LINE_TEST_CFG, accountId: "acc" }, ); const pushOrder = pushMessagesLine.mock.invocationCallOrder[0]; const replyOrder = replyMessageLine.mock.invocationCallOrder[0]; @@ -203,7 +206,7 @@ describe("deliverLineAutoReply", () => { expect(pushMessagesLine).toHaveBeenCalledWith( "line:user:1", [createFlexMessage("Card", { type: "bubble" })], - { accountId: "acc" }, + { cfg: LINE_TEST_CFG, accountId: "acc" }, ); }); }); diff --git a/extensions/line/src/auto-reply-delivery.ts b/extensions/line/src/auto-reply-delivery.ts index cc3aa664b0f..d6f828f8957 100644 --- a/extensions/line/src/auto-reply-delivery.ts +++ b/extensions/line/src/auto-reply-delivery.ts @@ -1,4 +1,5 @@ import type { messagingApi } from "@line/bot-sdk"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { FlexContainer } from "./flex-templates.js"; @@ -17,7 +18,7 @@ export type LineAutoReplyDeps = { pushMessagesLine: ( to: string, messages: messagingApi.Message[], - opts?: { accountId?: string }, + opts: { cfg: OpenClawConfig; accountId?: string }, ) => Promise; createFlexMessage: (altText: string, contents: FlexContainer) => messagingApi.FlexMessage; createImageMessage: ( @@ -46,6 +47,7 @@ export async function deliverLineAutoReply(params: { replyToken?: string | null; replyTokenUsed: boolean; accountId?: string; + cfg: OpenClawConfig; textLimit: number; deps: LineAutoReplyDeps; }): Promise<{ replyTokenUsed: boolean }> { @@ -58,6 +60,7 @@ export async function deliverLineAutoReply(params: { } for (let i = 0; i < messages.length; i += 5) { await deps.pushMessagesLine(to, messages.slice(i, i + 5), { + cfg: params.cfg, accountId, }); } @@ -76,6 +79,7 @@ export async function deliverLineAutoReply(params: { const replyBatch = remaining.slice(0, 5); try { await deps.replyMessageLine(replyToken, replyBatch, { + cfg: params.cfg, accountId, }); } catch (err) { @@ -145,6 +149,7 @@ export async function deliverLineAutoReply(params: { quickReplies: lineData.quickReplies, replyToken, replyTokenUsed, + cfg: params.cfg, accountId, replyMessageLine: deps.replyMessageLine, pushMessageLine: deps.pushMessageLine, diff --git a/extensions/line/src/bot-handlers.ts b/extensions/line/src/bot-handlers.ts index df58515b6ff..a5ce88ade06 100644 --- a/extensions/line/src/bot-handlers.ts +++ b/extensions/line/src/bot-handlers.ts @@ -209,6 +209,7 @@ async function sendLinePairingReply(params: { if (replyToken) { try { await replyMessageLine(replyToken, [{ type: "text", text }], { + cfg: context.cfg, accountId: context.account.accountId, channelAccessToken: context.account.channelAccessToken, }); @@ -219,6 +220,7 @@ async function sendLinePairingReply(params: { } try { await pushMessageLine(`line:${senderId}`, text, { + cfg: context.cfg, accountId: context.account.accountId, channelAccessToken: context.account.channelAccessToken, }); diff --git a/extensions/line/src/channel.sendPayload.test.ts b/extensions/line/src/channel.sendPayload.test.ts index c6b7d972235..7ad6812ad05 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", () => { "line:user:1", "OpenClaw: your access has been approved.", { + cfg, accountId: "primary", channelAccessToken: "token-primary", }, diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 690678e5904..304dc8ba657 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -141,6 +141,7 @@ export const linePlugin: ChannelPlugin = createChatChannelP getLineRuntime().channel.line?.pushMessageLine ?? (await loadLineChannelRuntime()).pushMessageLine; await pushMessageLine(id, message, { + cfg, accountId: account.accountId, channelAccessToken: account.channelAccessToken, }); diff --git a/extensions/line/src/monitor.ts b/extensions/line/src/monitor.ts index dc4e98fdcbf..f0ca80f4ed4 100644 --- a/extensions/line/src/monitor.ts +++ b/extensions/line/src/monitor.ts @@ -102,6 +102,7 @@ export function clearLineRuntimeStateForTests() { } function startLineLoadingKeepalive(params: { + cfg: OpenClawConfig; userId: string; accountId?: string; intervalMs?: number; @@ -116,6 +117,7 @@ function startLineLoadingKeepalive(params: { return; } void showLoadingAnimation(params.userId, { + cfg: params.cfg, accountId: params.accountId, loadingSeconds, }).catch(() => {}); @@ -189,11 +191,15 @@ export async function monitorLineProvider( const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup); const displayNamePromise = ctx.userId - ? getUserDisplayName(ctx.userId, { accountId: ctx.accountId }) + ? getUserDisplayName(ctx.userId, { cfg: config, accountId: ctx.accountId }) : Promise.resolve(ctxPayload.From); const stopLoading = shouldShowLoading - ? startLineLoadingKeepalive({ userId: ctx.userId!, accountId: ctx.accountId }) + ? startLineLoadingKeepalive({ + cfg: config, + userId: ctx.userId!, + accountId: ctx.accountId, + }) : null; const displayName = await displayNamePromise; @@ -218,7 +224,10 @@ export async function monitorLineProvider( const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {}; if (ctx.userId && !ctx.isGroup) { - void showLoadingAnimation(ctx.userId, { accountId: ctx.accountId }).catch(() => {}); + void showLoadingAnimation(ctx.userId, { + cfg: config, + accountId: ctx.accountId, + }).catch(() => {}); } const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({ @@ -228,6 +237,7 @@ export async function monitorLineProvider( replyToken, replyTokenUsed, accountId: ctx.accountId, + cfg: config, textLimit, deps: { buildTemplateMessageFromPayload, @@ -280,7 +290,7 @@ export async function monitorLineProvider( await replyMessageLine( replyToken, [{ type: "text", text: "Sorry, I encountered an error processing your message." }], - { accountId: ctx.accountId }, + { cfg: config, accountId: ctx.accountId }, ); } catch (replyErr) { runtime.error?.(danger(`line: error reply failed: ${String(replyErr)}`)); diff --git a/extensions/line/src/reply-chunks.test.ts b/extensions/line/src/reply-chunks.test.ts index 04efbbfdf9c..958ba56c213 100644 --- a/extensions/line/src/reply-chunks.test.ts +++ b/extensions/line/src/reply-chunks.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { sendLineReplyChunks } from "./reply-chunks.js"; +const LINE_TEST_CFG = { channels: { line: { channelAccessToken: "line-token" } } }; + function createReplyChunksHarness() { const replyMessageLine = vi.fn(async () => ({})); const pushMessageLine = vi.fn(async () => ({})); @@ -33,6 +35,7 @@ describe("sendLineReplyChunks", () => { quickReplies: ["A", "B"], replyToken: "token", replyTokenUsed: false, + cfg: LINE_TEST_CFG, accountId: "default", replyMessageLine, pushMessageLine, @@ -50,7 +53,7 @@ describe("sendLineReplyChunks", () => { { type: "text", text: "two" }, { type: "text", text: "three" }, ], - { accountId: "default" }, + { cfg: LINE_TEST_CFG, accountId: "default" }, ); expect(pushMessageLine).not.toHaveBeenCalled(); expect(pushTextMessageWithQuickReplies).not.toHaveBeenCalled(); @@ -71,6 +74,7 @@ describe("sendLineReplyChunks", () => { quickReplies: ["A"], replyToken: "token", replyTokenUsed: false, + cfg: LINE_TEST_CFG, replyMessageLine, pushMessageLine, pushTextMessageWithQuickReplies, @@ -99,6 +103,7 @@ describe("sendLineReplyChunks", () => { quickReplies: ["A"], replyToken: "token", replyTokenUsed: false, + cfg: LINE_TEST_CFG, replyMessageLine, pushMessageLine, pushTextMessageWithQuickReplies, @@ -116,12 +121,16 @@ describe("sendLineReplyChunks", () => { { type: "text", text: "4" }, { type: "text", text: "5" }, ], - { accountId: undefined }, + { cfg: LINE_TEST_CFG, accountId: undefined }, ); expect(pushMessageLine).toHaveBeenCalledTimes(1); - expect(pushMessageLine).toHaveBeenCalledWith("line:group:1", "6", { accountId: undefined }); + expect(pushMessageLine).toHaveBeenCalledWith("line:group:1", "6", { + cfg: LINE_TEST_CFG, + accountId: undefined, + }); expect(pushTextMessageWithQuickReplies).toHaveBeenCalledTimes(1); expect(pushTextMessageWithQuickReplies).toHaveBeenCalledWith("line:group:1", "7", ["A"], { + cfg: LINE_TEST_CFG, accountId: undefined, }); expect(createTextMessageWithQuickReplies).not.toHaveBeenCalled(); @@ -143,6 +152,7 @@ describe("sendLineReplyChunks", () => { quickReplies: ["A"], replyToken: "token", replyTokenUsed: false, + cfg: LINE_TEST_CFG, accountId: "default", replyMessageLine, pushMessageLine, @@ -154,12 +164,15 @@ describe("sendLineReplyChunks", () => { expect(result.replyTokenUsed).toBe(true); expect(onReplyError).toHaveBeenCalledWith(expect.any(Error)); expect(pushMessageLine).toHaveBeenNthCalledWith(1, "line:group:1", "1", { + cfg: LINE_TEST_CFG, accountId: "default", }); expect(pushMessageLine).toHaveBeenNthCalledWith(2, "line:group:1", "2", { + cfg: LINE_TEST_CFG, accountId: "default", }); expect(pushTextMessageWithQuickReplies).toHaveBeenCalledWith("line:group:1", "3", ["A"], { + cfg: LINE_TEST_CFG, accountId: "default", }); }); diff --git a/extensions/line/src/reply-chunks.ts b/extensions/line/src/reply-chunks.ts index 466a1be02d3..6534b96d3c1 100644 --- a/extensions/line/src/reply-chunks.ts +++ b/extensions/line/src/reply-chunks.ts @@ -1,4 +1,5 @@ import type { messagingApi } from "@line/bot-sdk"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; export type LineReplyMessage = messagingApi.TextMessage; @@ -8,18 +9,23 @@ export type SendLineReplyChunksParams = { quickReplies?: string[]; replyToken?: string | null; replyTokenUsed?: boolean; + cfg: OpenClawConfig; accountId?: string; replyMessageLine: ( replyToken: string, messages: messagingApi.Message[], - opts?: { accountId?: string }, + opts: { cfg: OpenClawConfig; accountId?: string }, + ) => Promise; + pushMessageLine: ( + to: string, + text: string, + opts: { cfg: OpenClawConfig; accountId?: string }, ) => Promise; - pushMessageLine: (to: string, text: string, opts?: { accountId?: string }) => Promise; pushTextMessageWithQuickReplies: ( to: string, text: string, quickReplies: string[], - opts?: { accountId?: string }, + opts: { cfg: OpenClawConfig; accountId?: string }, ) => Promise; createTextMessageWithQuickReplies: (text: string, quickReplies: string[]) => LineReplyMessage; onReplyError?: (err: unknown) => void; @@ -54,6 +60,7 @@ export async function sendLineReplyChunks( } await params.replyMessageLine(params.replyToken, replyMessages, { + cfg: params.cfg, accountId: params.accountId, }); replyTokenUsed = true; @@ -65,10 +72,11 @@ export async function sendLineReplyChunks( params.to, remaining[i], params.quickReplies!, - { accountId: params.accountId }, + { cfg: params.cfg, accountId: params.accountId }, ); } else { await params.pushMessageLine(params.to, remaining[i], { + cfg: params.cfg, accountId: params.accountId, }); } @@ -88,10 +96,11 @@ export async function sendLineReplyChunks( params.to, params.chunks[i], params.quickReplies!, - { accountId: params.accountId }, + { cfg: params.cfg, accountId: params.accountId }, ); } else { await params.pushMessageLine(params.to, params.chunks[i], { + cfg: params.cfg, accountId: params.accountId, }); } diff --git a/extensions/line/src/rich-menu.ts b/extensions/line/src/rich-menu.ts index f344b78c354..0103068b584 100644 --- a/extensions/line/src/rich-menu.ts +++ b/extensions/line/src/rich-menu.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import { messagingApi } from "@line/bot-sdk"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { resolveLineAccount } from "./accounts.js"; @@ -37,14 +37,15 @@ export interface CreateRichMenuParams { } interface RichMenuOpts { + cfg: OpenClawConfig; channelAccessToken?: string; accountId?: string; verbose?: boolean; } -function getClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiClient { +function getClient(opts: RichMenuOpts): messagingApi.MessagingApiClient { const account = resolveLineAccount({ - cfg: loadConfig(), + cfg: opts.cfg, accountId: opts.accountId, }); const token = resolveLineChannelAccessToken(opts.channelAccessToken, account); @@ -54,9 +55,9 @@ function getClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiClient { }); } -function getBlobClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiBlobClient { +function getBlobClient(opts: RichMenuOpts): messagingApi.MessagingApiBlobClient { const account = resolveLineAccount({ - cfg: loadConfig(), + cfg: opts.cfg, accountId: opts.accountId, }); const token = resolveLineChannelAccessToken(opts.channelAccessToken, account); @@ -76,7 +77,7 @@ function chunkUserIds(userIds: string[]): string[][] { export async function createRichMenu( menu: CreateRichMenuParams, - opts: RichMenuOpts = {}, + opts: RichMenuOpts, ): Promise { const client = getClient(opts); @@ -100,7 +101,7 @@ export async function createRichMenu( export async function uploadRichMenuImage( richMenuId: string, imagePath: string, - opts: RichMenuOpts = {}, + opts: RichMenuOpts, ): Promise { const blobClient = getBlobClient(opts); @@ -116,10 +117,7 @@ export async function uploadRichMenuImage( } } -export async function setDefaultRichMenu( - richMenuId: string, - opts: RichMenuOpts = {}, -): Promise { +export async function setDefaultRichMenu(richMenuId: string, opts: RichMenuOpts): Promise { const client = getClient(opts); await client.setDefaultRichMenu(richMenuId); @@ -128,7 +126,7 @@ export async function setDefaultRichMenu( } } -export async function cancelDefaultRichMenu(opts: RichMenuOpts = {}): Promise { +export async function cancelDefaultRichMenu(opts: RichMenuOpts): Promise { const client = getClient(opts); await client.cancelDefaultRichMenu(); @@ -137,7 +135,7 @@ export async function cancelDefaultRichMenu(opts: RichMenuOpts = {}): Promise { +export async function getDefaultRichMenuId(opts: RichMenuOpts): Promise { const client = getClient(opts); try { @@ -151,7 +149,7 @@ export async function getDefaultRichMenuId(opts: RichMenuOpts = {}): Promise { const client = getClient(opts); await client.linkRichMenuIdToUser(userId, richMenuId); @@ -164,7 +162,7 @@ export async function linkRichMenuToUser( export async function linkRichMenuToUsers( userIds: string[], richMenuId: string, - opts: RichMenuOpts = {}, + opts: RichMenuOpts, ): Promise { const client = getClient(opts); @@ -180,10 +178,7 @@ export async function linkRichMenuToUsers( } } -export async function unlinkRichMenuFromUser( - userId: string, - opts: RichMenuOpts = {}, -): Promise { +export async function unlinkRichMenuFromUser(userId: string, opts: RichMenuOpts): Promise { const client = getClient(opts); await client.unlinkRichMenuIdFromUser(userId); @@ -194,7 +189,7 @@ export async function unlinkRichMenuFromUser( export async function unlinkRichMenuFromUsers( userIds: string[], - opts: RichMenuOpts = {}, + opts: RichMenuOpts, ): Promise { const client = getClient(opts); @@ -211,7 +206,7 @@ export async function unlinkRichMenuFromUsers( export async function getRichMenuIdOfUser( userId: string, - opts: RichMenuOpts = {}, + opts: RichMenuOpts, ): Promise { const client = getClient(opts); @@ -223,7 +218,7 @@ export async function getRichMenuIdOfUser( } } -export async function getRichMenuList(opts: RichMenuOpts = {}): Promise { +export async function getRichMenuList(opts: RichMenuOpts): Promise { const client = getClient(opts); const response = await client.getRichMenuList(); return response.richmenus ?? []; @@ -231,7 +226,7 @@ export async function getRichMenuList(opts: RichMenuOpts = {}): Promise { const client = getClient(opts); @@ -242,7 +237,7 @@ export async function getRichMenu( } } -export async function deleteRichMenu(richMenuId: string, opts: RichMenuOpts = {}): Promise { +export async function deleteRichMenu(richMenuId: string, opts: RichMenuOpts): Promise { const client = getClient(opts); await client.deleteRichMenu(richMenuId); @@ -254,7 +249,7 @@ export async function deleteRichMenu(richMenuId: string, opts: RichMenuOpts = {} export async function createRichMenuAlias( richMenuId: string, aliasId: string, - opts: RichMenuOpts = {}, + opts: RichMenuOpts, ): Promise { const client = getClient(opts); @@ -268,7 +263,7 @@ export async function createRichMenuAlias( } } -export async function deleteRichMenuAlias(aliasId: string, opts: RichMenuOpts = {}): Promise { +export async function deleteRichMenuAlias(aliasId: string, opts: RichMenuOpts): Promise { const client = getClient(opts); await client.deleteRichMenuAlias(aliasId); diff --git a/extensions/line/src/send.test.ts b/extensions/line/src/send.test.ts index e614856d5fc..3c8db94ca5d 100644 --- a/extensions/line/src/send.test.ts +++ b/extensions/line/src/send.test.ts @@ -6,7 +6,7 @@ const { showLoadingAnimationMock, getProfileMock, MessagingApiClientMock, - loadConfigMock, + requireRuntimeConfigMock, resolveLineAccountMock, resolveLineChannelAccessTokenMock, recordChannelActivityMock, @@ -25,7 +25,7 @@ const { getProfile: getProfileMock, }; }); - const loadConfigMock = vi.fn(() => ({})); + const requireRuntimeConfigMock = vi.fn((cfg: unknown) => cfg ?? {}); const resolveLineAccountMock = vi.fn(() => ({ accountId: "default" })); const resolveLineChannelAccessTokenMock = vi.fn(() => "line-token"); const recordChannelActivityMock = vi.fn(); @@ -37,7 +37,7 @@ const { showLoadingAnimationMock, getProfileMock, MessagingApiClientMock, - loadConfigMock, + requireRuntimeConfigMock, resolveLineAccountMock, resolveLineChannelAccessTokenMock, recordChannelActivityMock, @@ -51,7 +51,7 @@ vi.mock("@line/bot-sdk", () => ({ })); vi.mock("openclaw/plugin-sdk/config-runtime", () => ({ - loadConfig: loadConfigMock, + requireRuntimeConfig: requireRuntimeConfigMock, })); vi.mock("./accounts.js", () => ({ @@ -82,6 +82,16 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ let sendModule: typeof import("./send.js"); +const LINE_TEST_CFG = { + channels: { + line: { + accounts: { + default: {}, + }, + }, + }, +}; + describe("LINE send helpers", () => { beforeEach(async () => { vi.resetModules(); @@ -90,7 +100,7 @@ describe("LINE send helpers", () => { showLoadingAnimationMock.mockReset(); getProfileMock.mockReset(); MessagingApiClientMock.mockReset(); - loadConfigMock.mockReset(); + requireRuntimeConfigMock.mockClear(); resolveLineAccountMock.mockReset(); resolveLineChannelAccessTokenMock.mockReset(); recordChannelActivityMock.mockReset(); @@ -105,7 +115,7 @@ describe("LINE send helpers", () => { getProfile: getProfileMock, }; }); - loadConfigMock.mockReturnValue({}); + requireRuntimeConfigMock.mockImplementation((cfg: unknown) => cfg ?? LINE_TEST_CFG); resolveLineAccountMock.mockReturnValue({ accountId: "default" }); resolveLineChannelAccessTokenMock.mockReturnValue("line-token"); resolvePinnedHostnameWithPolicyMock.mockResolvedValue({ @@ -134,7 +144,7 @@ describe("LINE send helpers", () => { "line:user:U123", "https://example.com/original.jpg", undefined, - { verbose: true }, + { cfg: LINE_TEST_CFG, verbose: true }, ); expect(pushMessageMock).toHaveBeenCalledWith({ @@ -158,6 +168,7 @@ describe("LINE send helpers", () => { it("replies when reply token is provided", async () => { const result = await sendModule.sendMessageLine("line:group:C1", "Hello", { + cfg: LINE_TEST_CFG, replyToken: "reply-token", mediaUrl: "https://example.com/media.jpg", verbose: true, @@ -185,6 +196,7 @@ describe("LINE send helpers", () => { it("sends video with explicit image preview URL", async () => { await sendModule.sendMessageLine("line:user:U100", "Video", { + cfg: LINE_TEST_CFG, mediaUrl: "https://example.com/video.mp4", mediaKind: "video", previewImageUrl: "https://example.com/preview.jpg", @@ -211,6 +223,7 @@ describe("LINE send helpers", () => { it("throws when video preview URL is missing", async () => { await expect( sendModule.sendMessageLine("line:user:U200", "Video", { + cfg: LINE_TEST_CFG, mediaUrl: "https://example.com/video.mp4", mediaKind: "video", }), @@ -224,6 +237,7 @@ describe("LINE send helpers", () => { await expect( sendModule.sendMessageLine("line:user:U200", "Image", { + cfg: LINE_TEST_CFG, mediaUrl: "https://127.0.0.1/image.jpg", }), ).rejects.toThrow(/private network/i); @@ -233,6 +247,7 @@ describe("LINE send helpers", () => { it("omits trackingId for non-user destinations", async () => { await sendModule.sendMessageLine("line:group:C100", "Video", { + cfg: LINE_TEST_CFG, mediaUrl: "https://example.com/video.mp4", mediaKind: "video", previewImageUrl: "https://example.com/preview.jpg", @@ -256,7 +271,7 @@ describe("LINE send helpers", () => { }); it("throws when push messages are empty", async () => { - await expect(sendModule.pushMessagesLine("U123", [])).rejects.toThrow( + await expect(sendModule.pushMessagesLine("U123", [], { cfg: LINE_TEST_CFG })).rejects.toThrow( "Message must be non-empty for LINE sends", ); }); @@ -273,7 +288,9 @@ describe("LINE send helpers", () => { pushMessageMock.mockRejectedValueOnce(err); await expect( - sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }]), + sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }], { + cfg: LINE_TEST_CFG, + }), ).rejects.toThrow("LINE push failed"); expect(logVerboseMock).toHaveBeenCalledWith( @@ -287,8 +304,8 @@ describe("LINE send helpers", () => { pictureUrl: "https://example.com/peter.jpg", }); - const first = await sendModule.getUserProfile("U-cache"); - const second = await sendModule.getUserProfile("U-cache"); + const first = await sendModule.getUserProfile("U-cache", { cfg: LINE_TEST_CFG }); + const second = await sendModule.getUserProfile("U-cache", { cfg: LINE_TEST_CFG }); expect(first).toEqual({ displayName: "Peter", @@ -301,7 +318,9 @@ describe("LINE send helpers", () => { it("continues when loading animation is unsupported", async () => { showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported")); - await expect(sendModule.showLoadingAnimation("line:room:R1")).resolves.toBeUndefined(); + await expect( + sendModule.showLoadingAnimation("line:room:R1", { cfg: LINE_TEST_CFG }), + ).resolves.toBeUndefined(); expect(logVerboseMock).toHaveBeenCalledWith( expect.stringContaining("line: loading animation failed (non-fatal)"), @@ -313,6 +332,7 @@ describe("LINE send helpers", () => { "U-quick", "Pick one", Array.from({ length: 20 }, (_, index) => `Choice ${index + 1}`), + { cfg: LINE_TEST_CFG }, ); expect(pushMessageMock).toHaveBeenCalledTimes(1); diff --git a/extensions/line/src/send.ts b/extensions/line/src/send.ts index 338ed7cd2e3..f9be3d6bee3 100644 --- a/extensions/line/src/send.ts +++ b/extensions/line/src/send.ts @@ -1,5 +1,5 @@ import { messagingApi } from "@line/bot-sdk"; -import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveLineAccount } from "./accounts.js"; @@ -26,7 +26,7 @@ const userProfileCache = new Map< const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; interface LineSendOpts { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; channelAccessToken?: string; accountId?: string; verbose?: boolean; @@ -77,7 +77,7 @@ function createLineMessagingClient(opts: LineClientOpts): { account: ReturnType; client: messagingApi.MessagingApiClient; } { - const cfg = opts.cfg ?? loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "LINE send"); const account = resolveLineAccount({ cfg, accountId: opts.accountId, @@ -179,7 +179,7 @@ function recordLineOutboundActivity(accountId: string): void { async function pushLineMessages( to: string, messages: Message[], - opts: LinePushOpts = {}, + opts: LinePushOpts, behavior: LinePushBehavior = {}, ): Promise { if (messages.length === 0) { @@ -219,7 +219,7 @@ async function pushLineMessages( async function replyLineMessages( replyToken: string, messages: Message[], - opts: LinePushOpts = {}, + opts: LinePushOpts, behavior: LineReplyBehavior = {}, ): Promise { const { account, client } = createLineMessagingClient(opts); @@ -242,7 +242,7 @@ async function replyLineMessages( export async function sendMessageLine( to: string, text: string, - opts: LineSendOpts = {}, + opts: LineSendOpts, ): Promise { const chatId = normalizeTarget(to); const messages: Message[] = []; @@ -303,7 +303,7 @@ export async function sendMessageLine( export async function pushMessageLine( to: string, text: string, - opts: LineSendOpts = {}, + opts: LineSendOpts, ): Promise { return sendMessageLine(to, text, { ...opts, replyToken: undefined }); } @@ -311,7 +311,7 @@ export async function pushMessageLine( export async function replyMessageLine( replyToken: string, messages: Message[], - opts: LinePushOpts = {}, + opts: LinePushOpts, ): Promise { await replyLineMessages(replyToken, messages, opts); } @@ -319,7 +319,7 @@ export async function replyMessageLine( export async function pushMessagesLine( to: string, messages: Message[], - opts: LinePushOpts = {}, + opts: LinePushOpts, ): Promise { return pushLineMessages(to, messages, opts, { errorContext: "push message", @@ -340,8 +340,8 @@ export function createFlexMessage( export async function pushImageMessage( to: string, originalContentUrl: string, - previewImageUrl?: string, - opts: LinePushOpts = {}, + previewImageUrl: string | undefined, + opts: LinePushOpts, ): Promise { await validateLineMediaUrl(originalContentUrl); if (previewImageUrl) { @@ -360,7 +360,7 @@ export async function pushLocationMessage( latitude: number; longitude: number; }, - opts: LinePushOpts = {}, + opts: LinePushOpts, ): Promise { return pushLineMessages(to, [createLocationMessage(location)], opts, { verboseMessage: (chatId) => `line: pushed location to ${chatId}`, @@ -371,7 +371,7 @@ export async function pushFlexMessage( to: string, altText: string, contents: FlexContainer, - opts: LinePushOpts = {}, + opts: LinePushOpts, ): Promise { const flexMessage: FlexMessage = { type: "flex", @@ -388,7 +388,7 @@ export async function pushFlexMessage( export async function pushTemplateMessage( to: string, template: TemplateMessage, - opts: LinePushOpts = {}, + opts: LinePushOpts, ): Promise { return pushLineMessages(to, [template], opts, { verboseMessage: (chatId) => `line: pushed template message to ${chatId}`, @@ -399,7 +399,7 @@ export async function pushTextMessageWithQuickReplies( to: string, text: string, quickReplyLabels: string[], - opts: LinePushOpts = {}, + opts: LinePushOpts, ): Promise { const message = createTextMessageWithQuickReplies(text, quickReplyLabels); @@ -433,7 +433,7 @@ export function createTextMessageWithQuickReplies( export async function showLoadingAnimation( chatId: string, - opts: { channelAccessToken?: string; accountId?: string; loadingSeconds?: number } = {}, + opts: LineClientOpts & { loadingSeconds?: number }, ): Promise { const { client } = createLineMessagingClient(opts); @@ -450,7 +450,7 @@ export async function showLoadingAnimation( export async function getUserProfile( userId: string, - opts: { channelAccessToken?: string; accountId?: string; useCache?: boolean } = {}, + opts: LineClientOpts & { useCache?: boolean }, ): Promise<{ displayName: string; pictureUrl?: string } | null> { const useCache = opts.useCache ?? true; @@ -482,10 +482,7 @@ export async function getUserProfile( } } -export async function getUserDisplayName( - userId: string, - opts: { channelAccessToken?: string; accountId?: string } = {}, -): Promise { +export async function getUserDisplayName(userId: string, opts: LineClientOpts): Promise { const profile = await getUserProfile(userId, opts); return profile?.displayName ?? userId; } diff --git a/extensions/matrix/src/channel-account-paths.ts b/extensions/matrix/src/channel-account-paths.ts index fb0af175dd1..18c53f57bbf 100644 --- a/extensions/matrix/src/channel-account-paths.ts +++ b/extensions/matrix/src/channel-account-paths.ts @@ -30,7 +30,7 @@ type ProbeMatrix = (params: { type SendMessageMatrix = ( to: string, message: string, - options?: { accountId?: string }, + options: { cfg: CoreConfig; accountId?: string }, ) => Promise; export function createMatrixProbeAccount(params: { @@ -80,13 +80,18 @@ export function createMatrixPairingText(sendMessageMatrix: SendMessageMatrix) { notify: async ({ id, message, + cfg, accountId, }: { id: string; message: string; + cfg: CoreConfig; accountId?: string; }) => { - await sendMessageMatrix(`user:${id}`, message, accountId ? { accountId } : {}); + await sendMessageMatrix(`user:${id}`, message, { + cfg, + ...(accountId ? { accountId } : {}), + }); }, }; } diff --git a/extensions/matrix/src/channel.account-paths.test.ts b/extensions/matrix/src/channel.account-paths.test.ts index ebca5661bef..d18410170e7 100644 --- a/extensions/matrix/src/channel.account-paths.test.ts +++ b/extensions/matrix/src/channel.account-paths.test.ts @@ -60,6 +60,7 @@ describe("matrix account path propagation", () => { ); await pairingText.notify({ + cfg: {} as never, id: "@user:example.org", message: pairingText.message, accountId: "poe", @@ -68,7 +69,7 @@ describe("matrix account path propagation", () => { expect(sendMessageMatrixMock).toHaveBeenCalledWith( "user:@user:example.org", expect.any(String), - { accountId: "poe" }, + { cfg: {}, accountId: "poe" }, ); }); diff --git a/extensions/matrix/src/matrix/actions/client.test.ts b/extensions/matrix/src/matrix/actions/client.test.ts index 5cb2f737fa0..c7d0e7ecd90 100644 --- a/extensions/matrix/src/matrix/actions/client.test.ts +++ b/extensions/matrix/src/matrix/actions/client.test.ts @@ -19,6 +19,8 @@ const { resolveMatrixAuthContextMock, } = matrixClientResolverMocks; +const TEST_CFG = {}; + vi.mock("../../runtime.js", () => ({ getMatrixRuntime: () => getMatrixRuntimeMock(), })); @@ -65,14 +67,20 @@ describe("action client helpers", () => { it("stops one-off shared clients when no active monitor client is registered", async () => { vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799"); - const result = await withResolvedActionClient({ accountId: "default" }, async () => "ok"); + const result = await withResolvedActionClient( + { cfg: TEST_CFG, accountId: "default" }, + async () => "ok", + ); await expectOneOffSharedMatrixClient(); expect(result).toBe("ok"); }); it("skips one-off room preparation when readiness is disabled", async () => { - await withResolvedActionClient({ accountId: "default", readiness: "none" }, async () => {}); + await withResolvedActionClient( + { cfg: TEST_CFG, accountId: "default", readiness: "none" }, + async () => {}, + ); const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value; expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled(); @@ -81,7 +89,7 @@ describe("action client helpers", () => { }); it("starts one-off clients when started readiness is required", async () => { - await withStartedActionClient({ accountId: "default" }, async () => {}); + await withStartedActionClient({ cfg: TEST_CFG, accountId: "default" }, async () => {}); const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value; expect(sharedClient.start).toHaveBeenCalledTimes(1); @@ -93,10 +101,13 @@ describe("action client helpers", () => { const activeClient = createMockMatrixClient(); getActiveMatrixClientMock.mockReturnValue(activeClient); - const result = await withResolvedActionClient({ accountId: "default" }, async (client) => { - expect(client).toBe(activeClient); - return "ok"; - }); + const result = await withResolvedActionClient( + { cfg: TEST_CFG, accountId: "default" }, + async (client) => { + expect(client).toBe(activeClient); + return "ok"; + }, + ); expect(result).toBe("ok"); expect(acquireSharedMatrixClientMock).not.toHaveBeenCalled(); @@ -107,7 +118,7 @@ describe("action client helpers", () => { const activeClient = createMockMatrixClient(); getActiveMatrixClientMock.mockReturnValue(activeClient); - await withStartedActionClient({ accountId: "default" }, async (client) => { + await withStartedActionClient({ cfg: TEST_CFG, accountId: "default" }, async (client) => { expect(client).toBe(activeClient); }); @@ -143,7 +154,7 @@ describe("action client helpers", () => { encryption: true, }, }); - await withResolvedActionClient({}, async () => {}); + await withResolvedActionClient({ cfg: loadConfigMock() as never }, async () => {}); await expectOneOffSharedMatrixClient({ cfg: loadConfigMock(), @@ -172,10 +183,13 @@ describe("action client helpers", () => { const sharedClient = createMockMatrixClient(); acquireSharedMatrixClientMock.mockResolvedValue(sharedClient); - const result = await withResolvedActionClient({ accountId: "default" }, async (client) => { - expect(client).toBe(sharedClient); - return "ok"; - }); + const result = await withResolvedActionClient( + { cfg: TEST_CFG, accountId: "default" }, + async (client) => { + expect(client).toBe(sharedClient); + return "ok"; + }, + ); expect(result).toBe("ok"); expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop"); @@ -186,7 +200,7 @@ describe("action client helpers", () => { acquireSharedMatrixClientMock.mockResolvedValue(sharedClient); await expect( - withResolvedActionClient({ accountId: "default" }, async () => { + withResolvedActionClient({ cfg: TEST_CFG, accountId: "default" }, async () => { throw new Error("boom"); }), ).rejects.toThrow("boom"); @@ -201,7 +215,7 @@ describe("action client helpers", () => { const result = await withResolvedRoomAction( "room:#ops:example.org", - { accountId: "default" }, + { cfg: TEST_CFG, accountId: "default" }, async (client, resolvedRoom) => { expect(client).toBe(sharedClient); return resolvedRoom; diff --git a/extensions/matrix/src/matrix/actions/messages.test.ts b/extensions/matrix/src/matrix/actions/messages.test.ts index 53934ff4807..b6519da6962 100644 --- a/extensions/matrix/src/matrix/actions/messages.test.ts +++ b/extensions/matrix/src/matrix/actions/messages.test.ts @@ -4,6 +4,12 @@ import type { MatrixClient } from "../sdk.js"; import * as sendModule from "../send.js"; import { editMatrixMessage, readMatrixMessages } from "./messages.js"; +const MATRIX_ACTION_TEST_CFG = { + channels: { + matrix: {}, + }, +}; + function installMatrixActionTestRuntime(): void { setMatrixRuntime({ config: { @@ -110,13 +116,15 @@ describe("matrix message actions", () => { const editSpy = vi.spyOn(sendModule, "editMessageMatrix").mockResolvedValue("evt-edit"); try { + const cfg = {} as never; const result = await editMatrixMessage("!room:example.org", "$original", "hello", { + cfg, timeoutMs: 12_345, }); expect(result).toEqual({ eventId: "evt-edit" }); expect(editSpy).toHaveBeenCalledWith("!room:example.org", "$original", "hello", { - cfg: undefined, + cfg, accountId: undefined, client: undefined, timeoutMs: 12_345, @@ -137,7 +145,7 @@ describe("matrix message actions", () => { "!room:example.org", "$original", "hello @alice:example.org and @bob:example.org", - { client }, + { cfg: MATRIX_ACTION_TEST_CFG, client }, ); expect(result).toEqual({ eventId: "evt-edit" }); @@ -162,7 +170,7 @@ describe("matrix message actions", () => { "!room:example.org", "$original", "hello again @alice:example.org", - { client }, + { cfg: MATRIX_ACTION_TEST_CFG, client }, ); expect(result).toEqual({ eventId: "evt-edit" }); diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts index dd51ddf5668..61333b6945d 100644 --- a/extensions/matrix/src/matrix/actions/messages.ts +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -22,6 +22,9 @@ export async function sendMatrixMessage( audioAsVoice?: boolean; } = {}, ) { + if (!opts.cfg) { + throw new Error("Matrix message actions require a resolved runtime config."); + } return await sendMessageMatrix(to, content, { cfg: opts.cfg, mediaUrl: opts.mediaUrl, @@ -41,6 +44,9 @@ export async function editMatrixMessage( content: string, opts: MatrixActionClientOpts = {}, ) { + if (!opts.cfg) { + throw new Error("Matrix message actions require a resolved runtime config."); + } const trimmed = content.trim(); if (!trimmed) { throw new Error("Matrix edit requires content"); diff --git a/extensions/matrix/src/matrix/actions/verification.test.ts b/extensions/matrix/src/matrix/actions/verification.test.ts index 2c6242daa21..9b62747c5a1 100644 --- a/extensions/matrix/src/matrix/actions/verification.test.ts +++ b/extensions/matrix/src/matrix/actions/verification.test.ts @@ -16,6 +16,16 @@ vi.mock("../../runtime.js", () => ({ }), })); +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); + return { + ...actual, + requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfigMock()), + }; +}); + vi.mock("./client.js", () => ({ withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args), withStartedActionClient: (...args: unknown[]) => withStartedActionClientMock(...args), @@ -61,7 +71,9 @@ describe("matrix verification actions", () => { return await run({ crypto: null }); }); - await expect(listMatrixVerifications({ accountId: "ops" })).rejects.toThrow( + await expect( + listMatrixVerifications({ cfg: loadConfigMock(), accountId: "ops" }), + ).rejects.toThrow( "Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)", ); }); @@ -83,7 +95,7 @@ describe("matrix verification actions", () => { return await run({ crypto: null }); }); - await expect(listMatrixVerifications()).rejects.toThrow( + await expect(listMatrixVerifications({ cfg: loadConfigMock() })).rejects.toThrow( "Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)", ); }); diff --git a/extensions/matrix/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts index 90bd240223a..0979dd61317 100644 --- a/extensions/matrix/src/matrix/actions/verification.ts +++ b/extensions/matrix/src/matrix/actions/verification.ts @@ -1,5 +1,5 @@ +import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js"; import { withResolvedActionClient, withStartedActionClient } from "./client.js"; @@ -10,7 +10,12 @@ function requireCrypto( opts: MatrixActionClientOpts, ): NonNullable { if (!client.crypto) { - const cfg = opts.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); + if (!opts.cfg) { + throw new Error( + "Matrix verification actions requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", + ); + } + const cfg = requireRuntimeConfig(opts.cfg, "Matrix verification actions") as CoreConfig; throw new Error(formatMatrixEncryptionUnavailableError(cfg, opts.accountId)); } return client.crypto; diff --git a/extensions/matrix/src/matrix/client-bootstrap.test.ts b/extensions/matrix/src/matrix/client-bootstrap.test.ts index b6c6d2b136b..421add0998a 100644 --- a/extensions/matrix/src/matrix/client-bootstrap.test.ts +++ b/extensions/matrix/src/matrix/client-bootstrap.test.ts @@ -14,6 +14,8 @@ const { resolveMatrixAuthContextMock, } = matrixClientResolverMocks; +const TEST_CFG = {}; + vi.mock("../runtime.js", () => ({ getMatrixRuntime: () => getMatrixRuntimeMock(), })); @@ -56,6 +58,7 @@ describe("client bootstrap", () => { await expect( resolveRuntimeMatrixClientWithReadiness({ + cfg: TEST_CFG, accountId: "default", readiness: "prepared", }), @@ -72,6 +75,7 @@ describe("client bootstrap", () => { await expect( withResolvedRuntimeMatrixClient( { + cfg: TEST_CFG, accountId: "default", readiness: "started", }, diff --git a/extensions/matrix/src/matrix/client-bootstrap.ts b/extensions/matrix/src/matrix/client-bootstrap.ts index 28e72fd7274..82c5890e7be 100644 --- a/extensions/matrix/src/matrix/client-bootstrap.ts +++ b/extensions/matrix/src/matrix/client-bootstrap.ts @@ -1,4 +1,4 @@ -import { getMatrixRuntime } from "../runtime.js"; +import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import type { CoreConfig } from "../types.js"; import { getActiveMatrixClient } from "./active-client.js"; import { isBunRuntime } from "./client/runtime.js"; @@ -71,7 +71,12 @@ async function resolveRuntimeMatrixClient(opts: { return { client: opts.client, stopOnDone: false }; } - const cfg = opts.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); + if (!opts.cfg) { + throw new Error( + "Matrix runtime client requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", + ); + } + const cfg = requireRuntimeConfig(opts.cfg, "Matrix runtime client") as CoreConfig; const { acquireSharedMatrixClient, releaseSharedClientInstance, resolveMatrixAuthContext } = await loadMatrixSharedClientRuntimeDeps(); const authContext = resolveMatrixAuthContext({ diff --git a/extensions/matrix/src/matrix/client-resolver.test-helpers.ts b/extensions/matrix/src/matrix/client-resolver.test-helpers.ts index da440a1b9d8..c8a0fdd5a0c 100644 --- a/extensions/matrix/src/matrix/client-resolver.test-helpers.ts +++ b/extensions/matrix/src/matrix/client-resolver.test-helpers.ts @@ -23,6 +23,21 @@ export const matrixClientResolverMocks: MatrixClientResolverMocks = { resolveMatrixAuthContextMock: vi.fn(), }; +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); + return { + ...actual, + requireRuntimeConfig: vi.fn((cfg: unknown) => { + if (cfg) { + return cfg; + } + return matrixClientResolverMocks.loadConfigMock(); + }), + }; +}); + export function createMockMatrixClient(): MatrixClient { return { prepareForOneOff: vi.fn(async () => undefined), diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 550e6df3486..d661042b49f 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -1,3 +1,4 @@ +import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { retryAsync } from "openclaw/plugin-sdk/retry-runtime"; import { @@ -11,7 +12,6 @@ import { } from "../../account-selection.js"; import { resolveMatrixAccountStringValues } from "../../auth-precedence.js"; import { getMatrixScopedEnvVarNames } from "../../env-vars.js"; -import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; import { findMatrixAccountConfig, @@ -556,8 +556,8 @@ function resolveImplicitMatrixAccountId( return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg, env)); } -export function resolveMatrixAuthContext(params?: { - cfg?: CoreConfig; +export function resolveMatrixAuthContext(params: { + cfg: CoreConfig; env?: NodeJS.ProcessEnv; accountId?: string | null; }): { @@ -566,7 +566,7 @@ export function resolveMatrixAuthContext(params?: { accountId: string; resolved: MatrixResolvedConfig; } { - const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); + const cfg = requireRuntimeConfig(params.cfg, "Matrix auth context") as CoreConfig; const env = params?.env ?? process.env; const explicitAccountId = normalizeOptionalAccountId(params?.accountId); const effectiveAccountId = explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env); @@ -600,7 +600,16 @@ export async function resolveMatrixAuth(params?: { env?: NodeJS.ProcessEnv; accountId?: string | null; }): Promise { - const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params); + if (!params?.cfg) { + throw new Error( + "Matrix auth requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", + ); + } + const { cfg, env, accountId, resolved } = resolveMatrixAuthContext({ + cfg: params.cfg, + env: params.env, + accountId: params.accountId, + }); const accessToken = (await resolveConfiguredMatrixAuthSecretInput({ cfg, diff --git a/extensions/matrix/src/matrix/client/shared.test.ts b/extensions/matrix/src/matrix/client/shared.test.ts index d41401227ea..970664d7ba0 100644 --- a/extensions/matrix/src/matrix/client/shared.test.ts +++ b/extensions/matrix/src/matrix/client/shared.test.ts @@ -5,6 +5,8 @@ const resolveMatrixAuthMock = vi.hoisted(() => vi.fn()); const resolveMatrixAuthContextMock = vi.hoisted(() => vi.fn()); const createMatrixClientMock = vi.hoisted(() => vi.fn()); +const TEST_CFG = {}; + vi.mock("./config.js", () => ({ resolveMatrixAuth: resolveMatrixAuthMock, resolveMatrixAuthContext: resolveMatrixAuthContextMock, @@ -106,7 +108,7 @@ describe("resolveSharedMatrixClient", () => { createMatrixClientMock.mockReset(); resolveMatrixAuthContextMock.mockImplementation( ({ accountId }: { accountId?: string | null } = {}) => ({ - cfg: undefined, + cfg: TEST_CFG, env: undefined, accountId: accountId ?? "default", resolved: {}, @@ -122,9 +124,17 @@ describe("resolveSharedMatrixClient", () => { it("keeps account clients isolated when resolves are interleaved", async () => { const { mainClient, opsClient } = primeAccountClientMocks(); - const firstMain = await resolveSharedMatrixClient({ accountId: "main", startClient: false }); - const firstPoe = await resolveSharedMatrixClient({ accountId: "ops", startClient: false }); - const secondMain = await resolveSharedMatrixClient({ accountId: "main" }); + const firstMain = await resolveSharedMatrixClient({ + cfg: TEST_CFG, + accountId: "main", + startClient: false, + }); + const firstPoe = await resolveSharedMatrixClient({ + cfg: TEST_CFG, + accountId: "ops", + startClient: false, + }); + const secondMain = await resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" }); expect(firstMain).toBe(mainClient); expect(firstPoe).toBe(opsClient); @@ -137,8 +147,8 @@ describe("resolveSharedMatrixClient", () => { it("stops only the targeted account client", async () => { const { mainAuth, mainClient, opsClient } = primeAccountClientMocks(); - await resolveSharedMatrixClient({ accountId: "main", startClient: false }); - await resolveSharedMatrixClient({ accountId: "ops", startClient: false }); + await resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main", startClient: false }); + await resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "ops", startClient: false }); stopSharedClientForAccount(mainAuth); @@ -160,9 +170,17 @@ describe("resolveSharedMatrixClient", () => { .mockResolvedValueOnce(firstMainClient) .mockResolvedValueOnce(secondMainClient); - const first = await resolveSharedMatrixClient({ accountId: "main", startClient: false }); + const first = await resolveSharedMatrixClient({ + cfg: TEST_CFG, + accountId: "main", + startClient: false, + }); stopSharedClientInstance(first as unknown as import("../sdk.js").MatrixClient); - const second = await resolveSharedMatrixClient({ accountId: "main", startClient: false }); + const second = await resolveSharedMatrixClient({ + cfg: TEST_CFG, + accountId: "main", + startClient: false, + }); expect(first).toBe(firstMainClient); expect(second).toBe(secondMainClient); @@ -175,7 +193,7 @@ describe("resolveSharedMatrixClient", () => { const poeClient = createMockClient("ops"); resolveMatrixAuthContextMock.mockReturnValue({ - cfg: undefined, + cfg: TEST_CFG, env: undefined, accountId: "ops", resolved: {}, @@ -183,13 +201,13 @@ describe("resolveSharedMatrixClient", () => { resolveMatrixAuthMock.mockResolvedValue(poeAuth); createMatrixClientMock.mockResolvedValue(poeClient); - const first = await resolveSharedMatrixClient({ startClient: false }); - const second = await resolveSharedMatrixClient({ startClient: false }); + const first = await resolveSharedMatrixClient({ cfg: TEST_CFG, startClient: false }); + const second = await resolveSharedMatrixClient({ cfg: TEST_CFG, startClient: false }); expect(first).toBe(poeClient); expect(second).toBe(poeClient); expect(resolveMatrixAuthMock).toHaveBeenCalledWith({ - cfg: undefined, + cfg: TEST_CFG, env: undefined, accountId: "ops", }); @@ -208,7 +226,11 @@ describe("resolveSharedMatrixClient", () => { resolveMatrixAuthMock.mockResolvedValue(mainAuth); createMatrixClientMock.mockResolvedValue(mainClient); - const client = await acquireSharedMatrixClient({ accountId: "main", startClient: false }); + const client = await acquireSharedMatrixClient({ + cfg: TEST_CFG, + accountId: "main", + startClient: false, + }); expect(client).toBe(mainClient); expect(mainClient.start).not.toHaveBeenCalled(); @@ -224,8 +246,16 @@ describe("resolveSharedMatrixClient", () => { resolveMatrixAuthMock.mockResolvedValue(mainAuth); createMatrixClientMock.mockResolvedValue(mainClient); - const first = await acquireSharedMatrixClient({ accountId: "main", startClient: false }); - const second = await acquireSharedMatrixClient({ accountId: "main", startClient: false }); + const first = await acquireSharedMatrixClient({ + cfg: TEST_CFG, + accountId: "main", + startClient: false, + }); + const second = await acquireSharedMatrixClient({ + cfg: TEST_CFG, + accountId: "main", + startClient: false, + }); expect(first).toBe(mainClient); expect(second).toBe(mainClient); @@ -254,13 +284,14 @@ describe("resolveSharedMatrixClient", () => { it("lets a later waiter abort while shared startup continues for the owner", async () => { const { mainClient, resolveStartup } = createPendingSharedStartup(); - const ownerPromise = resolveSharedMatrixClient({ accountId: "main" }); + const ownerPromise = resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" }); await vi.waitFor(() => { expect(mainClient.start).toHaveBeenCalledTimes(1); }); const abortController = new AbortController(); const canceledWaiter = resolveSharedMatrixClient({ + cfg: TEST_CFG, accountId: "main", abortSignal: abortController.signal, }); @@ -278,13 +309,14 @@ describe("resolveSharedMatrixClient", () => { it("keeps the shared startup lock while an aborted waiter exits early", async () => { const { mainClient, resolveStartup } = createPendingSharedStartup(); - const ownerPromise = resolveSharedMatrixClient({ accountId: "main" }); + const ownerPromise = resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" }); await vi.waitFor(() => { expect(mainClient.start).toHaveBeenCalledTimes(1); }); const abortController = new AbortController(); const abortedWaiter = resolveSharedMatrixClient({ + cfg: TEST_CFG, accountId: "main", abortSignal: abortController.signal, }); @@ -294,7 +326,7 @@ describe("resolveSharedMatrixClient", () => { name: "AbortError", }); - const followerPromise = resolveSharedMatrixClient({ accountId: "main" }); + const followerPromise = resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" }); expect(mainClient.start).toHaveBeenCalledTimes(1); resolveStartup(); @@ -324,8 +356,16 @@ describe("resolveSharedMatrixClient", () => { resolveMatrixAuthMock.mockResolvedValueOnce(firstAuth).mockResolvedValueOnce(secondAuth); createMatrixClientMock.mockResolvedValueOnce(firstClient).mockResolvedValueOnce(secondClient); - const first = await resolveSharedMatrixClient({ accountId: "main", startClient: false }); - const second = await resolveSharedMatrixClient({ accountId: "main", startClient: false }); + const first = await resolveSharedMatrixClient({ + cfg: TEST_CFG, + accountId: "main", + startClient: false, + }); + const second = await resolveSharedMatrixClient({ + cfg: TEST_CFG, + accountId: "main", + startClient: false, + }); expect(first).toBe(firstClient); expect(second).toBe(secondClient); diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index ff13bb0e6c9..6201f901862 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -155,13 +155,21 @@ async function resolveSharedMatrixClientState( `Matrix shared client account mismatch: requested ${requestedAccountId}, auth resolved ${params.auth.accountId}`, ); } - const authContext = params.auth - ? null - : resolveMatrixAuthContext({ - cfg: params.cfg, - env: params.env, - accountId: params.accountId, - }); + const authContext = (() => { + if (params.auth) { + return null; + } + if (!params.cfg) { + throw new Error( + "Matrix shared client requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", + ); + } + return resolveMatrixAuthContext({ + cfg: params.cfg, + env: params.env, + accountId: params.accountId, + }); + })(); const auth = params.auth ?? (await resolveMatrixAuth({ diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index be66487e004..9a1edf7fcdf 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -356,6 +356,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi needsRoomAliasesForConfig, }); threadBindingManager = await createMatrixThreadBindingManager({ + cfg, accountId: effectiveAccountId, auth, client, diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 145bc8e8915..ff827517fae 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -33,6 +33,16 @@ const resolveMarkdownTableModeMock = vi.fn(() => "code"); const convertMarkdownTablesMock = vi.fn((text: string) => text); const chunkMarkdownTextWithModeMock = vi.fn((text: string) => (text ? [text] : [])); +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); + return { + ...actual, + requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfigMock()), + }; +}); + vi.mock("./outbound-media-runtime.js", () => ({ loadOutboundMediaFromUrl: loadOutboundMediaFromUrlMock, })); @@ -179,6 +189,7 @@ describe("sendMessageMatrix media", () => { await sendMessageMatrix("room:!room:example", "caption", { client, + cfg: {} as never, mediaUrl: "file:///tmp/photo.png", }); @@ -202,6 +213,7 @@ describe("sendMessageMatrix media", () => { await sendMessageMatrix("room:!room:example", "caption", { client, + cfg: {} as never, mediaUrl: "file:///tmp/photo.png", }); @@ -246,6 +258,7 @@ describe("sendMessageMatrix media", () => { await sendMessageMatrix("room:!room:example", "caption", { client, + cfg: {} as never, mediaUrl: "file:///tmp/photo.png", }); @@ -279,6 +292,7 @@ describe("sendMessageMatrix media", () => { await sendMessageMatrix("room:!room:example", "voice caption", { client, + cfg: {} as never, mediaUrl: "file:///tmp/clip.mp3", audioAsVoice: true, replyToId: "$reply", @@ -310,6 +324,7 @@ describe("sendMessageMatrix media", () => { await sendMessageMatrix("room:!room:example", "voice caption", { client, + cfg: {} as never, mediaUrl: "file:///tmp/clip.wav", audioAsVoice: true, }); @@ -334,6 +349,7 @@ describe("sendMessageMatrix media", () => { await sendMessageMatrix("room:!room:example", "caption", { client, + cfg: {} as never, mediaUrl: "file:///tmp/photo.png", }); @@ -401,6 +417,7 @@ describe("sendMessageMatrix media", () => { await sendMessageMatrix("room:!room:example", "caption", { client, + cfg: {} as never, mediaUrl: "file:///tmp/photo.png", mediaLocalRoots: ["/tmp/openclaw-matrix-test"], }); @@ -426,6 +443,7 @@ describe("sendMessageMatrix mentions", () => { await sendMessageMatrix("room:!room:example", "hello", { client, + cfg: {} as never, }); expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ @@ -439,6 +457,7 @@ describe("sendMessageMatrix mentions", () => { await sendMessageMatrix("room:!room:example", "hello @alice:example.org", { client, + cfg: {} as never, }); expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ @@ -455,6 +474,7 @@ describe("sendMessageMatrix mentions", () => { await sendMessageMatrix("room:!room:example", "hello @alice", { client, + cfg: {} as never, }); expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ @@ -470,6 +490,7 @@ describe("sendMessageMatrix mentions", () => { await sendMessageMatrix("room:!room:example", "\\@alice:example.org", { client, + cfg: {} as never, }); expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ @@ -485,6 +506,7 @@ describe("sendMessageMatrix mentions", () => { await sendMessageMatrix("room:!room:example", "\\@room please review", { client, + cfg: {} as never, }); expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ @@ -497,6 +519,7 @@ describe("sendMessageMatrix mentions", () => { await sendMessageMatrix("room:!room:example", "@room please review", { client, + cfg: {} as never, }); expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ @@ -509,6 +532,7 @@ describe("sendMessageMatrix mentions", () => { await sendMessageMatrix("room:!room:example", "caption @alice:example.org", { client, + cfg: {} as never, mediaUrl: "file:///tmp/photo.png", }); @@ -528,6 +552,7 @@ describe("sendMessageMatrix mentions", () => { await sendMessageMatrix("room:!room:example", "", { client, + cfg: {} as never, mediaUrl: "file:///tmp/room.png", }); @@ -552,6 +577,7 @@ describe("sendMessageMatrix threads", () => { await sendMessageMatrix("room:!room:example", "hello thread", { client, + cfg: {} as never, threadId: "$thread", }); @@ -575,6 +601,7 @@ describe("sendMessageMatrix threads", () => { await sendMessageMatrix("room:!room:example", "hello", { client, + cfg: {} as never, accountId: "ops", }); @@ -593,6 +620,7 @@ describe("sendMessageMatrix threads", () => { const result = await sendMessageMatrix("room:!room:example", "ignored", { client, + cfg: {} as never, }); expect(result).toMatchObject({ @@ -618,6 +646,7 @@ describe("sendSingleTextMessageMatrix", () => { await expect( sendSingleTextMessageMatrix("room:!room:example", "1234", { client, + cfg: {} as never, }), ).rejects.toThrow("Matrix single-message text exceeds limit"); @@ -629,6 +658,7 @@ describe("sendSingleTextMessageMatrix", () => { await sendSingleTextMessageMatrix("room:!room:example", "@room hi @alice:example.org", { client, + cfg: {} as never, msgtype: "m.notice", includeMentions: false, }); @@ -648,6 +678,7 @@ describe("sendSingleTextMessageMatrix", () => { await sendSingleTextMessageMatrix("room:!room:example", "done", { client, + cfg: {} as never, extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true }, }); @@ -679,6 +710,7 @@ describe("editMessageMatrix mentions", () => { "hello @alice:example.org and @bob:example.org", { client, + cfg: {} as never, }, ); @@ -700,6 +732,7 @@ describe("editMessageMatrix mentions", () => { await editMessageMatrix("room:!room:example", "$original", "hello again @alice:example.org", { client, + cfg: {} as never, }); expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ @@ -722,6 +755,7 @@ describe("editMessageMatrix mentions", () => { await editMessageMatrix("room:!room:example", "$original", "@alice:example.org", { client, + cfg: {} as never, }); expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ @@ -743,6 +777,7 @@ describe("editMessageMatrix mentions", () => { await editMessageMatrix("room:!room:example", "$original", "@room hi @alice:example.org", { client, + cfg: {} as never, msgtype: "m.notice", includeMentions: false, }); @@ -772,6 +807,7 @@ describe("editMessageMatrix mentions", () => { await editMessageMatrix("room:!room:example", "$original", "done", { client, + cfg: {} as never, extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true }, }); @@ -801,6 +837,7 @@ describe("sendPollMatrix mentions", () => { }, { client, + cfg: {} as never, }, ); @@ -841,6 +878,7 @@ describe("voteMatrixPoll", () => { const result = await voteMatrixPoll("room:!room:example", "$poll", { client, + cfg: {} as never, optionIndex: 2, }); @@ -877,6 +915,7 @@ describe("voteMatrixPoll", () => { await expect( voteMatrixPoll("room:!room:example", "$poll", { client, + cfg: {} as never, optionIndex: 2, }), ).rejects.toThrow("out of range"); @@ -901,6 +940,7 @@ describe("voteMatrixPoll", () => { await expect( voteMatrixPoll("room:!room:example", "$poll", { client, + cfg: {} as never, optionIndexes: [1, 2], }), ).rejects.toThrow("at most 1 selection"); @@ -916,6 +956,7 @@ describe("voteMatrixPoll", () => { await expect( voteMatrixPoll("room:!room:example", "$poll", { client, + cfg: {} as never, optionIndex: 1, }), ).rejects.toThrow("is not a Matrix poll start event"); @@ -938,6 +979,7 @@ describe("voteMatrixPoll", () => { await expect( voteMatrixPoll("room:!room:example", "$poll", { client, + cfg: {} as never, optionIndex: 1, }), ).resolves.toMatchObject({ diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 5b223bb1d33..7aefc46803b 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -1,3 +1,4 @@ +import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import type { MarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import type { PollInput } from "../runtime-api.js"; import { getMatrixRuntime } from "../runtime.js"; @@ -135,13 +136,13 @@ async function resolvePreviousEditMentions(params: { export function prepareMatrixSingleText( text: string, opts: { - cfg?: CoreConfig; + cfg: CoreConfig; accountId?: string; tableMode?: MarkdownTableMode; - } = {}, + }, ): MatrixPreparedSingleText { const trimmedText = text.trim(); - const cfg = opts.cfg ?? getCore().config.loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "Matrix text preparation") as CoreConfig; const tableMode = opts.tableMode ?? getCore().channel.text.resolveMarkdownTableMode({ @@ -165,13 +166,13 @@ export function prepareMatrixSingleText( export function chunkMatrixText( text: string, opts: { - cfg?: CoreConfig; + cfg: CoreConfig; accountId?: string; tableMode?: MarkdownTableMode; - } = {}, + }, ): MatrixPreparedChunkedText { const preparedText = prepareMatrixSingleText(text, opts); - const cfg = opts.cfg ?? getCore().config.loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "Matrix text chunking") as CoreConfig; const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); return { ...preparedText, @@ -186,7 +187,7 @@ export function chunkMatrixText( export async function sendMessageMatrix( to: string, message: string | undefined, - opts: MatrixSendOpts = {}, + opts: MatrixSendOpts, ): Promise { const trimmedMessage = message?.trim() ?? ""; if (!trimmedMessage && !opts.mediaUrl) { @@ -201,7 +202,7 @@ export async function sendMessageMatrix( }, async (client) => { const roomId = await resolveMatrixRoomId(client, to); - const cfg = opts.cfg ?? getCore().config.loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "Matrix send") as CoreConfig; const { chunks } = chunkMatrixText(trimmedMessage, { cfg, accountId: opts.accountId, @@ -330,7 +331,7 @@ export async function sendMessageMatrix( export async function sendPollMatrix( to: string, poll: PollInput, - opts: MatrixSendOpts = {}, + opts: MatrixSendOpts, ): Promise<{ eventId: string; roomId: string }> { if (!poll.question?.trim()) { throw new Error("Matrix poll requires a question"); @@ -416,7 +417,7 @@ export async function sendSingleTextMessageMatrix( text: string, opts: { client?: MatrixClient; - cfg?: CoreConfig; + cfg: CoreConfig; replyToId?: string; threadId?: string; accountId?: string; @@ -425,7 +426,7 @@ export async function sendSingleTextMessageMatrix( extraContent?: MatrixExtraContentFields; /** When true, marks the message as a live/streaming update (MSC4357). */ live?: boolean; - } = {}, + }, ): Promise { const { trimmedText, convertedText, singleEventLimit, fitsInSingleEvent } = prepareMatrixSingleText(text, { @@ -502,7 +503,7 @@ export async function editMessageMatrix( newText: string, opts: { client?: MatrixClient; - cfg?: CoreConfig; + cfg: CoreConfig; threadId?: string; accountId?: string; timeoutMs?: number; @@ -511,7 +512,7 @@ export async function editMessageMatrix( extraContent?: MatrixExtraContentFields; /** When true, marks the edit as a live/streaming update (MSC4357). */ live?: boolean; - } = {}, + }, ): Promise { return await withResolvedMatrixSendClient( { @@ -522,7 +523,7 @@ export async function editMessageMatrix( }, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const cfg = opts.cfg ?? getCore().config.loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "Matrix message edit") as CoreConfig; const tableMode = getCore().channel.text.resolveMarkdownTableMode({ cfg, channel: "matrix", diff --git a/extensions/matrix/src/matrix/send/client.test.ts b/extensions/matrix/src/matrix/send/client.test.ts index 2e7555256fa..81a0e7711eb 100644 --- a/extensions/matrix/src/matrix/send/client.test.ts +++ b/extensions/matrix/src/matrix/send/client.test.ts @@ -16,6 +16,8 @@ const { resolveMatrixAuthContextMock, } = matrixClientResolverMocks; +const TEST_CFG = {}; + vi.mock("../active-client.js", () => ({ getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args), })); @@ -56,7 +58,10 @@ describe("matrix send client helpers", () => { it("stops one-off shared clients when no active monitor client is registered", async () => { vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799"); - const result = await withResolvedMatrixSendClient({ accountId: "default" }, async () => "ok"); + const result = await withResolvedMatrixSendClient( + { cfg: TEST_CFG, accountId: "default" }, + async () => "ok", + ); await expectOneOffSharedMatrixClient({ prepareForOneOffCalls: 0, @@ -70,10 +75,13 @@ describe("matrix send client helpers", () => { const activeClient = createMockMatrixClient(); getActiveMatrixClientMock.mockReturnValue(activeClient); - const result = await withResolvedMatrixSendClient({ accountId: "default" }, async (client) => { - expect(client).toBe(activeClient); - return "ok"; - }); + const result = await withResolvedMatrixSendClient( + { cfg: TEST_CFG, accountId: "default" }, + async (client) => { + expect(client).toBe(activeClient); + return "ok"; + }, + ); expect(result).toBe("ok"); expect(acquireSharedMatrixClientMock).not.toHaveBeenCalled(); @@ -89,7 +97,7 @@ describe("matrix send client helpers", () => { accountId: "ops", resolved: {}, }); - await withResolvedMatrixSendClient({}, async () => {}); + await withResolvedMatrixSendClient({ cfg: TEST_CFG }, async () => {}); await expectOneOffSharedMatrixClient({ accountId: "ops", @@ -121,7 +129,7 @@ describe("matrix send client helpers", () => { acquireSharedMatrixClientMock.mockResolvedValue(sharedClient); await expect( - withResolvedMatrixSendClient({ accountId: "default" }, async () => { + withResolvedMatrixSendClient({ cfg: TEST_CFG, accountId: "default" }, async () => { throw new Error("boom"); }), ).rejects.toThrow("boom"); @@ -133,7 +141,7 @@ describe("matrix send client helpers", () => { const sharedClient = createMockMatrixClient(); acquireSharedMatrixClientMock.mockResolvedValue(sharedClient); - await withResolvedMatrixSendClient({ accountId: "default" }, async () => "ok"); + await withResolvedMatrixSendClient({ cfg: TEST_CFG, accountId: "default" }, async () => "ok"); expect(sharedClient.start).toHaveBeenCalledTimes(1); expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled(); @@ -141,7 +149,7 @@ describe("matrix send client helpers", () => { it("keeps one-off control clients lightweight when no active monitor client is registered", async () => { const result = await withResolvedMatrixControlClient( - { accountId: "default" }, + { cfg: TEST_CFG, accountId: "default" }, async () => "ok", ); @@ -158,7 +166,7 @@ describe("matrix send client helpers", () => { getActiveMatrixClientMock.mockReturnValue(activeClient); const result = await withResolvedMatrixControlClient( - { accountId: "default" }, + { cfg: TEST_CFG, accountId: "default" }, async (client) => { expect(client).toBe(activeClient); return "ok"; diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index 29789a22483..b06c8e33509 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -1,10 +1,8 @@ -import { getMatrixRuntime } from "../../runtime.js"; +import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import type { CoreConfig } from "../../types.js"; import { resolveMatrixAccountConfig } from "../account-config.js"; import type { MatrixClient } from "../sdk.js"; -const getCore = () => getMatrixRuntime(); - type MatrixSendClientRuntime = Pick< typeof import("../client-bootstrap.js"), "withResolvedRuntimeMatrixClient" @@ -21,7 +19,12 @@ export function resolveMediaMaxBytes( accountId?: string | null, cfg?: CoreConfig, ): number | undefined { - const resolvedCfg = cfg ?? (getCore().config.loadConfig() as CoreConfig); + if (!cfg) { + throw new Error( + "Matrix media limits requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", + ); + } + const resolvedCfg = requireRuntimeConfig(cfg, "Matrix media limits") as CoreConfig; const matrixCfg = resolveMatrixAccountConfig({ cfg: resolvedCfg, accountId }); const mediaMaxMb = typeof matrixCfg.mediaMaxMb === "number" ? matrixCfg.mediaMaxMb : undefined; if (typeof mediaMaxMb === "number") { diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index 634b8c83eaa..89ba915f283 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -89,8 +89,8 @@ export type MatrixSendResult = { }; export type MatrixSendOpts = { + cfg: CoreConfig; client?: import("../sdk.js").MatrixClient; - cfg?: CoreConfig; mediaUrl?: string; mediaAccess?: { localRoots?: readonly string[]; diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts index 804634ee46a..362aabf29c8 100644 --- a/extensions/matrix/src/matrix/thread-bindings.test.ts +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -74,6 +74,7 @@ describe("matrix thread bindings", () => { } = {}, ) { return createMatrixThreadBindingManager({ + cfg: {}, accountId, auth: params.auth ?? auth, client: matrixClient, @@ -170,6 +171,7 @@ describe("matrix thread bindings", () => { it("creates child Matrix thread bindings from a top-level room context", async () => { await createMatrixThreadBindingManager({ + cfg: {}, accountId, auth, client: matrixClient, @@ -193,6 +195,7 @@ describe("matrix thread bindings", () => { }); expect(sendMessageMatrixMock).toHaveBeenCalledWith("room:!room:example", "intro root", { + cfg: {}, client: {}, accountId: "ops", }); @@ -214,6 +217,7 @@ describe("matrix thread bindings", () => { }); expect(sendMessageMatrixMock).toHaveBeenCalledWith("room:!room:example", "intro thread", { + cfg: {}, client: {}, accountId: "ops", threadId: "$thread", @@ -236,6 +240,7 @@ describe("matrix thread bindings", () => { vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z")); try { await createMatrixThreadBindingManager({ + cfg: {}, accountId: "ops", auth, client: {} as never, @@ -280,6 +285,7 @@ describe("matrix thread bindings", () => { vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z")); try { await createMatrixThreadBindingManager({ + cfg: {}, accountId: "ops", auth, client: {} as never, @@ -333,6 +339,7 @@ describe("matrix thread bindings", () => { const logVerboseMessage = vi.fn(); try { await createMatrixThreadBindingManager({ + cfg: {}, accountId: "ops", auth, client: {} as never, @@ -387,6 +394,7 @@ describe("matrix thread bindings", () => { it("sends threaded farewell messages when bindings are unbound", async () => { await createMatrixThreadBindingManager({ + cfg: {}, accountId: "ops", auth, client: {} as never, @@ -420,6 +428,7 @@ describe("matrix thread bindings", () => { "room:!room:example", expect.stringContaining("Session ended automatically"), expect.objectContaining({ + cfg: {}, accountId: "ops", threadId: "$thread", }), @@ -569,6 +578,7 @@ describe("matrix thread bindings", () => { vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z")); try { const manager = await createMatrixThreadBindingManager({ + cfg: {}, accountId: "ops", auth, client: {} as never, diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index edb50da4052..f0541619023 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/session-key-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -146,6 +147,7 @@ function buildMatrixBindingIntroText(params: { } async function sendBindingMessage(params: { + cfg: OpenClawConfig; client: MatrixClient; accountId: string; roomId: string; @@ -157,6 +159,7 @@ async function sendBindingMessage(params: { return null; } const result = await sendMessageMatrix(`room:${params.roomId}`, trimmed, { + cfg: params.cfg, client: params.client, accountId: params.accountId, ...(params.threadId ? { threadId: params.threadId } : {}), @@ -165,6 +168,7 @@ async function sendBindingMessage(params: { } async function sendFarewellMessage(params: { + cfg: OpenClawConfig; client: MatrixClient; accountId: string; record: MatrixThreadBindingRecord; @@ -185,6 +189,7 @@ async function sendFarewellMessage(params: { maxAgeMs, }); await sendBindingMessage({ + cfg: params.cfg, client: params.client, accountId: params.accountId, roomId, @@ -198,6 +203,7 @@ async function sendFarewellMessage(params: { } export async function createMatrixThreadBindingManager(params: { + cfg: OpenClawConfig; accountId: string; auth: MatrixAuth; client: MatrixClient; @@ -387,6 +393,7 @@ export async function createMatrixThreadBindingManager(params: { await Promise.all( removed.map(async (record) => { await sendFarewellMessage({ + cfg: params.cfg, client: params.client, accountId: params.accountId, record, @@ -429,6 +436,7 @@ export async function createMatrixThreadBindingManager(params: { if (input.placement === "child") { const roomId = parentConversationId || conversationId; const rootEventId = await sendBindingMessage({ + cfg: params.cfg, client: params.client, accountId: params.accountId, roomId, @@ -468,6 +476,7 @@ export async function createMatrixThreadBindingManager(params: { ? boundConversationId : undefined; await sendBindingMessage({ + cfg: params.cfg, client: params.client, accountId: params.accountId, roomId, diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 1f559186ae2..70dbc917d60 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -196,6 +196,7 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { const result = await ( await loadMattermostChannelRuntime() ).sendMessageMattermost(to, message, { + cfg, accountId: resolvedAccountId, replyToId, buttons: presentation diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts index 02a66b5e88c..4625ca8462e 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -16,7 +16,7 @@ type SendMattermostMessage = ( to: string, text: string, opts: { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; accountId?: string; mediaUrl?: string; mediaLocalRoots?: readonly string[]; diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index 6dc03d95854..2dab3357f2b 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -1,8 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - expectProvidedCfgSkipsRuntimeLoad, - expectRuntimeCfgFallback, -} from "../../../../test/helpers/plugins/send-config.js"; +import { expectProvidedCfgSkipsRuntimeLoad } from "../../../../test/helpers/plugins/send-config.js"; let parseMattermostTarget: typeof import("./send.js").parseMattermostTarget; let sendMessageMattermost: typeof import("./send.js").sendMessageMattermost; @@ -12,6 +9,8 @@ type SendMessageMattermostOptions = NonNullable< Parameters[2] >; +const TEST_CFG = {}; + const mockState = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({})), loadOutboundMediaFromUrl: vi.fn(), @@ -40,6 +39,12 @@ vi.mock("../../runtime-api.js", () => ({ })); vi.mock("openclaw/plugin-sdk/config-runtime", () => ({ + requireRuntimeConfig: (cfg: unknown) => { + if (cfg) { + return cfg; + } + throw new Error("Mattermost send requires a resolved runtime config"); + }, resolveMarkdownTableMode: vi.fn(() => "off"), })); @@ -178,30 +183,12 @@ describe("sendMessageMattermost", () => { }); }); - it("falls back to runtime loadConfig when cfg is omitted", async () => { - const runtimeCfg = { - channels: { - mattermost: { - botToken: "runtime-token", - }, - }, - }; - mockState.loadConfig.mockReturnValueOnce(runtimeCfg); - mockState.resolveMattermostAccount.mockReturnValue({ - accountId: "default", - botToken: "runtime-token", - baseUrl: "https://mattermost.example.com", - config: {}, - }); - - await sendMessageMattermost("channel:town-square", "hello"); - - expectRuntimeCfgFallback({ - loadConfig: mockState.loadConfig, - resolveAccount: mockState.resolveMattermostAccount, - cfg: runtimeCfg, - accountId: undefined, - }); + it("fails hard when cfg is omitted", async () => { + await expect( + sendMessageMattermost("channel:town-square", "hello", undefined as never), + ).rejects.toThrow("Mattermost send requires a resolved runtime config"); + expect(mockState.loadConfig).not.toHaveBeenCalled(); + expect(mockState.resolveMattermostAccount).not.toHaveBeenCalled(); }); it("sends with provided cfg even when the runtime store is not initialized", async () => { @@ -249,6 +236,7 @@ describe("sendMessageMattermost", () => { }); await sendMessageMattermost("channel:town-square", "hello", { + cfg: TEST_CFG, mediaUrl: "file:///tmp/agent-workspace/photo.png", mediaLocalRoots: ["/tmp/agent-workspace"], }); @@ -278,6 +266,7 @@ describe("sendMessageMattermost", () => { }); await sendMessageMattermost("channel:town-square", "Pick a model", { + cfg: TEST_CFG, buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]], }); @@ -319,6 +308,7 @@ describe("sendMessageMattermost", () => { }); const result = await sendMessageMattermost(userId, "hello", { + cfg: TEST_CFG, mediaUrl: "file:///tmp/agent-workspace/photo.png", mediaLocalRoots: ["/tmp/agent-workspace"], }); @@ -357,6 +347,7 @@ describe("sendMessageMattermost", () => { }); const result = await sendMessageMattermost(channelId, "hello", { + cfg: TEST_CFG, mediaUrl: "file:///tmp/agent-workspace/photo.png", mediaLocalRoots: ["/tmp/agent-workspace"], }); @@ -484,7 +475,7 @@ describe("sendMessageMattermost user-first resolution", () => { mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-user-dm-t1")); mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); - const res = await sendMessageMattermost(userId, "hello"); + const res = await sendMessageMattermost(userId, "hello", { cfg: TEST_CFG }); expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1); @@ -501,7 +492,7 @@ describe("sendMessageMattermost user-first resolution", () => { const err = new Error("Mattermost API 404: user not found"); mockState.fetchMattermostUser.mockRejectedValueOnce(err); - const res = await sendMessageMattermost(channelId, "hello"); + const res = await sendMessageMattermost(channelId, "hello", { cfg: TEST_CFG }); expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled(); @@ -521,7 +512,7 @@ describe("sendMessageMattermost user-first resolution", () => { mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenA)); mockState.fetchMattermostUser.mockRejectedValueOnce(transientErr); - const res1 = await sendMessageMattermost(userId, "first"); + const res1 = await sendMessageMattermost(userId, "first", { cfg: TEST_CFG }); expect(res1.channelId).toBe(userId); // Second call with a different token (new cache key) → retries user lookup @@ -533,7 +524,7 @@ describe("sendMessageMattermost user-first resolution", () => { mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenB)); mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); - const res2 = await sendMessageMattermost(userId, "second"); + const res2 = await sendMessageMattermost(userId, "second", { cfg: TEST_CFG }); expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); expect(res2.channelId).toBe("dm-channel-id"); }); @@ -544,7 +535,7 @@ describe("sendMessageMattermost user-first resolution", () => { mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-user-t4")); mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" }); - const res = await sendMessageMattermost(`user:${userId}`, "hello"); + const res = await sendMessageMattermost(`user:${userId}`, "hello", { cfg: TEST_CFG }); expect(mockState.fetchMattermostUser).not.toHaveBeenCalled(); expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1); @@ -556,7 +547,7 @@ describe("sendMessageMattermost user-first resolution", () => { const chanId = "eeeeee5555555555eeeeee5555"; // 26 chars mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-chan-t5")); - const res = await sendMessageMattermost(`channel:${chanId}`, "hello"); + const res = await sendMessageMattermost(`channel:${chanId}`, "hello", { cfg: TEST_CFG }); expect(mockState.fetchMattermostUser).not.toHaveBeenCalled(); expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled(); @@ -578,6 +569,7 @@ describe("sendMessageMattermost user-first resolution", () => { }; await sendMessageMattermost(`user:${userId}`, "hello", { + cfg: TEST_CFG, dmRetryOptions: retryOptions, }); @@ -605,7 +597,7 @@ describe("sendMessageMattermost user-first resolution", () => { }); mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); - await sendMessageMattermost(`user:${userId}`, "hello"); + await sendMessageMattermost(`user:${userId}`, "hello", { cfg: TEST_CFG }); expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith( {}, @@ -640,6 +632,7 @@ describe("sendMessageMattermost user-first resolution", () => { }; await sendMessageMattermost(`user:${userId}`, "hello", { + cfg: TEST_CFG, dmRetryOptions: overrideOptions, }); diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index e03dba6a8c4..e9cc0c9b225 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -1,4 +1,4 @@ -import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { convertMarkdownTables, @@ -30,7 +30,7 @@ import { loadOutboundMediaFromUrl, type OpenClawConfig } from "./runtime-api.js" import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js"; export type MattermostSendOpts = { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; botToken?: string; baseUrl?: string; accountId?: string; @@ -315,11 +315,16 @@ type MattermostSendContext = { async function resolveMattermostSendContext( to: string, - opts: MattermostSendOpts = {}, + opts: MattermostSendOpts, ): Promise { const core = getCore(); const logger = core.logging.getChildLogger({ module: "mattermost" }); - const cfg = opts.cfg ?? core.config.loadConfig(); + if (!opts?.cfg) { + throw new Error( + "Mattermost send requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", + ); + } + const cfg = requireRuntimeConfig(opts.cfg, "Mattermost send"); const account = resolveMattermostAccount({ cfg, accountId: opts.accountId, @@ -382,7 +387,7 @@ async function resolveMattermostSendContext( export async function resolveMattermostSendChannelId( to: string, - opts: MattermostSendOpts = {}, + opts: MattermostSendOpts, ): Promise { return (await resolveMattermostSendContext(to, opts)).channelId; } @@ -390,7 +395,7 @@ export async function resolveMattermostSendChannelId( export async function sendMessageMattermost( to: string, text: string, - opts: MattermostSendOpts = {}, + opts: MattermostSendOpts, ): Promise { const core = getCore(); const logger = core.logging.getChildLogger({ module: "mattermost" }); diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 9f7c435f309..318abdda8a1 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -31,16 +31,18 @@ import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; const CHANNEL_ID = "nextcloud-talk" as const; async function deliverNextcloudTalkReply(params: { + cfg: CoreConfig; payload: OutboundReplyPayload; roomToken: string; accountId: string; statusSink?: (patch: { lastOutboundAt?: number }) => void; }): Promise { - const { payload, roomToken, accountId, statusSink } = params; + const { cfg, payload, roomToken, accountId, statusSink } = params; await deliverFormattedTextWithAttachments({ payload, send: async ({ text, replyToId }) => { await sendMessageNextcloudTalk(roomToken, text, { + cfg, accountId, replyTo: replyToId, }); @@ -177,7 +179,10 @@ export async function handleNextcloudTalkInbound(params: { senderIdLine: `Your Nextcloud user id: ${senderId}`, meta: { name: senderName || undefined }, sendPairingReply: async (text) => { - await sendMessageNextcloudTalk(roomToken, text, { accountId: account.accountId }); + await sendMessageNextcloudTalk(roomToken, text, { + cfg: config, + accountId: account.accountId, + }); statusSink?.({ lastOutboundAt: Date.now() }); }, onReplyError: (err) => { @@ -291,6 +296,7 @@ export async function handleNextcloudTalkInbound(params: { core, deliver: async (payload) => { await deliverNextcloudTalkReply({ + cfg: config, payload, roomToken, accountId: account.accountId, diff --git a/extensions/nextcloud-talk/src/send.cfg-threading.test.ts b/extensions/nextcloud-talk/src/send.cfg-threading.test.ts index 9f648a3436c..36b2603c1d4 100644 --- a/extensions/nextcloud-talk/src/send.cfg-threading.test.ts +++ b/extensions/nextcloud-talk/src/send.cfg-threading.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSendCfgThreadingRuntime, expectProvidedCfgSkipsRuntimeLoad, - expectRuntimeCfgFallback, } from "../../../test/helpers/plugins/send-config.js"; const hoisted = vi.hoisted(() => ({ @@ -25,6 +24,12 @@ vi.mock("./send.runtime.js", () => { fetchWithSsrFGuard: hoisted.mockFetchGuard, generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature, getNextcloudTalkRuntime: () => createSendCfgThreadingRuntime(hoisted), + requireRuntimeConfig: (cfg: unknown, context: string) => { + if (cfg) { + return cfg; + } + throw new Error(`${context} requires a resolved runtime config`); + }, resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount, resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode, ssrfPolicyFromPrivateNetworkOptIn: hoisted.ssrfPolicyFromPrivateNetworkOptIn, @@ -133,21 +138,16 @@ describe("nextcloud-talk send cfg threading", () => { }); }); - it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => { - const runtimeCfg = { source: "runtime" } as const; - hoisted.loadConfig.mockReturnValueOnce(runtimeCfg); + it("fails hard for sendReaction when cfg is omitted", async () => { fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 })); - const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", { - accountId: "default", - }); + await expect( + sendReactionNextcloudTalk("room:ops", "m-1", "👍", { + accountId: "default", + } as never), + ).rejects.toThrow("Nextcloud Talk send requires a resolved runtime config"); - expect(result).toEqual({ ok: true }); - expectRuntimeCfgFallback({ - loadConfig: hoisted.loadConfig, - resolveAccount: hoisted.resolveNextcloudTalkAccount, - cfg: runtimeCfg, - accountId: "default", - }); + expect(hoisted.loadConfig).not.toHaveBeenCalled(); + expect(hoisted.resolveNextcloudTalkAccount).not.toHaveBeenCalled(); }); }); diff --git a/extensions/nextcloud-talk/src/send.runtime.ts b/extensions/nextcloud-talk/src/send.runtime.ts index 6ced3f2c905..edad9a15879 100644 --- a/extensions/nextcloud-talk/src/send.runtime.ts +++ b/extensions/nextcloud-talk/src/send.runtime.ts @@ -1,4 +1,4 @@ -export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +export { requireRuntimeConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; export { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime"; export { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; export { fetchWithSsrFGuard } from "../runtime-api.js"; diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index b5bd84501a4..c7aa17f7b94 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -4,6 +4,7 @@ import { fetchWithSsrFGuard, generateNextcloudTalkSignature, getNextcloudTalkRuntime, + requireRuntimeConfig, resolveMarkdownTableMode, resolveNextcloudTalkAccount, ssrfPolicyFromPrivateNetworkOptIn, @@ -11,12 +12,12 @@ import { import type { CoreConfig, NextcloudTalkSendResult } from "./types.js"; type NextcloudTalkSendOpts = { + cfg: CoreConfig; baseUrl?: string; secret?: string; accountId?: string; replyTo?: string; verbose?: boolean; - cfg?: CoreConfig; }; function resolveCredentials( @@ -54,7 +55,7 @@ function resolveNextcloudTalkSendContext(opts: NextcloudTalkSendOpts): { baseUrl: string; secret: string; } { - const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig; + const cfg = requireRuntimeConfig(opts.cfg, "Nextcloud Talk send") as CoreConfig; const account = resolveNextcloudTalkAccount({ cfg, accountId: opts.accountId, @@ -83,7 +84,7 @@ function recordNextcloudTalkOutboundActivity(accountId: string): void { export async function sendMessageNextcloudTalk( to: string, text: string, - opts: NextcloudTalkSendOpts = {}, + opts: NextcloudTalkSendOpts, ): Promise { const { cfg, account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts); const roomToken = normalizeRoomToken(to); @@ -192,7 +193,7 @@ export async function sendReactionNextcloudTalk( roomToken: string, messageId: string, reaction: string, - opts: Omit = {}, + opts: Omit, ): Promise<{ ok: true }> { const { account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts); const normalizedToken = normalizeRoomToken(roomToken); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index cfda32cea25..33c76530cec 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -348,8 +348,12 @@ export const signalPlugin: ChannelPlugin = idLabel: "signalNumber", message: PAIRING_APPROVED_MESSAGE, normalizeAllowEntry: createPairingPrefixStripper(/^signal:/i), - notify: async ({ id, message }) => { - await (await loadSignalSendRuntime()).sendMessageSignal(id, message); + notify: async ({ cfg, id, message }) => { + await ( + await loadSignalSendRuntime() + ).sendMessageSignal(id, message, { + cfg, + }); }, }, }, diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 809d24db8b4..3ef8b0797f1 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -304,6 +304,7 @@ async function fetchAttachment(params: { } async function deliverReplies(params: { + cfg: OpenClawConfig; replies: ReplyPayload[]; target: string; baseUrl: string; @@ -324,6 +325,7 @@ async function deliverReplies(params: { chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), sendText: async (chunk) => { await sendMessageSignal(target, chunk, { + cfg: params.cfg, baseUrl, account, maxBytes, @@ -332,6 +334,7 @@ async function deliverReplies(params: { }, sendMedia: async ({ mediaUrl, caption }) => { await sendMessageSignal(target, caption ?? "", { + cfg: params.cfg, baseUrl, account, mediaUrl, @@ -465,7 +468,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi sendReadReceipts, readReceiptsViaDaemon, fetchAttachment, - deliverReplies: (params) => deliverReplies({ ...params, chunkMode }), + deliverReplies: (params) => deliverReplies({ ...params, cfg, chunkMode }), resolveSignalReactionTargets, isSignalReactionMessage, shouldEmitSignalReactionNotification, diff --git a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts index 8e3c9192eee..8e77a0620d6 100644 --- a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts @@ -139,11 +139,26 @@ describe("signal createSignalEventHandler inbound context", () => { }), ); - expect(sendTypingMock).toHaveBeenCalledWith("+15550001111", expect.any(Object)); + expect(sendTypingMock).toHaveBeenCalledWith( + "+15550001111", + expect.objectContaining({ + cfg: expect.objectContaining({ + channels: expect.objectContaining({ + signal: expect.objectContaining({ dmPolicy: "open" }), + }), + }), + }), + ); expect(sendReadReceiptMock).toHaveBeenCalledWith( "signal:+15550001111", 1700000000000, - expect.any(Object), + expect.objectContaining({ + cfg: expect.objectContaining({ + channels: expect.objectContaining({ + signal: expect.objectContaining({ dmPolicy: "open" }), + }), + }), + }), ); }); diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 4447e325d49..c5d52d1326f 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -284,6 +284,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { return; } await sendTypingSignal(ctxPayload.To, { + cfg: deps.cfg, baseUrl: deps.baseUrl, account: deps.account, accountId: deps.accountId, @@ -306,6 +307,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { typingCallbacks, deliver: async (payload) => { await deps.deliverReplies({ + cfg: deps.cfg, replies: [payload], target: ctxPayload.To, baseUrl: deps.baseUrl, @@ -601,6 +603,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { accountId: deps.accountId, sendPairingReply: async (text) => { await sendMessageSignal(`signal:${senderRecipient}`, text, { + cfg: deps.cfg, baseUrl: deps.baseUrl, account: deps.account, maxBytes: deps.mediaMaxBytes, @@ -831,6 +834,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { if (deps.sendReadReceipts && !deps.readReceiptsViaDaemon && !isGroup && receiptTimestamp) { try { await sendReadReceiptSignal(`signal:${senderRecipient}`, receiptTimestamp, { + cfg: deps.cfg, baseUrl: deps.baseUrl, account: deps.account, accountId: deps.accountId, diff --git a/extensions/signal/src/monitor/event-handler.types.ts b/extensions/signal/src/monitor/event-handler.types.ts index 2540083b444..228247dbec5 100644 --- a/extensions/signal/src/monitor/event-handler.types.ts +++ b/extensions/signal/src/monitor/event-handler.types.ts @@ -105,6 +105,7 @@ export type SignalEventHandlerDeps = { maxBytes: number; }) => Promise<{ path: string; contentType?: string } | null>; deliverReplies: (params: { + cfg: OpenClawConfig; replies: ReplyPayload[]; target: string; baseUrl: string; diff --git a/extensions/signal/src/send-reactions.test.ts b/extensions/signal/src/send-reactions.test.ts index 376aad08e38..d1cf292a731 100644 --- a/extensions/signal/src/send-reactions.test.ts +++ b/extensions/signal/src/send-reactions.test.ts @@ -29,6 +29,16 @@ vi.mock("./client.js", () => ({ let sendReactionSignal: typeof import("./send-reactions.js").sendReactionSignal; let removeReactionSignal: typeof import("./send-reactions.js").removeReactionSignal; +const SIGNAL_TEST_CFG = { + channels: { + signal: { + accounts: { + default: {}, + }, + }, + }, +}; + describe("sendReactionSignal", () => { beforeAll(async () => { ({ sendReactionSignal, removeReactionSignal } = await import("./send-reactions.js")); @@ -39,7 +49,9 @@ describe("sendReactionSignal", () => { }); it("uses recipients array and targetAuthor for uuid dms", async () => { - await sendReactionSignal("uuid:123e4567-e89b-12d3-a456-426614174000", 123, "🔥"); + await sendReactionSignal("uuid:123e4567-e89b-12d3-a456-426614174000", 123, "🔥", { + cfg: SIGNAL_TEST_CFG, + }); const params = rpcMock.mock.calls[0]?.[1] as Record; expect(rpcMock).toHaveBeenCalledWith("sendReaction", expect.any(Object), expect.any(Object)); @@ -52,6 +64,7 @@ describe("sendReactionSignal", () => { it("uses groupIds array and maps targetAuthorUuid", async () => { await sendReactionSignal("", 123, "✅", { + cfg: SIGNAL_TEST_CFG, groupId: "group-id", targetAuthorUuid: "uuid:123e4567-e89b-12d3-a456-426614174000", }); @@ -63,7 +76,7 @@ describe("sendReactionSignal", () => { }); it("defaults targetAuthor to recipient for removals", async () => { - await removeReactionSignal("+15551230000", 456, "❌"); + await removeReactionSignal("+15551230000", 456, "❌", { cfg: SIGNAL_TEST_CFG }); const params = rpcMock.mock.calls[0]?.[1] as Record; expect(params.recipients).toEqual(["+15551230000"]); diff --git a/extensions/signal/src/send-reactions.ts b/extensions/signal/src/send-reactions.ts index 6f3c00d6bf3..0b3306d6d1a 100644 --- a/extensions/signal/src/send-reactions.ts +++ b/extensions/signal/src/send-reactions.ts @@ -2,14 +2,14 @@ * Signal reactions via signal-cli JSON-RPC API */ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { resolveSignalAccount } from "./accounts.js"; import { signalRpcRequest } from "./client.js"; import { resolveSignalRpcContext } from "./rpc-context.js"; export type SignalReactionOpts = { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; baseUrl?: string; account?: string; accountId?: string; @@ -31,15 +31,6 @@ type SignalReactionErrorMessages = { missingTargetAuthor: string; }; -let signalConfigRuntimePromise: - | Promise - | undefined; - -async function loadSignalConfigRuntime() { - signalConfigRuntimePromise ??= import("openclaw/plugin-sdk/config-runtime"); - return await signalConfigRuntimePromise; -} - function normalizeSignalId(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { @@ -86,7 +77,7 @@ async function sendReactionSignalCore(params: { opts: SignalReactionOpts; errors: SignalReactionErrorMessages; }): Promise { - const cfg = params.opts.cfg ?? (await loadSignalConfigRuntime()).loadConfig(); + const cfg = requireRuntimeConfig(params.opts.cfg, "Signal reactions"); const accountInfo = resolveSignalAccount({ cfg, accountId: params.opts.accountId, @@ -153,7 +144,7 @@ export async function sendReactionSignal( recipient: string, targetTimestamp: number, emoji: string, - opts: SignalReactionOpts = {}, + opts: SignalReactionOpts, ): Promise { return await sendReactionSignalCore({ recipient, @@ -181,7 +172,7 @@ export async function removeReactionSignal( recipient: string, targetTimestamp: number, emoji: string, - opts: SignalReactionOpts = {}, + opts: SignalReactionOpts, ): Promise { return await sendReactionSignalCore({ recipient, diff --git a/extensions/signal/src/send.ts b/extensions/signal/src/send.ts index e31c0d1cf81..844b1d8f56f 100644 --- a/extensions/signal/src/send.ts +++ b/extensions/signal/src/send.ts @@ -1,4 +1,4 @@ -import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime"; @@ -9,7 +9,7 @@ import { markdownToSignalText, type SignalTextStyleRange } from "./format.js"; import { resolveSignalRpcContext } from "./rpc-context.js"; export type SignalSendOpts = { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; baseUrl?: string; account?: string; accountId?: string; @@ -43,13 +43,16 @@ type SignalTarget = | { type: "group"; groupId: string } | { type: "username"; username: string }; -async function resolveSignalRpcAccountInfo( - opts: Pick, -) { +async function resolveSignalRpcAccountInfo(opts: SignalRpcOpts) { if (opts.baseUrl?.trim() && opts.account?.trim()) { return undefined; } - const cfg = opts.cfg ?? loadConfig(); + if (!opts.cfg) { + throw new Error( + "Signal RPC account resolution requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", + ); + } + const cfg = requireRuntimeConfig(opts.cfg, "Signal RPC account resolution"); return resolveSignalAccount({ cfg, accountId: opts.accountId, @@ -121,9 +124,9 @@ function buildTargetParams( export async function sendMessageSignal( to: string, text: string, - opts: SignalSendOpts = {}, + opts: SignalSendOpts, ): Promise { - const cfg = opts.cfg ?? loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "Signal send"); const accountInfo = resolveSignalAccount({ cfg, accountId: opts.accountId, @@ -218,7 +221,7 @@ export async function sendMessageSignal( export async function sendTypingSignal( to: string, - opts: SignalRpcOpts & { stop?: boolean } = {}, + opts: SignalRpcOpts & { stop?: boolean }, ): Promise { const accountInfo = await resolveSignalRpcAccountInfo(opts); const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo); @@ -246,7 +249,7 @@ export async function sendTypingSignal( export async function sendReadReceiptSignal( to: string, targetTimestamp: number, - opts: SignalRpcOpts & { type?: SignalReceiptType } = {}, + opts: SignalRpcOpts & { type?: SignalReceiptType }, ): Promise { if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) { return false; diff --git a/extensions/slack/src/accounts.test.ts b/extensions/slack/src/accounts.test.ts index 8fe0cd770a3..f97f994023a 100644 --- a/extensions/slack/src/accounts.test.ts +++ b/extensions/slack/src/accounts.test.ts @@ -109,19 +109,14 @@ describe("resolveSlackAccount allowFrom precedence", () => { }); }); -describe("resolveSlackAccount tolerateUnresolvedSecrets", () => { - // The static `SlackAccountConfig.botToken` type is `string` because it - // models the post-resolution shape, but the runtime cfg snapshot can still - // hold an unresolved `SecretRef` object for inactive channel targets (per - // the inspect/strict separation in #66818). Cast via `unknown` so the test - // can construct that runtime-only shape without weakening the production - // type. See #68237. +describe("resolveSlackAccount active secret surfaces", () => { + const secretRef = { source: "exec", provider: "default", id: "slack_token" } as const; const cfgWithUnresolvedBotTokenRef = { channels: { slack: { accounts: { default: { - botToken: { source: "exec", provider: "default", id: "slack_bot_token" }, + botToken: secretRef, allowFrom: ["U999"], }, }, @@ -129,7 +124,7 @@ describe("resolveSlackAccount tolerateUnresolvedSecrets", () => { }, } as unknown as OpenClawConfig; - it("throws by default when the snapshot still holds an unresolved SecretRef botToken", () => { + it("throws when an enabled account still has an unresolved active bot token SecretRef", () => { expect(() => resolveSlackAccount({ cfg: cfgWithUnresolvedBotTokenRef, @@ -138,100 +133,83 @@ describe("resolveSlackAccount tolerateUnresolvedSecrets", () => { ).toThrowError(/channels\.slack\.accounts\.default\.botToken/); }); - it("returns undefined credentials without throwing when tolerateUnresolvedSecrets is set", () => { - const resolved = resolveSlackAccount({ - cfg: cfgWithUnresolvedBotTokenRef, - accountId: "default", - tolerateUnresolvedSecrets: true, - }); - - expect(resolved.botToken).toBeUndefined(); - expect(resolved.botTokenSource).toBe("none"); - // Surrounding account info still resolves so callers with an explicit - // override (for example sendMessageSlack receiving opts.token) can keep - // operating. - expect(resolved.accountId).toBe("default"); - expect(resolved.config.allowFrom).toEqual(["U999"]); - }); - - it("still returns resolved string credentials in tolerant mode", () => { + it("does not read credentials for disabled accounts", () => { const resolved = resolveSlackAccount({ cfg: { channels: { slack: { accounts: { - default: { botToken: "xoxb-resolved", appToken: "xapp-resolved" }, + default: { + enabled: false, + botToken: secretRef, + appToken: secretRef, + userToken: secretRef, + allowFrom: ["U999"], + }, }, }, }, - }, + } as unknown as OpenClawConfig, + accountId: "default", + }); + + expect(resolved.botToken).toBeUndefined(); + expect(resolved.botTokenSource).toBe("none"); + expect(resolved.appToken).toBeUndefined(); + expect(resolved.appTokenSource).toBe("none"); + expect(resolved.userToken).toBeUndefined(); + expect(resolved.userTokenSource).toBe("none"); + expect(resolved.accountId).toBe("default"); + expect(resolved.config.allowFrom).toEqual(["U999"]); + }); + + it("does not read socket-only app token for HTTP mode accounts", () => { + const resolved = resolveSlackAccount({ + cfg: { + channels: { + slack: { + accounts: { + default: { + mode: "http", + botToken: "xoxb-resolved", + appToken: secretRef, + signingSecret: "signing-secret", + }, + }, + }, + }, + } as unknown as OpenClawConfig, accountId: "default", - tolerateUnresolvedSecrets: true, }); expect(resolved.botToken).toBe("xoxb-resolved"); expect(resolved.botTokenSource).toBe("config"); - expect(resolved.appToken).toBe("xapp-resolved"); - expect(resolved.appTokenSource).toBe("config"); + expect(resolved.appToken).toBeUndefined(); + expect(resolved.appTokenSource).toBe("none"); }); - it("does not silently fall back to SLACK_*_TOKEN env vars in tolerant mode when all credentials are configured as SecretRef (credential confusion guard)", () => { - // Each credential is configured as a SecretRef. In tolerant mode none of - // them resolves, so per-credential env gating must block all three env - // vars; otherwise a stray `SLACK_*_TOKEN` would silently impersonate the - // operator-configured account (CWE-287 credential confusion). - const cfgAllSecretRefs = { - channels: { - slack: { - accounts: { - default: { - botToken: { source: "exec", provider: "default", id: "slack_bot_token" }, - appToken: { source: "exec", provider: "default", id: "slack_app_token" }, - userToken: { source: "exec", provider: "default", id: "slack_user_token" }, + it("throws when a socket-mode account still has an unresolved active app token SecretRef", () => { + expect(() => + resolveSlackAccount({ + cfg: { + channels: { + slack: { + accounts: { + default: { + mode: "socket", + botToken: "xoxb-resolved", + appToken: secretRef, + }, + }, }, }, - }, - }, - } as unknown as OpenClawConfig; - const previousBotToken = process.env.SLACK_BOT_TOKEN; - const previousAppToken = process.env.SLACK_APP_TOKEN; - const previousUserToken = process.env.SLACK_USER_TOKEN; - process.env.SLACK_BOT_TOKEN = "xoxb-env-fallback"; - process.env.SLACK_APP_TOKEN = "xapp-env-fallback"; - process.env.SLACK_USER_TOKEN = "xoxp-env-fallback"; - try { - const resolved = resolveSlackAccount({ - cfg: cfgAllSecretRefs, + } as unknown as OpenClawConfig, accountId: "default", - tolerateUnresolvedSecrets: true, - }); - - expect(resolved.botToken).toBeUndefined(); - expect(resolved.botTokenSource).toBe("none"); - expect(resolved.appToken).toBeUndefined(); - expect(resolved.appTokenSource).toBe("none"); - expect(resolved.userToken).toBeUndefined(); - expect(resolved.userTokenSource).toBe("none"); - } finally { - if (previousBotToken === undefined) { - delete process.env.SLACK_BOT_TOKEN; - } else { - process.env.SLACK_BOT_TOKEN = previousBotToken; - } - if (previousAppToken === undefined) { - delete process.env.SLACK_APP_TOKEN; - } else { - process.env.SLACK_APP_TOKEN = previousAppToken; - } - if (previousUserToken === undefined) { - delete process.env.SLACK_USER_TOKEN; - } else { - process.env.SLACK_USER_TOKEN = previousUserToken; - } - } + }), + ).toThrowError(/channels\.slack\.accounts\.default\.appToken/); }); - it("preserves SLACK_BOT_TOKEN env fallback in tolerant mode when no config token is set (env-only setups)", () => { + it("preserves env fallback when no active config token is set", () => { const previousBotToken = process.env.SLACK_BOT_TOKEN; const previousAppToken = process.env.SLACK_APP_TOKEN; process.env.SLACK_BOT_TOKEN = "xoxb-env-only"; @@ -252,7 +230,6 @@ describe("resolveSlackAccount tolerateUnresolvedSecrets", () => { }, }, accountId: "default", - tolerateUnresolvedSecrets: true, }); expect(resolved.botToken).toBe("xoxb-env-only"); @@ -273,36 +250,31 @@ describe("resolveSlackAccount tolerateUnresolvedSecrets", () => { } }); - it("blocks env fallback per-credential: unresolved SecretRef on botToken does not leak SLACK_APP_TOKEN", () => { + it("does not use env fallback for inactive credentials", () => { const previousBotToken = process.env.SLACK_BOT_TOKEN; const previousAppToken = process.env.SLACK_APP_TOKEN; process.env.SLACK_BOT_TOKEN = "xoxb-env-bot"; process.env.SLACK_APP_TOKEN = "xapp-env-app"; try { - // botToken has an unresolved SecretRef (env fallback should be - // blocked), but appToken is unset (env fallback should still fire). - // This proves the gating is per-credential, not whole-account. const resolved = resolveSlackAccount({ cfg: { channels: { slack: { accounts: { default: { - botToken: { source: "exec", provider: "default", id: "slack_bot_token" }, + enabled: false, }, }, }, }, - } as unknown as OpenClawConfig, + }, accountId: "default", - tolerateUnresolvedSecrets: true, }); expect(resolved.botToken).toBeUndefined(); expect(resolved.botTokenSource).toBe("none"); - // appToken was never configured → env fallback still fires. - expect(resolved.appToken).toBe("xapp-env-app"); - expect(resolved.appTokenSource).toBe("env"); + expect(resolved.appToken).toBeUndefined(); + expect(resolved.appTokenSource).toBe("none"); } finally { if (previousBotToken === undefined) { delete process.env.SLACK_BOT_TOKEN; diff --git a/extensions/slack/src/accounts.ts b/extensions/slack/src/accounts.ts index 948e547212e..d8a6eae1023 100644 --- a/extensions/slack/src/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -6,7 +6,6 @@ import { resolveMergedAccountConfig, type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; -import { isSecretRef, normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import type { SlackAccountConfig } from "./runtime-api.js"; @@ -45,28 +44,6 @@ export function mergeSlackAccountConfig( export function resolveSlackAccount(params: { cfg: OpenClawConfig; accountId?: string | null; - /** - * When true, account-level credential reads (`botToken`, `appToken`, - * `userToken`) silently become `undefined` for unresolved `SecretRef` - * inputs instead of throwing. Default is false to preserve the strict - * behavior expected by boot-time provider initialization, which must - * surface unresolved channel SecretRefs loudly. - * - * Pass `true` from call sites that already have a separately-resolved - * credential override (for example `sendMessageSlack` receives an explicit - * `opts.token` derived from the boot-time monitor context) and only need - * the rest of the account info (account id, dm policy, channel settings, - * etc.). The downstream consumer's existing `if (!token)` guard still - * surfaces a clean "missing token" error when no explicit override is - * supplied either. - * - * Without this opt-in, an inactive `channels.slack.accounts.*.botToken` - * SecretRef left in the runtime snapshot (per the inspect/strict - * separation introduced in #66818) blows up the strict resolver path even - * though the actual send already has a valid boot-resolved token. See - * #68237. - */ - tolerateUnresolvedSecrets?: boolean; }): ResolvedSlackAccount { const accountId = normalizeAccountId( params.accountId ?? resolveDefaultSlackAccountId(params.cfg), @@ -75,36 +52,26 @@ export function resolveSlackAccount(params: { const merged = mergeSlackAccountConfig(params.cfg, accountId); const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; - // Per-credential env-var fallback gating: in tolerant mode, only block - // the `SLACK_*_TOKEN` env fallback for credentials whose configured value - // is an unresolved `SecretRef` object. Otherwise (config field is a - // resolved string, or unset entirely) keep the original env fallback so - // legitimate env-only setups (no per-account config token, just - // `SLACK_BOT_TOKEN` in the process env) keep working. This avoids - // credential confusion (CWE-287) on misconfigured deployments where an - // unresolved SecretRef would otherwise be silently overridden by a stray - // env var, while preserving the env-only contract that callers like - // `extensions/slack/src/channel.ts` rely on when omitting `opts.token`. + const mode = merged.mode ?? "socket"; const baseAllowEnv = accountId === DEFAULT_ACCOUNT_ID; - const tolerantMode = params.tolerateUnresolvedSecrets === true; - const blockBotEnv = tolerantMode && isSecretRef(merged.botToken); - const blockAppEnv = tolerantMode && isSecretRef(merged.appToken); - const blockUserEnv = tolerantMode && isSecretRef(merged.userToken); + const botActive = enabled; + const appActive = enabled && mode !== "http"; + const userActive = enabled; const envBot = - baseAllowEnv && !blockBotEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined; + botActive && baseAllowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined; const envApp = - baseAllowEnv && !blockAppEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined; + appActive && baseAllowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined; const envUser = - baseAllowEnv && !blockUserEnv ? resolveSlackUserToken(process.env.SLACK_USER_TOKEN) : undefined; - const configBot = tolerantMode - ? normalizeSecretInputString(merged.botToken) - : resolveSlackBotToken(merged.botToken, `channels.slack.accounts.${accountId}.botToken`); - const configApp = tolerantMode - ? normalizeSecretInputString(merged.appToken) - : resolveSlackAppToken(merged.appToken, `channels.slack.accounts.${accountId}.appToken`); - const configUser = tolerantMode - ? normalizeSecretInputString(merged.userToken) - : resolveSlackUserToken(merged.userToken, `channels.slack.accounts.${accountId}.userToken`); + userActive && baseAllowEnv ? resolveSlackUserToken(process.env.SLACK_USER_TOKEN) : undefined; + const configBot = botActive + ? resolveSlackBotToken(merged.botToken, `channels.slack.accounts.${accountId}.botToken`) + : undefined; + const configApp = appActive + ? resolveSlackAppToken(merged.appToken, `channels.slack.accounts.${accountId}.appToken`) + : undefined; + const configUser = userActive + ? resolveSlackUserToken(merged.userToken, `channels.slack.accounts.${accountId}.userToken`) + : undefined; const botToken = configBot ?? envBot; const appToken = configApp ?? envApp; const userToken = configUser ?? envUser; diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index 7d7b7219b1f..0c12ebef143 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -50,11 +50,16 @@ describe("handleSlackAction", () => { } function expectLastSlackSend(content: string, threadTs?: string) { - expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", content, { - mediaUrl: undefined, - threadTs, - blocks: undefined, - }); + expect(sendSlackMessage).toHaveBeenLastCalledWith( + "channel:C123", + content, + expect.objectContaining({ + cfg: expect.any(Object), + mediaUrl: undefined, + threadTs, + blocks: undefined, + }), + ); } async function sendSecondMessageAndExpectNoThread(params: { @@ -119,7 +124,12 @@ describe("handleSlackAction", () => { }, slackConfig(), ); - expect(reactSlackMessage).toHaveBeenCalledWith("C1", "123.456", "✅"); + expect(reactSlackMessage).toHaveBeenCalledWith( + "C1", + "123.456", + "✅", + expect.objectContaining({ cfg: expect.any(Object) }), + ); }); it("removes reactions on empty emoji", async () => { @@ -132,7 +142,11 @@ describe("handleSlackAction", () => { }, slackConfig(), ); - expect(removeOwnSlackReactions).toHaveBeenCalledWith("C1", "123.456"); + expect(removeOwnSlackReactions).toHaveBeenCalledWith( + "C1", + "123.456", + expect.objectContaining({ cfg: expect.any(Object) }), + ); }); it("removes reactions when remove flag set", async () => { @@ -146,7 +160,12 @@ describe("handleSlackAction", () => { }, slackConfig(), ); - expect(removeSlackReaction).toHaveBeenCalledWith("C1", "123.456", "✅"); + expect(removeSlackReaction).toHaveBeenCalledWith( + "C1", + "123.456", + "✅", + expect.objectContaining({ cfg: expect.any(Object) }), + ); }); it("rejects removes without emoji", async () => { @@ -188,11 +207,16 @@ describe("handleSlackAction", () => { }, slackConfig(), ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Hello thread", { - mediaUrl: undefined, - threadTs: "1234567890.123456", - blocks: undefined, - }); + expect(sendSlackMessage).toHaveBeenCalledWith( + "channel:C123", + "Hello thread", + expect.objectContaining({ + cfg: expect.any(Object), + mediaUrl: undefined, + threadTs: "1234567890.123456", + blocks: undefined, + }), + ); }); it("returns a friendly error when downloadFile cannot fetch the attachment", async () => { @@ -289,11 +313,16 @@ describe("handleSlackAction", () => { }, slackConfig(), ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "", { - mediaUrl: undefined, - threadTs: undefined, - blocks: expectedBlocks, - }); + expect(sendSlackMessage).toHaveBeenCalledWith( + "channel:C123", + "", + expect.objectContaining({ + cfg: expect.any(Object), + mediaUrl: undefined, + threadTs: undefined, + blocks: expectedBlocks, + }), + ); }); it.each([ @@ -344,12 +373,17 @@ describe("handleSlackAction", () => { slackConfig(), ); - expect(sendSlackMessage).toHaveBeenCalledWith("user:U123", "fresh report", { - mediaUrl: "/tmp/report.png", - threadTs: "111.222", - uploadFileName: "report-final.png", - uploadTitle: "Report Final", - }); + expect(sendSlackMessage).toHaveBeenCalledWith( + "user:U123", + "fresh report", + expect.objectContaining({ + cfg: expect.any(Object), + mediaUrl: "/tmp/report.png", + threadTs: "111.222", + uploadFileName: "report-final.png", + uploadTitle: "Report Final", + }), + ); }); it("rejects blocks combined with mediaUrl", async () => { @@ -389,9 +423,15 @@ describe("handleSlackAction", () => { }, slackConfig(), ); - expect(editSlackMessage).toHaveBeenCalledWith("C123", "123.456", "", { - blocks: expectedBlocks, - }); + expect(editSlackMessage).toHaveBeenCalledWith( + "C123", + "123.456", + "", + expect.objectContaining({ + cfg: expect.any(Object), + blocks: expectedBlocks, + }), + ); }); it("requires content or blocks for editMessage", async () => { @@ -493,11 +533,16 @@ describe("handleSlackAction", () => { replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Other channel", { - mediaUrl: undefined, - threadTs: undefined, - blocks: undefined, - }); + expect(sendSlackMessage).toHaveBeenCalledWith( + "channel:C999", + "Other channel", + expect.objectContaining({ + cfg: expect.any(Object), + mediaUrl: undefined, + threadTs: undefined, + blocks: undefined, + }), + ); }); it("explicit threadTs overrides context threadTs", async () => { @@ -528,11 +573,16 @@ describe("handleSlackAction", () => { replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith("C123", "Bare target", { - mediaUrl: undefined, - threadTs: "1111111111.111111", - blocks: undefined, - }); + expect(sendSlackMessage).toHaveBeenCalledWith( + "C123", + "Bare target", + expect.objectContaining({ + cfg: expect.any(Object), + mediaUrl: undefined, + threadTs: "1111111111.111111", + blocks: undefined, + }), + ); }); it("adds normalized timestamps to readMessages payloads", async () => { @@ -568,12 +618,16 @@ describe("handleSlackAction", () => { slackConfig(), ); - expect(readSlackMessages).toHaveBeenCalledWith("C1", { - threadId: "1712345678.123456", - limit: undefined, - before: undefined, - after: undefined, - }); + expect(readSlackMessages).toHaveBeenCalledWith( + "C1", + expect.objectContaining({ + cfg: expect.any(Object), + threadId: "1712345678.123456", + limit: undefined, + before: undefined, + after: undefined, + }), + ); }); it("adds normalized timestamps to pin payloads", async () => { diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index d5189eeb046..d9931b3a002 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -172,10 +172,8 @@ export async function handleSlackAction( const buildActionOpts = (operation: "read" | "write") => { const token = getTokenForOperation(operation); const tokenOverride = token && token !== botToken ? token : undefined; - if (!accountId && !tokenOverride) { - return undefined; - } return { + cfg, ...(accountId ? { accountId } : {}), ...(tokenOverride ? { token: tokenOverride } : {}), }; diff --git a/extensions/slack/src/actions.ts b/extensions/slack/src/actions.ts index f5013bec3ce..3d6215253ad 100644 --- a/extensions/slack/src/actions.ts +++ b/extensions/slack/src/actions.ts @@ -1,5 +1,5 @@ import type { Block, KnownBlock, WebClient } from "@slack/web-api"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveSlackAccount } from "./accounts.js"; import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; @@ -11,6 +11,7 @@ import { sendMessageSlack } from "./send.js"; import { resolveSlackBotToken } from "./token.js"; export type SlackActionClientOpts = { + cfg?: OpenClawConfig; accountId?: string; token?: string; client?: WebClient; @@ -41,10 +42,21 @@ export type SlackPin = { file?: { id?: string; name?: string }; }; -function resolveToken(explicit?: string, accountId?: string) { - const cfg = loadConfig(); - const account = resolveSlackAccount({ cfg, accountId }); - const token = resolveSlackBotToken(explicit ?? account.botToken ?? undefined); +function resolveToken(explicit?: string, accountId?: string, cfg?: OpenClawConfig): string { + if (explicit?.trim()) { + const token = resolveSlackBotToken(explicit); + if (token) { + return token; + } + } + if (!cfg) { + throw new Error( + "Slack actions requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", + ); + } + const resolvedCfg = requireRuntimeConfig(cfg, "Slack actions"); + const account = resolveSlackAccount({ cfg: resolvedCfg, accountId }); + const token = resolveSlackBotToken(account.botToken ?? undefined); if (!token) { logVerbose( `slack actions: missing bot token for account=${account.accountId} explicit=${Boolean( @@ -68,7 +80,7 @@ async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write if (opts.client) { return opts.client; } - const token = resolveToken(opts.token, opts.accountId); + const token = resolveToken(opts.token, opts.accountId, opts.cfg); return mode === "write" ? createSlackWriteClient(token) : createSlackWebClient(token); } @@ -160,7 +172,8 @@ export async function listSlackReactions( export async function sendSlackMessage( to: string, content: string, - opts: SlackActionClientOpts & { + opts: Omit & { + cfg: OpenClawConfig; mediaUrl?: string; mediaAccess?: { localRoots?: readonly string[]; @@ -172,10 +185,11 @@ export async function sendSlackMessage( uploadFileName?: string; uploadTitle?: string; blocks?: (Block | KnownBlock)[]; - } = {}, + }, ) { return await sendMessageSlack(to, content, { accountId: opts.accountId, + cfg: opts.cfg, token: opts.token, mediaUrl: opts.mediaUrl, mediaAccess: opts.mediaAccess, diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 0175ba14a4e..32eb23814c2 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -216,6 +216,11 @@ describe("slackPlugin actions", () => { expect(sendMessageSlackMock).toHaveBeenCalledWith( "user:U12345678", expect.stringContaining("approved"), + expect.objectContaining({ + accountId: "work", + cfg, + token: "xoxb-work", + }), ); }); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 9bdbba86438..9846eef31d6 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -54,7 +54,7 @@ import { SLACK_TEXT_LIMIT } from "./limits.js"; import { slackOutbound } from "./outbound-adapter.js"; import type { SlackProbe } from "./probe.js"; import { resolveSlackReplyBlocks } from "./reply-blocks.js"; -import { getOptionalSlackRuntime, getSlackRuntime } from "./runtime.js"; +import { getOptionalSlackRuntime } from "./runtime.js"; import { fetchSlackScopes } from "./scopes.js"; import { slackSecurityAdapter } from "./security.js"; import { slackSetupAdapter } from "./setup-core.js"; @@ -153,9 +153,8 @@ async function resolveSlackSendContext(params: { resolveOutboundSendDep(params.deps, "slack") ?? (await loadSlackSendRuntime()).sendMessageSlack; // params.cfg is the scoped channel-dispatch config; channel credentials are - // expected to be resolved here (not a raw loadConfig() snapshot). Strict mode - // is intentional so boot-time misconfigurations surface loudly. See #68237 - // for the companion tolerant-mode path in sendMessageSlack itself. + // expected to be resolved from this snapshot. Strict mode + // is intentional so boot-time misconfigurations surface loudly. See #68237. const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); const token = getTokenForOperation(account, "write"); const botToken = account.botToken?.trim(); @@ -513,23 +512,18 @@ export const slackPlugin: ChannelPlugin = crea idLabel: "slackUserId", message: PAIRING_APPROVED_MESSAGE, normalizeAllowEntry: createPairingPrefixStripper(/^(slack|user):/i), - notify: async ({ id, message }) => { - const cfg = getSlackRuntime().config.loadConfig(); + notify: async ({ cfg, id, message }) => { const account = resolveSlackAccount({ cfg, accountId: resolveDefaultSlackAccountId(cfg), }); const { sendMessageSlack } = await loadSlackSendRuntime(); const token = getTokenForOperation(account, "write"); - const botToken = account.botToken?.trim(); - const tokenOverride = token && token !== botToken ? token : undefined; - if (tokenOverride) { - await sendMessageSlack(`user:${id}`, message, { - token: tokenOverride, - }); - } else { - await sendMessageSlack(`user:${id}`, message); - } + await sendMessageSlack(`user:${id}`, message, { + cfg, + accountId: account.accountId, + ...(token ? { token } : {}), + }); }, }, }, diff --git a/extensions/slack/src/draft-stream.test.ts b/extensions/slack/src/draft-stream.test.ts index 9ee95622b5f..55f8a237e0f 100644 --- a/extensions/slack/src/draft-stream.test.ts +++ b/extensions/slack/src/draft-stream.test.ts @@ -7,6 +7,8 @@ type DraftEditFn = NonNullable; type DraftRemoveFn = NonNullable; type DraftWarnFn = NonNullable; +const TEST_CFG = {}; + function createDraftStreamHarness( params: { maxChars?: number; @@ -27,6 +29,7 @@ function createDraftStreamHarness( const warn = params.warn ?? vi.fn(); const stream = createSlackDraftStream({ target: "channel:C123", + cfg: TEST_CFG, token: "xoxb-test", throttleMs: 250, maxChars: params.maxChars, @@ -50,6 +53,7 @@ describe("createSlackDraftStream", () => { expect(send).toHaveBeenCalledTimes(1); expect(edit).toHaveBeenCalledTimes(1); expect(edit).toHaveBeenCalledWith("C123", "111.222", "hello world", { + cfg: TEST_CFG, token: "xoxb-test", accountId: undefined, }); diff --git a/extensions/slack/src/draft-stream.ts b/extensions/slack/src/draft-stream.ts index f52ef14169a..52915a9e585 100644 --- a/extensions/slack/src/draft-stream.ts +++ b/extensions/slack/src/draft-stream.ts @@ -1,4 +1,5 @@ import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { deleteSlackMessage, editSlackMessage } from "./actions.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; @@ -20,6 +21,7 @@ export type SlackDraftStream = { export function createSlackDraftStream(params: { target: string; + cfg: OpenClawConfig; token: string; accountId?: string; maxChars?: number; @@ -63,12 +65,14 @@ export function createSlackDraftStream(params: { try { if (streamChannelId && streamMessageId) { await edit(streamChannelId, streamMessageId, trimmed, { + cfg: params.cfg, token: params.token, accountId: params.accountId, }); return; } const sent = await send(params.target, trimmed, { + cfg: params.cfg, token: params.token, accountId: params.accountId, threadTs: params.resolveThreadTs?.(), diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 5bd5afa6525..715e2bd7b4c 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -443,6 +443,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag return; } await deliverReplies({ + cfg: ctx.cfg, replies: [params.payload], target: prepared.replyTarget, token: ctx.botToken, @@ -677,6 +678,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const draftStream = shouldUseDraftStream ? createSlackDraftStream({ target: prepared.replyTarget, + cfg, token: ctx.botToken, accountId: account.accountId, maxChars: Math.min(ctx.textLimit, SLACK_TEXT_LIMIT), diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 334115aff43..bfc20340413 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -235,6 +235,7 @@ async function authorizeSlackInboundMessage(params: { resolveSenderName: ctx.resolveUserName, sendPairingReply: async (text) => { await sendMessageSlack(message.channel, text, { + cfg: ctx.cfg, token: ctx.botToken, client: ctx.app.client, accountId: account.accountId, diff --git a/extensions/slack/src/monitor/replies.test.ts b/extensions/slack/src/monitor/replies.test.ts index 7d3d877c427..ce3430a82e1 100644 --- a/extensions/slack/src/monitor/replies.test.ts +++ b/extensions/slack/src/monitor/replies.test.ts @@ -10,8 +10,11 @@ let createSlackReplyDeliveryPlan: typeof import("./replies.js").createSlackReply let resolveSlackThreadTs: typeof import("./replies.js").resolveSlackThreadTs; import { deliverSlackSlashReplies } from "./replies.js"; +const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } }; + function baseParams(overrides?: Record) { return { + cfg: SLACK_TEST_CFG, replies: [{ text: "hello" }], target: "C123", token: "xoxb-test", diff --git a/extensions/slack/src/monitor/replies.ts b/extensions/slack/src/monitor/replies.ts index 8ed01a76cf8..c1fca8fd9ab 100644 --- a/extensions/slack/src/monitor/replies.ts +++ b/extensions/slack/src/monitor/replies.ts @@ -1,4 +1,4 @@ -import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { deliverTextOrMediaReply, resolveSendableOutboundReplyParts, @@ -22,6 +22,7 @@ export function readSlackReplyBlocks(payload: ReplyPayload) { } export async function deliverReplies(params: { + cfg: OpenClawConfig; replies: ReplyPayload[]; target: string; token: string; @@ -52,6 +53,7 @@ export async function deliverReplies(params: { continue; } await sendMessageSlack(params.target, trimmed, { + cfg: params.cfg, token: params.token, threadTs, accountId: params.accountId, @@ -76,6 +78,7 @@ export async function deliverReplies(params: { : undefined, sendText: async (trimmed) => { await sendMessageSlack(params.target, trimmed, { + cfg: params.cfg, token: params.token, threadTs, accountId: params.accountId, @@ -84,6 +87,7 @@ export async function deliverReplies(params: { }, sendMedia: async ({ mediaUrl, caption }) => { await sendMessageSlack(params.target, caption ?? "", { + cfg: params.cfg, token: params.token, mediaUrl, threadTs, diff --git a/extensions/slack/src/send.blocks.test.ts b/extensions/slack/src/send.blocks.test.ts index 3f256691654..d95406ea13b 100644 --- a/extensions/slack/src/send.blocks.test.ts +++ b/extensions/slack/src/send.blocks.test.ts @@ -3,12 +3,14 @@ import { createSlackSendTestClient, installSlackBlockTestMocks } from "./blocks. installSlackBlockTestMocks(); const { sendMessageSlack } = await import("./send.js"); +const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } }; describe("sendMessageSlack NO_REPLY guard", () => { it("suppresses NO_REPLY text before any Slack API call", async () => { const client = createSlackSendTestClient(); const result = await sendMessageSlack("channel:C123", "NO_REPLY", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, }); @@ -20,6 +22,7 @@ describe("sendMessageSlack NO_REPLY guard", () => { const client = createSlackSendTestClient(); const result = await sendMessageSlack("channel:C123", " NO_REPLY ", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, }); @@ -31,6 +34,7 @@ describe("sendMessageSlack NO_REPLY guard", () => { const client = createSlackSendTestClient(); await sendMessageSlack("channel:C123", "This is not a NO_REPLY situation", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, }); @@ -41,6 +45,7 @@ describe("sendMessageSlack NO_REPLY guard", () => { const client = createSlackSendTestClient(); const result = await sendMessageSlack("channel:C123", "NO_REPLY", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, blocks: [{ type: "section", text: { type: "mrkdwn", text: "content" } }], }); @@ -57,6 +62,7 @@ describe("sendMessageSlack chunking", () => { await sendMessageSlack("channel:C123", message, { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, }); @@ -75,6 +81,7 @@ describe("sendMessageSlack blocks", () => { const client = createSlackSendTestClient(); const result = await sendMessageSlack("channel:C123", "", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, blocks: [{ type: "divider" }], }); @@ -94,6 +101,7 @@ describe("sendMessageSlack blocks", () => { const client = createSlackSendTestClient(); await sendMessageSlack("channel:C123", "", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, blocks: [{ type: "image", image_url: "https://example.com/a.png", alt_text: "Build chart" }], }); @@ -109,6 +117,7 @@ describe("sendMessageSlack blocks", () => { const client = createSlackSendTestClient(); await sendMessageSlack("channel:C123", "", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, blocks: [ { @@ -132,6 +141,7 @@ describe("sendMessageSlack blocks", () => { const client = createSlackSendTestClient(); await sendMessageSlack("channel:C123", "", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, blocks: [{ type: "file", source: "remote", external_id: "F123" }], }); @@ -148,6 +158,7 @@ describe("sendMessageSlack blocks", () => { await expect( sendMessageSlack("channel:C123", "hi", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, mediaUrl: "https://example.com/image.png", blocks: [{ type: "divider" }], @@ -161,6 +172,7 @@ describe("sendMessageSlack blocks", () => { await expect( sendMessageSlack("channel:C123", "hi", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, blocks: [], }), @@ -174,6 +186,7 @@ describe("sendMessageSlack blocks", () => { await expect( sendMessageSlack("channel:C123", "hi", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, blocks, }), @@ -186,6 +199,7 @@ describe("sendMessageSlack blocks", () => { await expect( sendMessageSlack("channel:C123", "hi", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, blocks: [{} as { type: string }], }), diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index 988e95ccda3..d338def34d9 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -1,5 +1,5 @@ import { type Block, type KnownBlock, type WebClient } from "@slack/web-api"; -import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { withTrustedEnvProxyGuardedFetchMode } from "openclaw/plugin-sdk/fetch-runtime"; import { @@ -49,7 +49,7 @@ export type SlackSendIdentity = { }; type SlackSendOpts = { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; token?: string; accountId?: string; mediaUrl?: string; @@ -310,7 +310,7 @@ async function uploadSlackFile(params: { export async function sendMessageSlack( to: string, message: string, - opts: SlackSendOpts = {}, + opts: SlackSendOpts, ): Promise { const trimmedMessage = normalizeOptionalString(message) ?? ""; if (isSilentReplyText(trimmedMessage) && !opts.mediaUrl && !opts.blocks) { @@ -321,21 +321,10 @@ export async function sendMessageSlack( if (!trimmedMessage && !opts.mediaUrl && !blocks) { throw new Error("Slack send requires text, blocks, or media"); } - const cfg = opts.cfg ?? loadConfig(); - // Tolerate unresolved channel SecretRefs in the cfg snapshot here: the - // send path either receives an explicit `opts.token` (resolved at Slack - // monitor boot time and threaded through `ctx.botToken`) or surfaces the - // existing "Slack bot token missing" error via `resolveToken` below. The - // runtime snapshot can legitimately retain unresolved `channels.slack.*` - // SecretRefs (see the inspect/strict separation introduced in #66818) when - // the active account's secrets were not part of the agent-runtime base - // target set; failing the strict resolver here would block outbound - // replies even though `reactions.add` and inbound dispatch (which use the - // boot-resolved client/token directly) keep working. See #68237. + const cfg = requireRuntimeConfig(opts.cfg, "Slack send"); const account = resolveSlackAccount({ cfg, accountId: opts.accountId, - tolerateUnresolvedSecrets: true, }); const token = resolveToken({ explicit: opts.token, diff --git a/extensions/slack/src/send.upload.test.ts b/extensions/slack/src/send.upload.test.ts index fe309e39502..1b502fc5c46 100644 --- a/extensions/slack/src/send.upload.test.ts +++ b/extensions/slack/src/send.upload.test.ts @@ -44,6 +44,7 @@ vi.mock("./runtime-api.js", async () => { let sendMessageSlack: typeof import("./send.js").sendMessageSlack; let clearSlackDmChannelCache: typeof import("./send.js").clearSlackDmChannelCache; ({ sendMessageSlack, clearSlackDmChannelCache } = await import("./send.js")); +const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } }; type UploadTestClient = WebClient & { conversations: { open: ReturnType }; @@ -96,6 +97,7 @@ describe("sendMessageSlack file upload with user IDs", () => { // Bare user ID — parseSlackTarget classifies this as kind="channel" await sendMessageSlack("U2ZH3MFSR", "screenshot", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, mediaUrl: "/tmp/screenshot.png", }); @@ -118,6 +120,7 @@ describe("sendMessageSlack file upload with user IDs", () => { await sendMessageSlack("user:UABC123", "image", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, mediaUrl: "/tmp/photo.png", }); @@ -135,10 +138,12 @@ describe("sendMessageSlack file upload with user IDs", () => { await sendMessageSlack("user:UABC123", "first", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, }); await sendMessageSlack("user:UABC123", "second", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, }); @@ -158,10 +163,12 @@ describe("sendMessageSlack file upload with user IDs", () => { await sendMessageSlack("user:UABC123", "first", { token: "xoxb-test-a", + cfg: SLACK_TEST_CFG, client, }); await sendMessageSlack("user:UABC123", "second", { token: "xoxb-test-b", + cfg: SLACK_TEST_CFG, client, }); @@ -173,6 +180,7 @@ describe("sendMessageSlack file upload with user IDs", () => { await sendMessageSlack("channel:C123CHAN", "chart", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, mediaUrl: "/tmp/chart.png", }); @@ -188,6 +196,7 @@ describe("sendMessageSlack file upload with user IDs", () => { await sendMessageSlack("<@U777TEST>", "report", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, mediaUrl: "/tmp/report.png", }); @@ -205,6 +214,7 @@ describe("sendMessageSlack file upload with user IDs", () => { await sendMessageSlack("channel:C123CHAN", "caption", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, mediaUrl: "/tmp/threaded.png", threadTs: "171.222", @@ -241,6 +251,7 @@ describe("sendMessageSlack file upload with user IDs", () => { await sendMessageSlack("channel:C123CHAN", "caption", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, mediaUrl: "/tmp/threaded.png", uploadFileName: "custom-name.bin", @@ -263,6 +274,7 @@ describe("sendMessageSlack file upload with user IDs", () => { await sendMessageSlack("channel:C123CHAN", "caption", { token: "xoxb-test", + cfg: SLACK_TEST_CFG, client, mediaUrl: "/tmp/threaded.png", uploadFileName: "custom-name.bin", diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 05512a3441d..65c60cd77fe 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -810,7 +810,7 @@ export const registerTelegramHandlers = ({ if (user?.is_bot) { return; } - if (reactionMode === "own" && !telegramDeps.wasSentByBot(chatId, messageId)) { + if (reactionMode === "own" && !telegramDeps.wasSentByBot(chatId, messageId, cfg)) { logVerbose( `telegram: skipped reaction on msg ${messageId} in chat ${chatId} (own mode, not sent by bot)`, ); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 318573d4596..a0f94f37cc4 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1136,7 +1136,7 @@ export const registerTelegramNativeCommands = ({ linkPreview: runtimeTelegramCfg.linkPreview, buttons: telegramResultData?.buttons, }); - recordSentMessage(chatId, progressMessageId); + recordSentMessage(chatId, progressMessageId, runtimeCfg); emitTelegramMessageSentHooks({ sessionKeyForInternalHooks: route.sessionKey, chatId: String(chatId), diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts index 773f39e647b..9f9e3313321 100644 --- a/extensions/telegram/src/bot.ts +++ b/extensions/telegram/src/bot.ts @@ -131,6 +131,7 @@ export function createTelegramBot(opts: TelegramBotOptions): TelegramBotInstance }); const threadBindingManager = threadBindingPolicy.enabled ? createTelegramThreadBindingManager({ + cfg, accountId: account.accountId, idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({ cfg, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index e7eff8477bf..40ad98b10f9 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -640,8 +640,9 @@ export const telegramPlugin = createChatChannelPlugin({ : null; }, shouldStripThreadFromAnnounceOrigin: shouldStripTelegramThreadFromAnnounceOrigin, - createManager: ({ accountId }) => + createManager: ({ cfg, accountId }) => createTelegramThreadBindingManager({ + cfg, accountId: accountId ?? undefined, persist: false, enableSweeper: false, @@ -982,7 +983,7 @@ export const telegramPlugin = createChatChannelPlugin({ throw new Error("telegram token not configured"); } const send = await resolveTelegramSend(); - await send(id, message, { token, accountId }); + await send(id, message, { cfg, token, accountId }); }, }, }, diff --git a/extensions/telegram/src/send.proxy.test.ts b/extensions/telegram/src/send.proxy.test.ts index 26c01ab1f41..e85d2b632c6 100644 --- a/extensions/telegram/src/send.proxy.test.ts +++ b/extensions/telegram/src/send.proxy.test.ts @@ -72,6 +72,9 @@ let sendMessageTelegram: typeof import("./send.js").sendMessageTelegram; describe("telegram proxy client", () => { const proxyUrl = "http://proxy.test:8080"; + const TELEGRAM_PROXY_CFG = { + channels: { telegram: { accounts: { foo: { proxy: proxyUrl } } } }, + }; const prepareProxyFetch = () => { const proxyFetch = vi.fn(); @@ -108,9 +111,7 @@ describe("telegram proxy client", () => { botApi.setMessageReaction.mockResolvedValue(undefined); botApi.deleteMessage.mockResolvedValue(true); botCtorSpy.mockClear(); - loadConfig.mockReturnValue({ - channels: { telegram: { accounts: { foo: { proxy: proxyUrl } } } }, - }); + loadConfig.mockReturnValue(TELEGRAM_PROXY_CFG); makeProxyFetch.mockClear(); resolveTelegramFetch.mockClear(); }); @@ -120,8 +121,16 @@ describe("telegram proxy client", () => { vi.stubEnv("VITEST", ""); vi.stubEnv("NODE_ENV", "production"); - await sendMessageTelegram("123", "first", { token: "tok", accountId: "foo" }); - await sendMessageTelegram("123", "second", { token: "tok", accountId: "foo" }); + await sendMessageTelegram("123", "first", { + cfg: TELEGRAM_PROXY_CFG, + token: "tok", + accountId: "foo", + }); + await sendMessageTelegram("123", "second", { + cfg: TELEGRAM_PROXY_CFG, + token: "tok", + accountId: "foo", + }); expect(makeProxyFetch).toHaveBeenCalledTimes(1); expect(resolveTelegramFetch).toHaveBeenCalledTimes(1); @@ -145,15 +154,30 @@ describe("telegram proxy client", () => { it.each([ { name: "sendMessage", - run: () => sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" }), + run: () => + sendMessageTelegram("123", "hi", { + cfg: TELEGRAM_PROXY_CFG, + token: "tok", + accountId: "foo", + }), }, { name: "reactions", - run: () => reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" }), + run: () => + reactMessageTelegram("123", "456", "✅", { + cfg: TELEGRAM_PROXY_CFG, + token: "tok", + accountId: "foo", + }), }, { name: "deleteMessage", - run: () => deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" }), + run: () => + deleteMessageTelegram("123", "456", { + cfg: TELEGRAM_PROXY_CFG, + token: "tok", + accountId: "foo", + }), }, ])("uses proxy fetch for $name", async (testCase) => { const { fetchImpl } = prepareProxyFetch(); diff --git a/extensions/telegram/src/send.runtime.ts b/extensions/telegram/src/send.runtime.ts index e91c73edec0..b1fc9164f46 100644 --- a/extensions/telegram/src/send.runtime.ts +++ b/extensions/telegram/src/send.runtime.ts @@ -1,4 +1,5 @@ -export { loadConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +export { requireRuntimeConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; export type { PollInput, MediaKind } from "openclaw/plugin-sdk/media-runtime"; export { buildOutboundMediaLoadOptions, diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index 03ac546f6a2..2ce554ee52e 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -135,6 +135,16 @@ vi.mock("undici", () => ({ setGlobalDispatcher: undiciSetGlobalDispatcher, })); +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); + return { + ...actual, + requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfig()), + }; +}); + vi.mock("./send.runtime.js", () => ({ buildOutboundMediaLoadOptions, getImageMetadata: vi.fn(async () => ({ ...imageMetadata })), @@ -143,6 +153,7 @@ vi.mock("./send.runtime.js", () => ({ loadConfig, loadWebMedia, normalizePollInput, + requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfig()), resolveMarkdownTableMode, resolveStorePath, })); diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index e0a15d16989..e654fc70774 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -29,11 +29,12 @@ import { getImageMetadata, isGifMedia, kindFromMime, - loadConfig, loadWebMedia, type MediaKind, normalizePollInput, + type OpenClawConfig, type PollInput, + requireRuntimeConfig, resolveMarkdownTableMode, } from "./send.runtime.js"; import { recordSentMessage } from "./sent-message-cache.js"; @@ -71,7 +72,7 @@ const MAX_TELEGRAM_PHOTO_DIMENSION_SUM = 10_000; const MAX_TELEGRAM_PHOTO_ASPECT_RATIO = 20; type TelegramSendOpts = { - cfg?: ReturnType; + cfg: OpenClawConfig; token?: string; accountId?: string; verbose?: boolean; @@ -113,7 +114,7 @@ type TelegramMessageLike = { }; type TelegramReactionOpts = { - cfg?: ReturnType; + cfg: OpenClawConfig; token?: string; accountId?: string; api?: TelegramApiOverride; @@ -123,7 +124,7 @@ type TelegramReactionOpts = { }; type TelegramTypingOpts = { - cfg?: ReturnType; + cfg: OpenClawConfig; token?: string; accountId?: string; verbose?: boolean; @@ -198,7 +199,7 @@ export function resetTelegramClientOptionsCacheForTests(): void { telegramClientOptionsCache.clear(); } -function createTelegramHttpLogger(cfg: ReturnType) { +function createTelegramHttpLogger(cfg: OpenClawConfig) { const enabled = isDiagnosticFlagEnabled("telegram.http", cfg); if (!enabled) { return () => {}; @@ -330,7 +331,7 @@ async function resolveChatId( } async function resolveAndPersistChatId(params: { - cfg: ReturnType; + cfg: OpenClawConfig; api: TelegramApiOverride; lookupTarget: string; persistTarget: string; @@ -460,7 +461,7 @@ async function withTelegramHtmlParseFallback(params: { } type TelegramApiContext = { - cfg: ReturnType; + cfg: OpenClawConfig; account: ResolvedTelegramAccount; api: TelegramApi; }; @@ -469,9 +470,9 @@ function resolveTelegramApiContext(opts: { token?: string; accountId?: string; api?: TelegramApiOverride; - cfg?: ReturnType; + cfg: OpenClawConfig; }): TelegramApiContext { - const cfg = opts.cfg ?? loadConfig(); + const cfg = requireRuntimeConfig(opts.cfg, "Telegram API context"); const account = resolveTelegramAccount({ cfg, accountId: opts.accountId, @@ -489,7 +490,7 @@ type TelegramRequestWithDiag = ( ) => Promise; function createTelegramRequestWithDiag(params: { - cfg: ReturnType; + cfg: OpenClawConfig; account: ResolvedTelegramAccount; retry?: RetryConfig; verbose?: boolean; @@ -596,7 +597,7 @@ function createRequestWithChatNotFound(params: { } function createTelegramNonIdempotentRequestWithDiag(params: { - cfg: ReturnType; + cfg: OpenClawConfig; account: ResolvedTelegramAccount; retry?: RetryConfig; verbose?: boolean; @@ -616,7 +617,7 @@ function createTelegramNonIdempotentRequestWithDiag(params: { export async function sendMessageTelegram( to: string, text: string, - opts: TelegramSendOpts = {}, + opts: TelegramSendOpts, ): Promise { const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); @@ -740,7 +741,7 @@ export async function sendMessageTelegram( } const res = await sendTelegramTextChunk(chunk, buildTextParams(index === chunks.length - 1)); const messageId = resolveTelegramMessageIdOrThrow(res, context); - recordSentMessage(chatId, messageId); + recordSentMessage(chatId, messageId, cfg); lastMessageId = String(messageId); lastChatId = String(res?.chat?.id ?? chatId); } @@ -966,7 +967,7 @@ export async function sendMessageTelegram( const result = await sendMedia(mediaSender.label, mediaSender.sender); const mediaMessageId = resolveTelegramMessageIdOrThrow(result, "media send"); const resolvedChatId = String(result?.chat?.id ?? chatId); - recordSentMessage(chatId, mediaMessageId); + recordSentMessage(chatId, mediaMessageId, cfg); recordChannelActivity({ channel: "telegram", accountId: account.accountId, @@ -1012,7 +1013,7 @@ export async function sendMessageTelegram( export async function sendTypingTelegram( to: string, - opts: TelegramTypingOpts = {}, + opts: TelegramTypingOpts, ): Promise<{ ok: true }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); @@ -1047,7 +1048,7 @@ export async function reactMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, emoji: string, - opts: TelegramReactionOpts = {}, + opts: TelegramReactionOpts, ): Promise<{ ok: true } | { ok: false; warning: string }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const rawTarget = String(chatIdInput); @@ -1090,7 +1091,7 @@ export async function reactMessageTelegram( } type TelegramDeleteOpts = { - cfg?: ReturnType; + cfg: OpenClawConfig; token?: string; accountId?: string; notify?: boolean; @@ -1102,7 +1103,7 @@ type TelegramDeleteOpts = { export async function deleteMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, - opts: TelegramDeleteOpts = {}, + opts: TelegramDeleteOpts, ): Promise<{ ok: true }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const rawTarget = String(chatIdInput); @@ -1129,7 +1130,7 @@ export async function deleteMessageTelegram( export async function pinMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, - opts: TelegramDeleteOpts = {}, + opts: TelegramDeleteOpts, ): Promise<{ ok: true; messageId: string; chatId: string }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const rawTarget = String(chatIdInput); @@ -1160,8 +1161,8 @@ export async function pinMessageTelegram( export async function unpinMessageTelegram( chatIdInput: string | number, - messageIdInput?: string | number, - opts: TelegramDeleteOpts = {}, + messageIdInput: string | number | undefined, + opts: TelegramDeleteOpts, ): Promise<{ ok: true; chatId: string; messageId?: string }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const rawTarget = String(chatIdInput); @@ -1198,7 +1199,7 @@ type TelegramEditForumTopicOpts = TelegramDeleteOpts & { export async function editForumTopicTelegram( chatIdInput: string | number, messageThreadIdInput: string | number, - opts: TelegramEditForumTopicOpts = {}, + opts: TelegramEditForumTopicOpts, ): Promise<{ ok: true; chatId: string; @@ -1262,7 +1263,7 @@ export async function renameForumTopicTelegram( chatIdInput: string | number, messageThreadIdInput: string | number, name: string, - opts: TelegramDeleteOpts = {}, + opts: TelegramDeleteOpts, ): Promise<{ ok: true; chatId: string; messageThreadId: number; name: string }> { const result = await editForumTopicTelegram(chatIdInput, messageThreadIdInput, { ...opts, @@ -1287,8 +1288,8 @@ type TelegramEditOpts = { linkPreview?: boolean; /** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */ buttons?: TelegramInlineButtons; - /** Optional config injection to avoid global loadConfig() (improves testability). */ - cfg?: ReturnType; + /** Resolved runtime config from the command or gateway boundary. */ + cfg: OpenClawConfig; }; type TelegramEditReplyMarkupOpts = { @@ -1299,15 +1300,15 @@ type TelegramEditReplyMarkupOpts = { retry?: RetryConfig; /** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */ buttons?: TelegramInlineButtons; - /** Optional config injection to avoid global loadConfig() (improves testability). */ - cfg?: ReturnType; + /** Resolved runtime config from the command or gateway boundary. */ + cfg: OpenClawConfig; }; export async function editMessageReplyMarkupTelegram( chatIdInput: string | number, messageIdInput: string | number, buttons: TelegramInlineButtons, - opts: TelegramEditReplyMarkupOpts = {}, + opts: TelegramEditReplyMarkupOpts, ): Promise<{ ok: true; messageId: string; chatId: string }> { const { cfg, account, api } = resolveTelegramApiContext({ ...opts, @@ -1350,7 +1351,7 @@ export async function editMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, text: string, - opts: TelegramEditOpts = {}, + opts: TelegramEditOpts, ): Promise<{ ok: true; messageId: string; chatId: string }> { const { cfg, account, api } = resolveTelegramApiContext({ ...opts, @@ -1459,7 +1460,7 @@ function inferFilename(kind: MediaKind) { } type TelegramStickerOpts = { - cfg?: ReturnType; + cfg: OpenClawConfig; token?: string; accountId?: string; verbose?: boolean; @@ -1480,7 +1481,7 @@ type TelegramStickerOpts = { export async function sendStickerTelegram( to: string, fileId: string, - opts: TelegramStickerOpts = {}, + opts: TelegramStickerOpts, ): Promise { if (!fileId?.trim()) { throw new Error("Telegram sticker file_id is required"); @@ -1529,7 +1530,7 @@ export async function sendStickerTelegram( const messageId = resolveTelegramMessageIdOrThrow(result, "sticker send"); const resolvedChatId = String(result?.chat?.id ?? chatId); - recordSentMessage(chatId, messageId); + recordSentMessage(chatId, messageId, opts.cfg); recordChannelActivity({ channel: "telegram", accountId: account.accountId, @@ -1540,7 +1541,7 @@ export async function sendStickerTelegram( } type TelegramPollOpts = { - cfg?: ReturnType; + cfg: OpenClawConfig; token?: string; accountId?: string; verbose?: boolean; @@ -1566,7 +1567,7 @@ type TelegramPollOpts = { export async function sendPollTelegram( to: string, poll: PollInput, - opts: TelegramPollOpts = {}, + opts: TelegramPollOpts, ): Promise<{ messageId: string; chatId: string; pollId?: string }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); @@ -1638,7 +1639,7 @@ export async function sendPollTelegram( const messageId = resolveTelegramMessageIdOrThrow(result, "poll send"); const resolvedChatId = String(result?.chat?.id ?? chatId); const pollId = result?.poll?.id; - recordSentMessage(chatId, messageId); + recordSentMessage(chatId, messageId, opts.cfg); recordChannelActivity({ channel: "telegram", @@ -1654,7 +1655,7 @@ export async function sendPollTelegram( // --------------------------------------------------------------------------- type TelegramCreateForumTopicOpts = { - cfg?: ReturnType; + cfg: OpenClawConfig; token?: string; accountId?: string; api?: TelegramApiOverride; @@ -1683,7 +1684,7 @@ export type TelegramCreateForumTopicResult = { export async function createForumTopicTelegram( chatId: string, name: string, - opts: TelegramCreateForumTopicOpts = {}, + opts: TelegramCreateForumTopicOpts, ): Promise { if (!name?.trim()) { throw new Error("Forum topic name is required"); diff --git a/extensions/telegram/src/thread-bindings.test.ts b/extensions/telegram/src/thread-bindings.test.ts index b18059da371..b7e4e5904cc 100644 --- a/extensions/telegram/src/thread-bindings.test.ts +++ b/extensions/telegram/src/thread-bindings.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -33,11 +34,32 @@ vi.mock("openclaw/plugin-sdk/json-store", async () => { import { __testing, - createTelegramThreadBindingManager, + createTelegramThreadBindingManager as createTelegramThreadBindingManagerImpl, setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, } from "./thread-bindings.js"; +const TELEGRAM_THREAD_BINDINGS_TEST_CFG = { + channels: { + telegram: { + token: "test-token", + }, + }, +} as OpenClawConfig; + +type TelegramThreadBindingManagerParams = Parameters< + typeof createTelegramThreadBindingManagerImpl +>[0]; + +function createTelegramThreadBindingManager( + params: Omit, +) { + return createTelegramThreadBindingManagerImpl({ + cfg: TELEGRAM_THREAD_BINDINGS_TEST_CFG, + ...params, + }); +} + async function flushMicrotasks(): Promise { await Promise.resolve(); await new Promise((resolve) => queueMicrotask(resolve)); @@ -156,11 +178,13 @@ describe("telegram thread bindings", () => { try { const managerA = bindingsA.createTelegramThreadBindingManager({ + cfg: TELEGRAM_THREAD_BINDINGS_TEST_CFG, accountId: "shared-runtime", persist: false, enableSweeper: false, }); const managerB = bindingsB.createTelegramThreadBindingManager({ + cfg: TELEGRAM_THREAD_BINDINGS_TEST_CFG, accountId: "shared-runtime", persist: false, enableSweeper: false, diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts index e789cdd169b..c03db717d9f 100644 --- a/extensions/telegram/src/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { readAcpSessionEntry } from "openclaw/plugin-sdk/acp-runtime"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { formatThreadBindingDurationLabel, registerSessionBindingAdapter, @@ -407,15 +407,14 @@ function shouldExpireByMaxAge(params: { return params.now >= params.record.boundAt + maxAgeMs; } -export function createTelegramThreadBindingManager( - params: { - accountId?: string; - persist?: boolean; - idleTimeoutMs?: number; - maxAgeMs?: number; - enableSweeper?: boolean; - } = {}, -): TelegramThreadBindingManager { +export function createTelegramThreadBindingManager(params: { + cfg: OpenClawConfig; + accountId?: string; + persist?: boolean; + idleTimeoutMs?: number; + maxAgeMs?: number; + enableSweeper?: boolean; +}): TelegramThreadBindingManager { const accountId = normalizeAccountId(params.accountId); const existing = getThreadBindingsState().managersByAccountId.get(accountId); if (existing) { @@ -629,7 +628,6 @@ export function createTelegramThreadBindingManager( if (placement === "child") { const rawConversationId = input.conversation.conversationId?.trim() ?? ""; const rawParent = input.conversation.parentConversationId?.trim() ?? ""; - const cfg = loadConfig(); let chatId = rawParent || rawConversationId; if (!chatId) { logVerbose( @@ -648,13 +646,13 @@ export function createTelegramThreadBindingManager( (normalizeOptionalString(metadata.label) ?? "") || `Agent: ${targetSessionKey.split(":").pop()}`; try { - const tokenResolution = resolveTelegramToken(cfg, { accountId }); + const tokenResolution = resolveTelegramToken(params.cfg, { accountId }); if (!tokenResolution.token) { return null; } const { createForumTopicTelegram } = await loadTelegramSendModule(); const result = await createForumTopicTelegram(chatId, threadName, { - cfg, + cfg: params.cfg, token: tokenResolution.token, accountId, }); diff --git a/extensions/whatsapp/src/action-runtime.test.ts b/extensions/whatsapp/src/action-runtime.test.ts index 4e5dc9d5ec8..a741cb91391 100644 --- a/extensions/whatsapp/src/action-runtime.test.ts +++ b/extensions/whatsapp/src/action-runtime.test.ts @@ -34,12 +34,17 @@ describe("handleWhatsAppAction", () => { }, enabledConfig, ); - expect(sendReactionWhatsApp).toHaveBeenLastCalledWith("+123", "msg1", "✅", { - verbose: false, - fromMe: undefined, - participant: undefined, - accountId: DEFAULT_ACCOUNT_ID, - }); + expect(sendReactionWhatsApp).toHaveBeenLastCalledWith( + "+123", + "msg1", + "✅", + expect.objectContaining({ + verbose: false, + fromMe: undefined, + participant: undefined, + accountId: DEFAULT_ACCOUNT_ID, + }), + ); }); it("adds reactions when reactionLevel is minimal", async () => { @@ -52,12 +57,17 @@ describe("handleWhatsAppAction", () => { }, reactionConfig("minimal"), ); - expect(sendReactionWhatsApp).toHaveBeenLastCalledWith("+123", "msg1", "✅", { - verbose: false, - fromMe: undefined, - participant: undefined, - accountId: DEFAULT_ACCOUNT_ID, - }); + expect(sendReactionWhatsApp).toHaveBeenLastCalledWith( + "+123", + "msg1", + "✅", + expect.objectContaining({ + verbose: false, + fromMe: undefined, + participant: undefined, + accountId: DEFAULT_ACCOUNT_ID, + }), + ); }); it("adds reactions when reactionLevel is extensive", async () => { @@ -70,12 +80,17 @@ describe("handleWhatsAppAction", () => { }, reactionConfig("extensive"), ); - expect(sendReactionWhatsApp).toHaveBeenLastCalledWith("+123", "msg1", "✅", { - verbose: false, - fromMe: undefined, - participant: undefined, - accountId: DEFAULT_ACCOUNT_ID, - }); + expect(sendReactionWhatsApp).toHaveBeenLastCalledWith( + "+123", + "msg1", + "✅", + expect.objectContaining({ + verbose: false, + fromMe: undefined, + participant: undefined, + accountId: DEFAULT_ACCOUNT_ID, + }), + ); }); it("removes reactions on empty emoji", async () => { @@ -88,12 +103,17 @@ describe("handleWhatsAppAction", () => { }, enabledConfig, ); - expect(sendReactionWhatsApp).toHaveBeenLastCalledWith("+123", "msg1", "", { - verbose: false, - fromMe: undefined, - participant: undefined, - accountId: DEFAULT_ACCOUNT_ID, - }); + expect(sendReactionWhatsApp).toHaveBeenLastCalledWith( + "+123", + "msg1", + "", + expect.objectContaining({ + verbose: false, + fromMe: undefined, + participant: undefined, + accountId: DEFAULT_ACCOUNT_ID, + }), + ); }); it("removes reactions when remove flag set", async () => { @@ -107,12 +127,17 @@ describe("handleWhatsAppAction", () => { }, enabledConfig, ); - expect(sendReactionWhatsApp).toHaveBeenLastCalledWith("+123", "msg1", "", { - verbose: false, - fromMe: undefined, - participant: undefined, - accountId: DEFAULT_ACCOUNT_ID, - }); + expect(sendReactionWhatsApp).toHaveBeenLastCalledWith( + "+123", + "msg1", + "", + expect.objectContaining({ + verbose: false, + fromMe: undefined, + participant: undefined, + accountId: DEFAULT_ACCOUNT_ID, + }), + ); }); it("passes account scope and sender flags", async () => { @@ -128,12 +153,17 @@ describe("handleWhatsAppAction", () => { }, enabledConfig, ); - expect(sendReactionWhatsApp).toHaveBeenLastCalledWith("+123", "msg1", "🎉", { - verbose: false, - fromMe: true, - participant: "999@s.whatsapp.net", - accountId: "work", - }); + expect(sendReactionWhatsApp).toHaveBeenLastCalledWith( + "+123", + "msg1", + "🎉", + expect.objectContaining({ + verbose: false, + fromMe: true, + participant: "999@s.whatsapp.net", + accountId: "work", + }), + ); }); it("preserves LID participant ids when forwarding reactions", async () => { @@ -147,12 +177,17 @@ describe("handleWhatsAppAction", () => { }, enabledConfig, ); - expect(sendReactionWhatsApp).toHaveBeenLastCalledWith("12345@g.us", "msg1", "🎉", { - verbose: false, - fromMe: undefined, - participant: "123@lid", - accountId: DEFAULT_ACCOUNT_ID, - }); + expect(sendReactionWhatsApp).toHaveBeenLastCalledWith( + "12345@g.us", + "msg1", + "🎉", + expect.objectContaining({ + verbose: false, + fromMe: undefined, + participant: "123@lid", + accountId: DEFAULT_ACCOUNT_ID, + }), + ); }); it("respects reaction gating", async () => { @@ -280,11 +315,16 @@ describe("handleWhatsAppAction", () => { cfg, ); - expect(sendReactionWhatsApp).toHaveBeenLastCalledWith("+123", "msg1", "✅", { - verbose: false, - fromMe: undefined, - participant: undefined, - accountId: "work", - }); + expect(sendReactionWhatsApp).toHaveBeenLastCalledWith( + "+123", + "msg1", + "✅", + expect.objectContaining({ + verbose: false, + fromMe: undefined, + participant: undefined, + accountId: "work", + }), + ); }); }); diff --git a/extensions/whatsapp/src/action-runtime.ts b/extensions/whatsapp/src/action-runtime.ts index e512ea965d1..9c812d03c56 100644 --- a/extensions/whatsapp/src/action-runtime.ts +++ b/extensions/whatsapp/src/action-runtime.ts @@ -64,6 +64,7 @@ export async function handleWhatsAppAction( fromMe, participant: participant ?? undefined, accountId: resolved.accountId, + cfg, }); if (!remove && !isEmpty) { return jsonResult({ ok: true, added: emoji }); diff --git a/extensions/whatsapp/src/active-listener.test.ts b/extensions/whatsapp/src/active-listener.test.ts index ed79bcdc20d..185d0f97bed 100644 --- a/extensions/whatsapp/src/active-listener.test.ts +++ b/extensions/whatsapp/src/active-listener.test.ts @@ -6,6 +6,10 @@ vi.mock("openclaw/plugin-sdk/config-runtime", () => ({ }), })); +const WHATSAPP_ACTIVE_LISTENER_TEST_CFG = { + channels: { whatsapp: { accounts: { work: { enabled: true } }, defaultAccount: "work" } }, +}; + type ActiveListenerModule = typeof import("./active-listener.js"); const activeListenerModuleUrl = new URL("./active-listener.ts", import.meta.url).href; @@ -59,8 +63,8 @@ describe("active WhatsApp listener view", () => { const mod = await importActiveListenerModule(`default-${Date.now()}`); - expect(mod.resolveWebAccountId()).toBe("work"); - expect(mod.getActiveWebListener()).toBe(listener); + expect(mod.resolveWebAccountId({ cfg: WHATSAPP_ACTIVE_LISTENER_TEST_CFG })).toBe("work"); + expect(mod.getActiveWebListener("work")).toBe(listener); }); it("returns null when the controller has no active listener for the account", async () => { diff --git a/extensions/whatsapp/src/active-listener.ts b/extensions/whatsapp/src/active-listener.ts index 8dbf727a40b..10afa52480d 100644 --- a/extensions/whatsapp/src/active-listener.ts +++ b/extensions/whatsapp/src/active-listener.ts @@ -1,15 +1,17 @@ -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveDefaultWhatsAppAccountId } from "./accounts.js"; import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js"; import type { ActiveWebListener, ActiveWebSendOptions } from "./inbound/types.js"; export type { ActiveWebListener, ActiveWebSendOptions } from "./inbound/types.js"; -export function resolveWebAccountId(accountId?: string | null): string { - return (accountId ?? "").trim() || resolveDefaultWhatsAppAccountId(loadConfig()); +export function resolveWebAccountId(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string { + return (params.accountId ?? "").trim() || resolveDefaultWhatsAppAccountId(params.cfg); } -export function getActiveWebListener(accountId?: string | null): ActiveWebListener | null { - const id = resolveWebAccountId(accountId); - return getRegisteredWhatsAppConnectionController(id)?.getActiveListener() ?? null; +export function getActiveWebListener(accountId: string): ActiveWebListener | null { + return getRegisteredWhatsAppConnectionController(accountId)?.getActiveListener() ?? null; } diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts index 07350aa835f..05f0fde03d0 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts @@ -134,7 +134,11 @@ describe("runWebHeartbeatOnce", () => { it("sends HEARTBEAT_OK when reply is empty and showOk is enabled", async () => { await runWebHeartbeatOnce(buildRunArgs()); - expect(senderMock).toHaveBeenCalledWith("+123", HEARTBEAT_TOKEN, { verbose: false }); + expect(senderMock).toHaveBeenCalledWith( + "+123", + HEARTBEAT_TOKEN, + expect.objectContaining({ verbose: false, cfg: expect.any(Object) }), + ); expect(state.events).toEqual( expect.arrayContaining([expect.objectContaining({ status: "ok-empty", silent: false })]), ); @@ -157,7 +161,11 @@ describe("runWebHeartbeatOnce", () => { replyResolverMock.mockResolvedValue({ text: HEARTBEAT_TOKEN }); await runWebHeartbeatOnce(buildRunArgs()); expect(state.store.k?.updatedAt).toBe(123); - expect(senderMock).toHaveBeenCalledWith("+123", HEARTBEAT_TOKEN, { verbose: false }); + expect(senderMock).toHaveBeenCalledWith( + "+123", + HEARTBEAT_TOKEN, + expect.objectContaining({ verbose: false, cfg: expect.any(Object) }), + ); expect(state.events).toEqual( expect.arrayContaining([expect.objectContaining({ status: "ok-token", silent: false })]), ); diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts index 83c27548c78..a51a0978e7d 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts @@ -70,7 +70,7 @@ export async function runWebHeartbeatOnce(opts: { whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${redactedTo}`); return false; } - const sendResult = await sender(to, heartbeatOkText, { verbose }); + const sendResult = await sender(to, heartbeatOkText, { verbose, cfg }); heartbeatLogger.info( { to: redactedTo, @@ -142,7 +142,7 @@ export async function runWebHeartbeatOnce(opts: { ); return; } - const sendResult = await sender(to, overrideBody, { verbose }); + const sendResult = await sender(to, overrideBody, { verbose, cfg }); emitHeartbeatEvent({ status: "sent", to, @@ -289,7 +289,7 @@ export async function runWebHeartbeatOnce(opts: { return; } - const sendResult = await sender(to, finalText, { verbose }); + const sendResult = await sender(to, finalText, { verbose, cfg }); emitHeartbeatEvent({ status: "sent", to, diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 18ad1c925fc..97e4c25fa58 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -247,6 +247,7 @@ export async function monitorWebChannel( }); return (await (listenerFactory ?? attachWebInboxToSocket)({ + cfg, verbose, accountId: account.accountId, authDir: account.authDir, diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts index 3520142c720..2ad7b82c0c4 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts @@ -68,12 +68,12 @@ const expectAckReactionSent = (accountId: string) => { "15551234567@s.whatsapp.net", "msg-1", "👀", - { + expect.objectContaining({ verbose: false, fromMe: false, participant: undefined, accountId, - }, + }), ); }; diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index d06fd34ed8a..f71c992a4c4 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -74,6 +74,7 @@ export async function maybeSendAckReaction(params: { fromMe: false, participant: sender.jid ?? undefined, accountId: params.accountId, + cfg: params.cfg, }).catch((err) => { params.warn( { diff --git a/extensions/whatsapp/src/inbound.media.test.ts b/extensions/whatsapp/src/inbound.media.test.ts index 73a7db5f8e2..1407fe7e564 100644 --- a/extensions/whatsapp/src/inbound.media.test.ts +++ b/extensions/whatsapp/src/inbound.media.test.ts @@ -167,6 +167,10 @@ describe("web inbound media saves with extension", () => { it("stores image extension and keeps document filename", async () => { const onMessage = vi.fn(); const listener = await monitorWebInbox({ + cfg: { + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: { messagePrefix: undefined, responsePrefix: undefined }, + } as never, verbose: false, onMessage, accountId: "default", @@ -217,6 +221,10 @@ describe("web inbound media saves with extension", () => { it("passes mediaMaxMb to saveMediaBuffer", async () => { const onMessage = vi.fn(); const listener = await monitorWebInbox({ + cfg: { + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: { messagePrefix: undefined, responsePrefix: undefined }, + } as never, verbose: false, onMessage, mediaMaxMb: 1, diff --git a/extensions/whatsapp/src/inbound/access-control.test-harness.ts b/extensions/whatsapp/src/inbound/access-control.test-harness.ts index f4899c0bfcd..4d678e69e0a 100644 --- a/extensions/whatsapp/src/inbound/access-control.test-harness.ts +++ b/extensions/whatsapp/src/inbound/access-control.test-harness.ts @@ -17,6 +17,10 @@ export function setAccessControlTestConfig(next: Record): void loadConfigMock.mockReturnValue(config); } +export function getAccessControlTestConfig(): Record { + return config; +} + export function setupAccessControlTestHarness(): void { beforeEach(() => { config = { diff --git a/extensions/whatsapp/src/inbound/access-control.test.ts b/extensions/whatsapp/src/inbound/access-control.test.ts index 240d9bbc58e..376b3ec2e9a 100644 --- a/extensions/whatsapp/src/inbound/access-control.test.ts +++ b/extensions/whatsapp/src/inbound/access-control.test.ts @@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { readAllowFromStoreMock, sendMessageMock, + getAccessControlTestConfig, setAccessControlTestConfig, setupAccessControlTestHarness, upsertPairingRequestMock, @@ -18,6 +19,7 @@ beforeAll(async () => { async function checkUnauthorizedWorkDmSender() { return checkInboundAccessControl({ + cfg: getAccessControlTestConfig() as never, accountId: "work", from: "+15550001111", selfE164: "+15550009999", @@ -61,6 +63,7 @@ describe("checkInboundAccessControl pairing grace", () => { async function runPairingGraceCase(messageTimestampMs: number) { const connectedAtMs = 1_000_000; return await checkInboundAccessControl({ + cfg: getAccessControlTestConfig() as never, accountId: "default", from: "+15550001111", selfE164: "+15550009999", @@ -178,6 +181,7 @@ describe("WhatsApp dmPolicy precedence", () => { setAccessControlTestConfig(cfg); const result = await checkInboundAccessControl({ + cfg: getAccessControlTestConfig() as never, accountId: "default", from: "+15550009999", selfE164: "+15550009999", @@ -214,6 +218,7 @@ describe("WhatsApp dmPolicy precedence", () => { setAccessControlTestConfig(cfg); const result = await checkInboundAccessControl({ + cfg: getAccessControlTestConfig() as never, accountId: "default", from: "+15550001111", selfE164: "+15550009999", @@ -241,6 +246,7 @@ describe("WhatsApp dmPolicy precedence", () => { setAccessControlTestConfig(cfg); const result = await checkInboundAccessControl({ + cfg: getAccessControlTestConfig() as never, accountId: "default", from: "+15550009999", selfE164: "+15550009999", diff --git a/extensions/whatsapp/src/inbound/access-control.ts b/extensions/whatsapp/src/inbound/access-control.ts index 903953e38f1..d0899b47dfe 100644 --- a/extensions/whatsapp/src/inbound/access-control.ts +++ b/extensions/whatsapp/src/inbound/access-control.ts @@ -1,5 +1,5 @@ import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/config-runtime"; import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env"; @@ -26,6 +26,7 @@ function logWhatsAppVerbose(enabled: boolean | undefined, message: string) { } export async function checkInboundAccessControl(params: { + cfg: OpenClawConfig; accountId: string; from: string; selfE164: string | null; @@ -42,9 +43,8 @@ export async function checkInboundAccessControl(params: { }; remoteJid: string; }): Promise { - const cfg = loadConfig(); const policy = resolveWhatsAppInboundPolicy({ - cfg, + cfg: params.cfg, accountId: params.accountId, selfE164: params.selfE164, }); diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 8eec772a760..bbc6ac6c89c 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -7,6 +7,7 @@ import { getChildLogger } from "openclaw/plugin-sdk/text-runtime"; import { readWebSelfIdentityForDecision, WhatsAppAuthUnstableError } from "../auth-store.js"; import { getPrimaryIdentityId, resolveComparableIdentity } from "../identity.js"; import { DEFAULT_RECONNECT_POLICY, computeBackoff, sleepWithAbort } from "../reconnect.js"; +import type { OpenClawConfig } from "../runtime-api.js"; import { createWaSocket, formatError, getStatusCode, waitForWaConnection } from "../session.js"; import { resolveJidToE164 } from "../text-runtime.js"; import { checkInboundAccessControl } from "./access-control.js"; @@ -58,6 +59,7 @@ function isNonEmptyString(value: string | undefined): value is string { } export type MonitorWebInboxOptions = { + cfg: OpenClawConfig; verbose: boolean; accountId: string; authDir: string; @@ -385,6 +387,7 @@ export async function attachWebInboxToSocket( : undefined; const access = await checkInboundAccessControl({ + cfg: options.cfg, accountId: options.accountId, from, selfE164: self.e164 ?? null, diff --git a/extensions/whatsapp/src/media.test.ts b/extensions/whatsapp/src/media.test.ts index d396e839204..b7f89c288aa 100644 --- a/extensions/whatsapp/src/media.test.ts +++ b/extensions/whatsapp/src/media.test.ts @@ -339,7 +339,7 @@ describe("Discord voice message input hardening", () => { for (const testCase of cases) { await expect( - sendVoiceMessageDiscord("channel:123", testCase.candidate), + sendVoiceMessageDiscord("channel:123", testCase.candidate, { cfg: {} as never }), testCase.name, ).rejects.toThrow(testCase.expectedMessage); } diff --git a/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts b/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts index 8689ef2df1b..677993d7bc6 100644 --- a/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts +++ b/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test.ts @@ -62,6 +62,7 @@ async function startWebInboxMonitor(params: { } const onMessage = vi.fn(); const base = { + cfg: (params.config ?? mockLoadConfig()) as never, verbose: false, accountId: DEFAULT_ACCOUNT_ID, authDir: getAuthDir(), diff --git a/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts b/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts index 81368081c5c..2b803773595 100644 --- a/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts +++ b/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test.ts @@ -36,6 +36,7 @@ describe("web monitor inbox", () => { async function openMonitor(onMessage = vi.fn()) { return await monitorWebInbox({ + cfg: mockLoadConfig() as never, verbose: false, accountId: DEFAULT_ACCOUNT_ID, authDir: getAuthDir(), diff --git a/extensions/whatsapp/src/monitor-inbox.test-harness.ts b/extensions/whatsapp/src/monitor-inbox.test-harness.ts index 3d2ab891bb6..12f7b0e9a83 100644 --- a/extensions/whatsapp/src/monitor-inbox.test-harness.ts +++ b/extensions/whatsapp/src/monitor-inbox.test-harness.ts @@ -206,6 +206,7 @@ export async function startInboxMonitor( ({ monitorWebInbox } = await import("./inbound.js")); } const listener = await monitorWebInbox({ + cfg: mockLoadConfig() as never, verbose: false, onMessage, accountId: DEFAULT_ACCOUNT_ID, diff --git a/extensions/whatsapp/src/outbound-base.ts b/extensions/whatsapp/src/outbound-base.ts index e6433992f91..45acef604be 100644 --- a/extensions/whatsapp/src/outbound-base.ts +++ b/extensions/whatsapp/src/outbound-base.ts @@ -9,7 +9,7 @@ import { WHATSAPP_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./outbound-send-deps.js" type WhatsAppChunker = NonNullable; type WhatsAppSendTextOptions = { verbose: boolean; - cfg?: OpenClawConfig; + cfg: OpenClawConfig; mediaUrl?: string; mediaAccess?: { localRoots?: readonly string[]; @@ -28,7 +28,7 @@ type WhatsAppSendMessage = ( type WhatsAppSendPoll = ( to: string, poll: Parameters>[0]["poll"], - options: { verbose: boolean; accountId?: string; cfg?: OpenClawConfig }, + options: { verbose: boolean; accountId?: string; cfg: OpenClawConfig }, ) => Promise<{ messageId: string; toJid: string }>; type CreateWhatsAppOutboundBaseParams = { diff --git a/extensions/whatsapp/src/send.test.ts b/extensions/whatsapp/src/send.test.ts index df84e6b9ce8..ff8f086a2a2 100644 --- a/extensions/whatsapp/src/send.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -18,6 +18,10 @@ let sendReactionWhatsApp: typeof import("./send.js").sendReactionWhatsApp; let resetLogger: typeof import("openclaw/plugin-sdk/runtime-env").resetLogger; let setLoggerOverride: typeof import("openclaw/plugin-sdk/runtime-env").setLoggerOverride; +const WHATSAPP_TEST_CFG: OpenClawConfig = { + channels: { whatsapp: {} }, +}; + vi.mock("./connection-controller-registry.js", async () => { const actual = await vi.importActual( "./connection-controller-registry.js", @@ -94,7 +98,10 @@ describe("web outbound", () => { }); it("sends message via active listener", async () => { - const result = await sendMessageWhatsApp("+1555", "hi", { verbose: false }); + const result = await sendMessageWhatsApp("+1555", "hi", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + }); expect(result).toEqual({ messageId: "msg123", toJid: "1555@s.whatsapp.net", @@ -134,7 +141,10 @@ describe("web outbound", () => { }); it("trims leading whitespace before sending text and captions", async () => { - await sendMessageWhatsApp("+1555", "\n \thello", { verbose: false }); + await sendMessageWhatsApp("+1555", "\n \thello", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + }); expect(sendMessage).toHaveBeenLastCalledWith("+1555", "hello", undefined, undefined); const buf = Buffer.from("img"); @@ -145,13 +155,17 @@ describe("web outbound", () => { }); await sendMessageWhatsApp("+1555", "\n \tcaption", { verbose: false, + cfg: WHATSAPP_TEST_CFG, mediaUrl: "/tmp/pic.jpg", }); expect(sendMessage).toHaveBeenLastCalledWith("+1555", "caption", buf, "image/jpeg"); }); it("skips whitespace-only text sends without media", async () => { - const result = await sendMessageWhatsApp("+1555", "\n \t", { verbose: false }); + const result = await sendMessageWhatsApp("+1555", "\n \t", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + }); expect(result).toEqual({ messageId: "", @@ -164,13 +178,25 @@ describe("web outbound", () => { it("throws a helpful error when no active listener exists", async () => { hoisted.controllerListeners.clear(); await expect( - sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }), + sendMessageWhatsApp("+1555", "hi", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + accountId: "work", + }), ).rejects.toThrow(/No active WhatsApp Web listener/); await expect( - sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }), + sendMessageWhatsApp("+1555", "hi", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + accountId: "work", + }), ).rejects.toThrow(/channels login/); await expect( - sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }), + sendMessageWhatsApp("+1555", "hi", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + accountId: "work", + }), ).rejects.toThrow(/account: work/); }); @@ -183,6 +209,7 @@ describe("web outbound", () => { }); await sendMessageWhatsApp("+1555", "voice note", { verbose: false, + cfg: WHATSAPP_TEST_CFG, mediaUrl: "/tmp/voice.ogg", }); expect(sendMessage).toHaveBeenLastCalledWith( @@ -202,6 +229,7 @@ describe("web outbound", () => { }); await sendMessageWhatsApp("+1555", "clip", { verbose: false, + cfg: WHATSAPP_TEST_CFG, mediaUrl: "/tmp/video.mp4", }); expect(sendMessage).toHaveBeenLastCalledWith("+1555", "clip", buf, "video/mp4"); @@ -216,6 +244,7 @@ describe("web outbound", () => { }); await sendMessageWhatsApp("+1555", "gif", { verbose: false, + cfg: WHATSAPP_TEST_CFG, mediaUrl: "/tmp/anim.mp4", gifPlayback: true, }); @@ -233,6 +262,7 @@ describe("web outbound", () => { }); await sendMessageWhatsApp("+1555", "pic", { verbose: false, + cfg: WHATSAPP_TEST_CFG, mediaUrl: "/tmp/pic.jpg", }); expect(sendMessage).toHaveBeenLastCalledWith("+1555", "pic", buf, "image/jpeg"); @@ -247,6 +277,7 @@ describe("web outbound", () => { }); await sendMessageWhatsApp("+1555", "pic", { verbose: false, + cfg: WHATSAPP_TEST_CFG, mediaUrls: [" ", " /tmp/pic.jpg "], }); expect(loadWebMediaMock).toHaveBeenCalledWith( @@ -268,6 +299,7 @@ describe("web outbound", () => { }); await sendMessageWhatsApp("+1555", "doc", { verbose: false, + cfg: WHATSAPP_TEST_CFG, mediaUrl: "/tmp/file.pdf", }); expect(sendMessage).toHaveBeenLastCalledWith("+1555", "doc", buf, "application/pdf", { @@ -322,7 +354,7 @@ describe("web outbound", () => { const result = await sendPollWhatsApp( "+1555", { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 2 }, - { verbose: false }, + { verbose: false, cfg: WHATSAPP_TEST_CFG }, ); expect(result).toEqual({ messageId: "poll123", @@ -344,7 +376,7 @@ describe("web outbound", () => { await sendPollWhatsApp( "+1555", { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 1 }, - { verbose: false }, + { verbose: false, cfg: WHATSAPP_TEST_CFG }, ); await vi.waitFor( @@ -365,6 +397,7 @@ describe("web outbound", () => { it("sends reactions via active listener", async () => { await sendReactionWhatsApp("1555@s.whatsapp.net", "msg123", "✅", { verbose: false, + cfg: WHATSAPP_TEST_CFG, fromMe: false, }); expect(sendReaction).toHaveBeenCalledWith( diff --git a/extensions/whatsapp/src/send.ts b/extensions/whatsapp/src/send.ts index 9e011d33bbc..9eafa4af66d 100644 --- a/extensions/whatsapp/src/send.ts +++ b/extensions/whatsapp/src/send.ts @@ -1,5 +1,5 @@ import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; -import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { generateSecureUuid } from "openclaw/plugin-sdk/core"; import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/media-runtime"; @@ -51,7 +51,7 @@ export async function sendMessageWhatsApp( body: string, options: { verbose: boolean; - cfg?: OpenClawConfig; + cfg: OpenClawConfig; mediaUrl?: string; mediaUrls?: readonly string[]; mediaAccess?: { @@ -77,7 +77,7 @@ export async function sendMessageWhatsApp( } const correlationId = generateSecureUuid(); const startedAt = Date.now(); - const cfg = options.cfg ?? loadConfig(); + const cfg = requireRuntimeConfig(options.cfg, "WhatsApp send"); const { listener: active, accountId: resolvedAccountId } = requireOutboundActiveWebListener({ cfg, accountId: options.accountId, @@ -164,11 +164,11 @@ export async function sendMessageWhatsApp( export async function sendTypingWhatsApp( to: string, options: { - cfg?: OpenClawConfig; + cfg: OpenClawConfig; accountId?: string; - } = {}, + }, ): Promise { - const cfg = options.cfg ?? loadConfig(); + const cfg = requireRuntimeConfig(options.cfg, "WhatsApp typing send"); const { listener: active } = requireOutboundActiveWebListener({ cfg, accountId: options.accountId, @@ -185,10 +185,11 @@ export async function sendReactionWhatsApp( fromMe?: boolean; participant?: string; accountId?: string; + cfg: OpenClawConfig; }, ): Promise { const correlationId = generateSecureUuid(); - const cfg = loadConfig(); + const cfg = requireRuntimeConfig(options.cfg, "WhatsApp reaction"); const { listener: active } = requireOutboundActiveWebListener({ cfg, accountId: options.accountId, @@ -226,11 +227,11 @@ export async function sendReactionWhatsApp( export async function sendPollWhatsApp( to: string, poll: PollInput, - options: { verbose: boolean; accountId?: string; cfg?: OpenClawConfig }, + options: { verbose: boolean; accountId?: string; cfg: OpenClawConfig }, ): Promise<{ messageId: string; toJid: string }> { const correlationId = generateSecureUuid(); const startedAt = Date.now(); - const cfg = options.cfg ?? loadConfig(); + const cfg = requireRuntimeConfig(options.cfg, "WhatsApp poll"); const { listener: active } = requireOutboundActiveWebListener({ cfg, accountId: options.accountId,