From 0687e04760218fa4d6bf06c35b60786a1834d374 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:30:57 -0500 Subject: [PATCH] fix: thread runtime config through Discord/Telegram sends (#42352) (thanks @joshavant) (#42352) --- CHANGELOG.md | 1 + docs/channels/discord.md | 1 + docs/channels/telegram.md | 1 + docs/gateway/configuration-reference.md | 1 + docs/gateway/secrets.md | 2 + src/agents/tools/telegram-actions.test.ts | 95 +++++++++++++++++++ src/agents/tools/telegram-actions.ts | 7 ++ src/discord/client.test.ts | 91 ++++++++++++++++++ src/discord/client.ts | 60 ++++++++---- src/discord/monitor/agent-components.ts | 1 + .../monitor/message-handler.process.ts | 1 + src/discord/monitor/provider.ts | 1 + src/discord/monitor/reply-delivery.test.ts | 37 ++++++++ src/discord/monitor/reply-delivery.ts | 25 ++++- .../thread-bindings.discord-api.test.ts | 78 ++++++++++++++- .../monitor/thread-bindings.discord-api.ts | 30 ++++-- .../monitor/thread-bindings.lifecycle.test.ts | 79 ++++++++++++++- .../monitor/thread-bindings.lifecycle.ts | 3 + .../monitor/thread-bindings.manager.ts | 31 ++++-- .../outbound/cfg-threading.guard.test.ts | 17 ++++ src/telegram/send.ts | 15 ++- 21 files changed, 531 insertions(+), 46 deletions(-) create mode 100644 src/discord/client.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d01a8e11b2a..f571691a7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai - Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus. - Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung. - Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode. +- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant. ## 2026.3.8 diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 994c03391ce..48a8a03f59e 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -168,6 +168,7 @@ openclaw pairing approve discord Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account. +For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. Account policy/retry settings still come from the selected account in the active runtime snapshot. ## Recommended: Set up a guild workspace diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index a039cb43483..b29ec3c59d5 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -410,6 +410,7 @@ curl "https://api.telegram.org/bot/getUpdates" - `channels.telegram.actions.sticker` (default: disabled) Note: `edit` and `topic-create` are currently enabled by default and do not have separate `channels.telegram.actions.*` toggles. + Runtime sends use the active config/secrets snapshot (startup/reload), so action paths do not perform ad-hoc SecretRef re-resolution per send. Reaction removal semantics: [/tools/reactions](/tools/reactions) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 538b80f6138..9a77f6ac1a3 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -304,6 +304,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat ``` - Token: `channels.discord.token`, with `DISCORD_BOT_TOKEN` as fallback for the default account. +- Direct outbound calls that provide an explicit Discord `token` use that token for the call; account retry/policy settings still come from the selected account in the active runtime snapshot. - Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id. - Use `user:` (DM) or `channel:` (guild channel) for delivery targets; bare numeric IDs are rejected. - Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index e9d75343147..213c98f9f14 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -21,6 +21,7 @@ Secrets are resolved into an in-memory runtime snapshot. - Startup fails fast when an effectively active SecretRef cannot be resolved. - Reload uses atomic swap: full success, or keep the last-known-good snapshot. - Runtime requests read from the active in-memory snapshot only. +- Outbound delivery paths also read from that active snapshot (for example Discord reply/thread delivery and Telegram action sends); they do not re-resolve SecretRefs on each send. This keeps secret-provider outages off hot request paths. @@ -321,6 +322,7 @@ Activation contract: - Success swaps the snapshot atomically. - Startup failure aborts gateway startup. - Runtime reload failure keeps the last-known-good snapshot. +- Providing an explicit per-call channel token to an outbound helper/tool call does not trigger SecretRef activation; activation points remain startup, reload, and explicit `secrets.reload`. ## Degraded and recovered signals diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index eeeb7bbf35b..e15b4bd2e17 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -18,6 +18,16 @@ const sendStickerTelegram = vi.fn(async () => ({ chatId: "123", })); const deleteMessageTelegram = vi.fn(async () => ({ ok: true })); +const editMessageTelegram = vi.fn(async () => ({ + ok: true, + messageId: "456", + chatId: "123", +})); +const createForumTopicTelegram = vi.fn(async () => ({ + topicId: 99, + name: "Topic", + chatId: "123", +})); let envSnapshot: ReturnType; vi.mock("../../telegram/send.js", () => ({ @@ -30,6 +40,10 @@ vi.mock("../../telegram/send.js", () => ({ sendStickerTelegram(...args), deleteMessageTelegram: (...args: Parameters) => deleteMessageTelegram(...args), + editMessageTelegram: (...args: Parameters) => + editMessageTelegram(...args), + createForumTopicTelegram: (...args: Parameters) => + createForumTopicTelegram(...args), })); describe("handleTelegramAction", () => { @@ -90,6 +104,8 @@ describe("handleTelegramAction", () => { sendPollTelegram.mockClear(); sendStickerTelegram.mockClear(); deleteMessageTelegram.mockClear(); + editMessageTelegram.mockClear(); + createForumTopicTelegram.mockClear(); process.env.TELEGRAM_BOT_TOKEN = "tok"; }); @@ -379,6 +395,85 @@ describe("handleTelegramAction", () => { ); }); + it.each([ + { + name: "react", + params: { action: "react", chatId: "123", messageId: 456, emoji: "✅" }, + cfg: reactionConfig("minimal"), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(reactMessageTelegram.mock.calls as unknown[][], 3), + }, + { + name: "sendMessage", + params: { action: "sendMessage", to: "123", content: "hello" }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(sendMessageTelegram.mock.calls as unknown[][], 2), + }, + { + name: "poll", + params: { + action: "poll", + to: "123", + question: "Q?", + answers: ["A", "B"], + }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(sendPollTelegram.mock.calls as unknown[][], 2), + }, + { + name: "deleteMessage", + params: { action: "deleteMessage", chatId: "123", messageId: 1 }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(deleteMessageTelegram.mock.calls as unknown[][], 2), + }, + { + name: "editMessage", + params: { action: "editMessage", chatId: "123", messageId: 1, content: "updated" }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(editMessageTelegram.mock.calls as unknown[][], 3), + }, + { + name: "sendSticker", + params: { action: "sendSticker", to: "123", fileId: "sticker-1" }, + cfg: telegramConfig({ actions: { sticker: true } }), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(sendStickerTelegram.mock.calls as unknown[][], 2), + }, + { + name: "createForumTopic", + params: { action: "createForumTopic", chatId: "123", name: "Topic" }, + cfg: telegramConfig({ actions: { createForumTopic: true } }), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(createForumTopicTelegram.mock.calls as unknown[][], 2), + }, + ])("forwards resolved cfg for $name action", async ({ params, cfg, assertCall }) => { + const readCallOpts = (calls: unknown[][], argIndex: number): Record => { + const args = calls[0]; + if (!Array.isArray(args)) { + throw new Error("Expected Telegram action call args"); + } + const opts = args[argIndex]; + if (!opts || typeof opts !== "object") { + throw new Error("Expected Telegram action options object"); + } + return opts as Record; + }; + await handleTelegramAction(params as Record, cfg); + const opts = assertCall(readCallOpts); + expect(opts.cfg).toBe(cfg); + }); + it.each([ { name: "media", diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 30c07530159..143d154e633 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -154,6 +154,7 @@ export async function handleTelegramAction( let reactionResult: Awaited>; try { reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { + cfg, token, remove, accountId: accountId ?? undefined, @@ -237,6 +238,7 @@ export async function handleTelegramAction( ); } const result = await sendMessageTelegram(to, content, { + cfg, token, accountId: accountId ?? undefined, mediaUrl: mediaUrl || undefined, @@ -293,6 +295,7 @@ export async function handleTelegramAction( durationHours: durationHours ?? undefined, }, { + cfg, token, accountId: accountId ?? undefined, replyToMessageId: replyToMessageId ?? undefined, @@ -327,6 +330,7 @@ export async function handleTelegramAction( ); } await deleteMessageTelegram(chatId ?? "", messageId ?? 0, { + cfg, token, accountId: accountId ?? undefined, }); @@ -367,6 +371,7 @@ export async function handleTelegramAction( ); } const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, { + cfg, token, accountId: accountId ?? undefined, buttons, @@ -399,6 +404,7 @@ export async function handleTelegramAction( ); } const result = await sendStickerTelegram(to, fileId, { + cfg, token, accountId: accountId ?? undefined, replyToMessageId: replyToMessageId ?? undefined, @@ -454,6 +460,7 @@ export async function handleTelegramAction( ); } const result = await createForumTopicTelegram(chatId ?? "", name, { + cfg, token, accountId: accountId ?? undefined, iconColor: iconColor ?? undefined, diff --git a/src/discord/client.test.ts b/src/discord/client.test.ts new file mode 100644 index 00000000000..3dc156670e7 --- /dev/null +++ b/src/discord/client.test.ts @@ -0,0 +1,91 @@ +import type { RequestClient } from "@buape/carbon"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createDiscordRestClient } from "./client.js"; + +describe("createDiscordRestClient", () => { + const fakeRest = {} as RequestClient; + + it("uses explicit token without resolving config token SecretRefs", () => { + const cfg = { + channels: { + discord: { + token: { + source: "exec", + provider: "vault", + id: "discord/bot-token", + }, + }, + }, + } as OpenClawConfig; + + const result = createDiscordRestClient( + { + token: "Bot explicit-token", + rest: fakeRest, + }, + cfg, + ); + + expect(result.token).toBe("explicit-token"); + expect(result.rest).toBe(fakeRest); + expect(result.account.accountId).toBe("default"); + }); + + it("keeps account retry config when explicit token is provided", () => { + const cfg = { + channels: { + discord: { + accounts: { + ops: { + token: { + source: "exec", + provider: "vault", + id: "discord/ops-token", + }, + retry: { + attempts: 7, + }, + }, + }, + }, + }, + } as OpenClawConfig; + + const result = createDiscordRestClient( + { + accountId: "ops", + token: "Bot explicit-account-token", + rest: fakeRest, + }, + cfg, + ); + + expect(result.token).toBe("explicit-account-token"); + expect(result.account.accountId).toBe("ops"); + expect(result.account.config.retry).toMatchObject({ attempts: 7 }); + }); + + it("still throws when no explicit token is provided and config token is unresolved", () => { + const cfg = { + channels: { + discord: { + token: { + source: "file", + provider: "default", + id: "/discord/token", + }, + }, + }, + } as OpenClawConfig; + + expect(() => + createDiscordRestClient( + { + rest: fakeRest, + }, + cfg, + ), + ).toThrow(/unresolved SecretRef/i); + }); +}); diff --git a/src/discord/client.ts b/src/discord/client.ts index 4f754fa8624..62d917cebb6 100644 --- a/src/discord/client.ts +++ b/src/discord/client.ts @@ -2,10 +2,16 @@ import { RequestClient } from "@buape/carbon"; import { loadConfig } from "../config/config.js"; import { createDiscordRetryRunner, type RetryRunner } from "../infra/retry-policy.js"; import type { RetryConfig } from "../infra/retry.js"; -import { resolveDiscordAccount } from "./accounts.js"; +import { normalizeAccountId } from "../routing/session-key.js"; +import { + mergeDiscordAccountConfig, + resolveDiscordAccount, + type ResolvedDiscordAccount, +} from "./accounts.js"; import { normalizeDiscordToken } from "./token.js"; export type DiscordClientOpts = { + cfg?: ReturnType; token?: string; accountId?: string; rest?: RequestClient; @@ -13,11 +19,7 @@ export type DiscordClientOpts = { verbose?: boolean; }; -function resolveToken(params: { explicit?: string; accountId: string; fallbackToken?: string }) { - const explicit = normalizeDiscordToken(params.explicit, "channels.discord.token"); - if (explicit) { - return explicit; - } +function resolveToken(params: { accountId: string; fallbackToken?: string }) { const fallback = normalizeDiscordToken(params.fallbackToken, "channels.discord.token"); if (!fallback) { throw new Error( @@ -31,22 +33,48 @@ function resolveRest(token: string, rest?: RequestClient) { return rest ?? new RequestClient(token); } -export function createDiscordRestClient(opts: DiscordClientOpts, cfg = loadConfig()) { - const account = resolveDiscordAccount({ cfg, accountId: opts.accountId }); - const token = resolveToken({ - explicit: opts.token, - accountId: account.accountId, - fallbackToken: account.token, - }); +function resolveAccountWithoutToken(params: { + cfg: ReturnType; + accountId?: string; +}): ResolvedDiscordAccount { + const accountId = normalizeAccountId(params.accountId); + const merged = mergeDiscordAccountConfig(params.cfg, accountId); + const baseEnabled = params.cfg.channels?.discord?.enabled !== false; + const accountEnabled = merged.enabled !== false; + return { + accountId, + enabled: baseEnabled && accountEnabled, + name: merged.name?.trim() || undefined, + token: "", + tokenSource: "none", + config: merged, + }; +} + +export function createDiscordRestClient( + opts: DiscordClientOpts, + cfg?: ReturnType, +) { + const resolvedCfg = opts.cfg ?? cfg ?? loadConfig(); + const explicitToken = normalizeDiscordToken(opts.token, "channels.discord.token"); + const account = explicitToken + ? resolveAccountWithoutToken({ cfg: resolvedCfg, accountId: opts.accountId }) + : resolveDiscordAccount({ cfg: resolvedCfg, accountId: opts.accountId }); + const token = + explicitToken ?? + resolveToken({ + accountId: account.accountId, + fallbackToken: account.token, + }); const rest = resolveRest(token, opts.rest); return { token, rest, account }; } export function createDiscordClient( opts: DiscordClientOpts, - cfg = loadConfig(), + cfg?: ReturnType, ): { token: string; rest: RequestClient; request: RetryRunner } { - const { token, rest, account } = createDiscordRestClient(opts, cfg); + const { token, rest, account } = createDiscordRestClient(opts, opts.cfg ?? cfg); const request = createDiscordRetryRunner({ retry: opts.retry, configRetry: account.config.retry, @@ -56,5 +84,5 @@ export function createDiscordClient( } export function resolveDiscordRest(opts: DiscordClientOpts) { - return createDiscordRestClient(opts).rest; + return createDiscordRestClient(opts, opts.cfg).rest; } diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index 16b3f564bfe..56e7dfe3240 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -1009,6 +1009,7 @@ async function dispatchDiscordComponentEvent(params: { deliver: async (payload) => { const replyToId = replyReference.use(); await deliverDiscordReply({ + cfg: ctx.cfg, replies: [payload], target: deliverTarget, token, diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index c283658ac09..ea64b37f98e 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -684,6 +684,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const replyToId = replyReference.use(); await deliverDiscordReply({ + cfg, replies: [payload], target: deliverTarget, token, diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index b0825d03345..08de298a062 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -441,6 +441,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ? createThreadBindingManager({ accountId: account.accountId, token, + cfg, idleTimeoutMs: threadBindingIdleTimeoutMs, maxAgeMs: threadBindingMaxAgeMs, }) diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 3d0357ef43a..1e0bdc00942 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; import { deliverDiscordReply } from "./reply-delivery.js"; import { @@ -23,6 +24,9 @@ vi.mock("../send.shared.js", () => ({ describe("deliverDiscordReply", () => { const runtime = {} as RuntimeEnv; + const cfg = { + channels: { discord: { token: "test-token" } }, + } as OpenClawConfig; const createBoundThreadBindings = async ( overrides: Partial<{ threadId: string; @@ -86,6 +90,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, replyToId: "reply-1", }); @@ -128,6 +133,7 @@ describe("deliverDiscordReply", () => { target: "channel:456", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -147,6 +153,7 @@ describe("deliverDiscordReply", () => { target: "channel:654", token: "token", runtime, + cfg, textLimit: 2000, mediaLocalRoots, }); @@ -174,6 +181,19 @@ describe("deliverDiscordReply", () => { ); }); + it("forwards cfg to Discord send helpers", async () => { + await deliverDiscordReply({ + replies: [{ text: "cfg path" }], + target: "channel:101", + token: "token", + runtime, + cfg, + textLimit: 2000, + }); + + expect(sendMessageDiscordMock.mock.calls[0]?.[2]?.cfg).toBe(cfg); + }); + it("uses replyToId only for the first chunk when replyToMode is first", async () => { await deliverDiscordReply({ replies: [ @@ -184,6 +204,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 5, replyToId: "reply-1", replyToMode: "first", @@ -200,6 +221,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 2000, replyToId: "reply-1", replyToMode: "first", @@ -219,6 +241,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -246,6 +269,7 @@ describe("deliverDiscordReply", () => { token: "token", rest: fakeRest, runtime, + cfg, textLimit: 5, }); @@ -265,6 +289,7 @@ describe("deliverDiscordReply", () => { token: "token", rest: fakeRest, runtime, + cfg, textLimit: 2000, maxLinesPerMessage: 120, chunkMode: "newline", @@ -285,6 +310,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -303,6 +329,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -320,6 +347,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -336,6 +364,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }), ).rejects.toThrow("bad request"); @@ -353,6 +382,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }), ).rejects.toThrow("rate limited"); @@ -372,6 +402,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2, }); @@ -386,6 +417,7 @@ describe("deliverDiscordReply", () => { target: "channel:thread-1", token: "token", runtime, + cfg, textLimit: 2000, replyToId: "reply-1", sessionKey: "agent:main:subagent:child", @@ -396,6 +428,7 @@ describe("deliverDiscordReply", () => { expect(sendWebhookMessageDiscordMock).toHaveBeenCalledWith( "Hello from subagent", expect.objectContaining({ + cfg, webhookId: "wh_1", webhookToken: "tok_1", accountId: "default", @@ -418,6 +451,7 @@ describe("deliverDiscordReply", () => { target: "channel:thread-1", token: "token", runtime, + cfg, textLimit: 2000, sessionKey: "agent:main:subagent:child", threadBindings, @@ -441,12 +475,14 @@ describe("deliverDiscordReply", () => { token: "token", accountId: "default", runtime, + cfg, textLimit: 2000, sessionKey: "agent:main:subagent:child", threadBindings, }); expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1); + expect(sendWebhookMessageDiscordMock.mock.calls[0]?.[1]?.cfg).toBe(cfg); expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1); expect(sendMessageDiscordMock).toHaveBeenCalledWith( "channel:thread-1", @@ -464,6 +500,7 @@ describe("deliverDiscordReply", () => { token: "token", accountId: "default", runtime, + cfg, textLimit: 2000, sessionKey: "agent:main:subagent:child", threadBindings, diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index d3e7ef9bf61..fb235ca65d0 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -2,7 +2,7 @@ import type { RequestClient } from "@buape/carbon"; import { resolveAgentAvatar } from "../../agents/identity-avatar.js"; import type { ChunkMode } from "../../auto-reply/chunk.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; -import { loadConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/config.js"; import type { MarkdownTableMode, ReplyToMode } from "../../config/types.base.js"; import { createDiscordRetryRunner, type RetryRunner } from "../../infra/retry-policy.js"; import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../infra/retry.js"; @@ -103,7 +103,10 @@ function resolveBoundThreadBinding(params: { return bindings.find((entry) => entry.threadId === targetChannelId); } -function resolveBindingPersona(binding: DiscordThreadBindingLookupRecord | undefined): { +function resolveBindingPersona( + cfg: OpenClawConfig, + binding: DiscordThreadBindingLookupRecord | undefined, +): { username?: string; avatarUrl?: string; } { @@ -115,7 +118,7 @@ function resolveBindingPersona(binding: DiscordThreadBindingLookupRecord | undef let avatarUrl: string | undefined; try { - const avatar = resolveAgentAvatar(loadConfig(), binding.agentId); + const avatar = resolveAgentAvatar(cfg, binding.agentId); if (avatar.kind === "remote") { avatarUrl = avatar.url; } @@ -126,6 +129,7 @@ function resolveBindingPersona(binding: DiscordThreadBindingLookupRecord | undef } async function sendDiscordChunkWithFallback(params: { + cfg: OpenClawConfig; target: string; text: string; token: string; @@ -152,6 +156,7 @@ async function sendDiscordChunkWithFallback(params: { if (binding?.webhookId && binding?.webhookToken) { try { await sendWebhookMessageDiscord(text, { + cfg: params.cfg, webhookId: binding.webhookId, webhookToken: binding.webhookToken, accountId: binding.accountId, @@ -190,6 +195,7 @@ async function sendDiscordChunkWithFallback(params: { await sendWithRetry( () => sendMessageDiscord(params.target, text, { + cfg: params.cfg, token: params.token, rest: params.rest, accountId: params.accountId, @@ -200,6 +206,7 @@ async function sendDiscordChunkWithFallback(params: { } async function sendAdditionalDiscordMedia(params: { + cfg: OpenClawConfig; target: string; token: string; rest?: RequestClient; @@ -214,6 +221,7 @@ async function sendAdditionalDiscordMedia(params: { await sendWithRetry( () => sendMessageDiscord(params.target, "", { + cfg: params.cfg, token: params.token, rest: params.rest, mediaUrl, @@ -227,6 +235,7 @@ async function sendAdditionalDiscordMedia(params: { } export async function deliverDiscordReply(params: { + cfg: OpenClawConfig; replies: ReplyPayload[]; target: string; token: string; @@ -267,12 +276,12 @@ export async function deliverDiscordReply(params: { sessionKey: params.sessionKey, target: params.target, }); - const persona = resolveBindingPersona(binding); + const persona = resolveBindingPersona(params.cfg, binding); // Pre-resolve channel ID and retry runner once to avoid per-chunk overhead. // This eliminates redundant channel-type GET requests and client creation that // can cause ordering issues when multiple chunks share the RequestClient queue. const channelId = resolveTargetChannelId(params.target); - const account = resolveDiscordAccount({ cfg: loadConfig(), accountId: params.accountId }); + const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); const retryConfig = resolveDeliveryRetryConfig(account.config.retry); const request: RetryRunner | undefined = channelId ? createDiscordRetryRunner({ configRetry: account.config.retry }) @@ -302,6 +311,7 @@ export async function deliverDiscordReply(params: { } const replyTo = resolveReplyTo(); await sendDiscordChunkWithFallback({ + cfg: params.cfg, target: params.target, text: chunk, token: params.token, @@ -331,6 +341,7 @@ export async function deliverDiscordReply(params: { if (payload.audioAsVoice) { const replyTo = resolveReplyTo(); await sendVoiceMessageDiscord(params.target, firstMedia, { + cfg: params.cfg, token: params.token, rest: params.rest, accountId: params.accountId, @@ -339,6 +350,7 @@ export async function deliverDiscordReply(params: { deliveredAny = true; // Voice messages cannot include text; send remaining text separately if present. await sendDiscordChunkWithFallback({ + cfg: params.cfg, target: params.target, text, token: params.token, @@ -356,6 +368,7 @@ export async function deliverDiscordReply(params: { }); // Additional media items are sent as regular attachments (voice is single-file only). await sendAdditionalDiscordMedia({ + cfg: params.cfg, target: params.target, token: params.token, rest: params.rest, @@ -370,6 +383,7 @@ export async function deliverDiscordReply(params: { const replyTo = resolveReplyTo(); await sendMessageDiscord(params.target, text, { + cfg: params.cfg, token: params.token, rest: params.rest, mediaUrl: firstMedia, @@ -379,6 +393,7 @@ export async function deliverDiscordReply(params: { }); deliveredAny = true; await sendAdditionalDiscordMedia({ + cfg: params.cfg, target: params.target, token: params.token, rest: params.rest, diff --git a/src/discord/monitor/thread-bindings.discord-api.test.ts b/src/discord/monitor/thread-bindings.discord-api.test.ts index 0dca4afe0b4..5b455da9e5d 100644 --- a/src/discord/monitor/thread-bindings.discord-api.test.ts +++ b/src/discord/monitor/thread-bindings.discord-api.test.ts @@ -1,8 +1,12 @@ import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ThreadBindingRecord } from "./thread-bindings.types.js"; const hoisted = vi.hoisted(() => { const restGet = vi.fn(); + const sendMessageDiscord = vi.fn(); + const sendWebhookMessageDiscord = vi.fn(); const createDiscordRestClient = vi.fn(() => ({ rest: { get: restGet, @@ -10,6 +14,8 @@ const hoisted = vi.hoisted(() => { })); return { restGet, + sendMessageDiscord, + sendWebhookMessageDiscord, createDiscordRestClient, }; }); @@ -18,12 +24,20 @@ vi.mock("../client.js", () => ({ createDiscordRestClient: hoisted.createDiscordRestClient, })); -const { resolveChannelIdForBinding } = await import("./thread-bindings.discord-api.js"); +vi.mock("../send.js", () => ({ + sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscord(...args), + sendWebhookMessageDiscord: (...args: unknown[]) => hoisted.sendWebhookMessageDiscord(...args), +})); + +const { maybeSendBindingMessage, resolveChannelIdForBinding } = + await import("./thread-bindings.discord-api.js"); describe("resolveChannelIdForBinding", () => { beforeEach(() => { hoisted.restGet.mockClear(); hoisted.createDiscordRestClient.mockClear(); + hoisted.sendMessageDiscord.mockClear().mockResolvedValue({}); + hoisted.sendWebhookMessageDiscord.mockClear().mockResolvedValue({}); }); it("returns explicit channelId without resolving route", async () => { @@ -53,6 +67,26 @@ describe("resolveChannelIdForBinding", () => { expect(resolved).toBe("channel-parent"); }); + it("forwards cfg when resolving channel id through Discord client", async () => { + const cfg = { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig; + hoisted.restGet.mockResolvedValueOnce({ + id: "thread-1", + type: ChannelType.PublicThread, + parent_id: "channel-parent", + }); + + await resolveChannelIdForBinding({ + cfg, + accountId: "default", + threadId: "thread-1", + }); + + const createDiscordRestClientCalls = hoisted.createDiscordRestClient.mock.calls as unknown[][]; + expect(createDiscordRestClientCalls[0]?.[1]).toBe(cfg); + }); + it("keeps non-thread channel id even when parent_id exists", async () => { hoisted.restGet.mockResolvedValueOnce({ id: "channel-text", @@ -83,3 +117,45 @@ describe("resolveChannelIdForBinding", () => { expect(resolved).toBe("forum-1"); }); }); + +describe("maybeSendBindingMessage", () => { + beforeEach(() => { + hoisted.sendMessageDiscord.mockClear().mockResolvedValue({}); + hoisted.sendWebhookMessageDiscord.mockClear().mockResolvedValue({}); + }); + + it("forwards cfg to webhook send path", async () => { + const cfg = { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig; + const record = { + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:test", + agentId: "main", + boundBy: "test", + boundAt: Date.now(), + lastActivityAt: Date.now(), + webhookId: "wh_1", + webhookToken: "tok_1", + } satisfies ThreadBindingRecord; + + await maybeSendBindingMessage({ + cfg, + record, + text: "hello webhook", + }); + + expect(hoisted.sendWebhookMessageDiscord).toHaveBeenCalledTimes(1); + expect(hoisted.sendWebhookMessageDiscord.mock.calls[0]?.[1]).toMatchObject({ + cfg, + webhookId: "wh_1", + webhookToken: "tok_1", + accountId: "default", + threadId: "thread-1", + }); + expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled(); + }); +}); diff --git a/src/discord/monitor/thread-bindings.discord-api.ts b/src/discord/monitor/thread-bindings.discord-api.ts index faac1cce4e8..2a59075cf46 100644 --- a/src/discord/monitor/thread-bindings.discord-api.ts +++ b/src/discord/monitor/thread-bindings.discord-api.ts @@ -1,4 +1,5 @@ import { ChannelType, Routes } from "discord-api-types/v10"; +import type { OpenClawConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; import { createDiscordRestClient } from "../client.js"; import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js"; @@ -122,6 +123,7 @@ export function isDiscordThreadGoneError(err: unknown): boolean { } export async function maybeSendBindingMessage(params: { + cfg?: OpenClawConfig; record: ThreadBindingRecord; text: string; preferWebhook?: boolean; @@ -134,6 +136,7 @@ export async function maybeSendBindingMessage(params: { if (params.preferWebhook !== false && record.webhookId && record.webhookToken) { try { await sendWebhookMessageDiscord(text, { + cfg: params.cfg, webhookId: record.webhookId, webhookToken: record.webhookToken, accountId: record.accountId, @@ -147,6 +150,7 @@ export async function maybeSendBindingMessage(params: { } try { await sendMessageDiscord(buildThreadTarget(record.threadId), text, { + cfg: params.cfg, accountId: record.accountId, }); } catch (err) { @@ -155,15 +159,19 @@ export async function maybeSendBindingMessage(params: { } export async function createWebhookForChannel(params: { + cfg?: OpenClawConfig; accountId: string; token?: string; channelId: string; }): Promise<{ webhookId?: string; webhookToken?: string }> { try { - const rest = createDiscordRestClient({ - accountId: params.accountId, - token: params.token, - }).rest; + const rest = createDiscordRestClient( + { + accountId: params.accountId, + token: params.token, + }, + params.cfg, + ).rest; const created = (await rest.post(Routes.channelWebhooks(params.channelId), { body: { name: "OpenClaw Agents", @@ -218,6 +226,7 @@ export function findReusableWebhook(params: { accountId: string; channelId: stri } export async function resolveChannelIdForBinding(params: { + cfg?: OpenClawConfig; accountId: string; token?: string; threadId: string; @@ -228,10 +237,13 @@ export async function resolveChannelIdForBinding(params: { return explicit; } try { - const rest = createDiscordRestClient({ - accountId: params.accountId, - token: params.token, - }).rest; + const rest = createDiscordRestClient( + { + accountId: params.accountId, + token: params.token, + }, + params.cfg, + ).rest; const channel = (await rest.get(Routes.channel(params.threadId))) as { id?: string; type?: number; @@ -261,6 +273,7 @@ export async function resolveChannelIdForBinding(params: { } export async function createThreadForBinding(params: { + cfg?: OpenClawConfig; accountId: string; token?: string; channelId: string; @@ -274,6 +287,7 @@ export async function createThreadForBinding(params: { autoArchiveMinutes: 60, }, { + cfg: params.cfg, accountId: params.accountId, token: params.token, }, diff --git a/src/discord/monitor/thread-bindings.lifecycle.test.ts b/src/discord/monitor/thread-bindings.lifecycle.test.ts index b4eeb229f6f..6d37dcc1c2a 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.test.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.test.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, + type OpenClawConfig, +} from "../../config/config.js"; const hoisted = vi.hoisted(() => { const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({})); @@ -68,6 +72,7 @@ const { describe("thread binding lifecycle", () => { beforeEach(() => { __testing.resetThreadBindingsForTests(); + clearRuntimeConfigSnapshot(); hoisted.sendMessageDiscord.mockClear(); hoisted.sendWebhookMessageDiscord.mockClear(); hoisted.restGet.mockClear(); @@ -627,9 +632,13 @@ describe("thread binding lifecycle", () => { }); it("passes manager token when resolving parent channels for auto-bind", async () => { + const cfg = { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig; createThreadBindingManager({ accountId: "runtime", token: "runtime-token", + cfg, persist: false, enableSweeper: false, idleTimeoutMs: 24 * 60 * 60 * 1000, @@ -647,6 +656,7 @@ describe("thread binding lifecycle", () => { hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime" }); const childBinding = await autoBindSpawnedDiscordSubagent({ + cfg, accountId: "runtime", channel: "discord", to: "channel:thread-runtime", @@ -662,6 +672,73 @@ describe("thread binding lifecycle", () => { accountId: "runtime", token: "runtime-token", }); + const usedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => { + if (call?.[1] === cfg) { + return true; + } + const first = call?.[0]; + return ( + typeof first === "object" && first !== null && (first as { cfg?: unknown }).cfg === cfg + ); + }); + expect(usedCfg).toBe(true); + }); + + it("uses the active runtime snapshot cfg for manager operations", async () => { + const startupCfg = { + channels: { discord: { token: "startup-token" } }, + } as OpenClawConfig; + const refreshedCfg = { + channels: { discord: { token: "refreshed-token" } }, + } as OpenClawConfig; + const manager = createThreadBindingManager({ + accountId: "runtime", + token: "runtime-token", + cfg: startupCfg, + persist: false, + enableSweeper: false, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }); + + setRuntimeConfigSnapshot(refreshedCfg); + hoisted.createDiscordRestClient.mockClear(); + hoisted.createThreadDiscord.mockClear(); + hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime-cfg" }); + + const bound = await manager.bindTarget({ + createThread: true, + channelId: "parent-runtime", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:runtime-cfg", + agentId: "main", + }); + + expect(bound).not.toBeNull(); + const usedRefreshedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => { + if (call?.[1] === refreshedCfg) { + return true; + } + const first = call?.[0]; + return ( + typeof first === "object" && + first !== null && + (first as { cfg?: unknown }).cfg === refreshedCfg + ); + }); + expect(usedRefreshedCfg).toBe(true); + const usedStartupCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => { + if (call?.[1] === startupCfg) { + return true; + } + const first = call?.[0]; + return ( + typeof first === "object" && + first !== null && + (first as { cfg?: unknown }).cfg === startupCfg + ); + }); + expect(usedStartupCfg).toBe(false); }); it("refreshes manager token when an existing manager is reused", async () => { diff --git a/src/discord/monitor/thread-bindings.lifecycle.ts b/src/discord/monitor/thread-bindings.lifecycle.ts index f5beb9a3e6f..256ab5e249c 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.ts @@ -118,6 +118,7 @@ export function listThreadBindingsBySessionKey(params: { } export async function autoBindSpawnedDiscordSubagent(params: { + cfg?: OpenClawConfig; accountId?: string; channel?: string; to?: string; @@ -146,6 +147,7 @@ export async function autoBindSpawnedDiscordSubagent(params: { } else { channelId = (await resolveChannelIdForBinding({ + cfg: params.cfg, accountId: manager.accountId, token: managerToken, threadId: requesterThreadId, @@ -164,6 +166,7 @@ export async function autoBindSpawnedDiscordSubagent(params: { } channelId = (await resolveChannelIdForBinding({ + cfg: params.cfg, accountId: manager.accountId, token: managerToken, threadId: target.id, diff --git a/src/discord/monitor/thread-bindings.manager.ts b/src/discord/monitor/thread-bindings.manager.ts index 386d1adbc8c..43ee414c2a5 100644 --- a/src/discord/monitor/thread-bindings.manager.ts +++ b/src/discord/monitor/thread-bindings.manager.ts @@ -1,5 +1,6 @@ import { Routes } from "discord-api-types/v10"; import { resolveThreadBindingConversationIdFromBindingId } from "../../channels/thread-binding-id.js"; +import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; import { registerSessionBindingAdapter, @@ -162,6 +163,7 @@ export function createThreadBindingManager( params: { accountId?: string; token?: string; + cfg?: OpenClawConfig; persist?: boolean; enableSweeper?: boolean; idleTimeoutMs?: number; @@ -188,6 +190,7 @@ export function createThreadBindingManager( params.maxAgeMs, DEFAULT_THREAD_BINDING_MAX_AGE_MS, ); + const resolveCurrentCfg = () => getRuntimeConfigSnapshot() ?? params.cfg; const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token; let sweepTimer: NodeJS.Timeout | null = null; @@ -255,6 +258,7 @@ export function createThreadBindingManager( return nextRecord; }, bindTarget: async (bindParams) => { + const cfg = resolveCurrentCfg(); let threadId = normalizeThreadId(bindParams.threadId); let channelId = bindParams.channelId?.trim() || ""; @@ -268,6 +272,7 @@ export function createThreadBindingManager( }); threadId = (await createThreadForBinding({ + cfg, accountId, token: resolveCurrentToken(), channelId, @@ -282,6 +287,7 @@ export function createThreadBindingManager( if (!channelId) { channelId = (await resolveChannelIdForBinding({ + cfg, accountId, token: resolveCurrentToken(), threadId, @@ -307,6 +313,7 @@ export function createThreadBindingManager( } if (!webhookId || !webhookToken) { const createdWebhook = await createWebhookForChannel({ + cfg, accountId, token: resolveCurrentToken(), channelId, @@ -340,7 +347,7 @@ export function createThreadBindingManager( const introText = bindParams.introText?.trim(); if (introText) { - void maybeSendBindingMessage({ record, text: introText }); + void maybeSendBindingMessage({ cfg, record, text: introText }); } return record; }, @@ -365,6 +372,7 @@ export function createThreadBindingManager( saveBindingsToDisk(); } if (unbindParams.sendFarewell !== false) { + const cfg = resolveCurrentCfg(); const farewell = resolveThreadBindingFarewellText({ reason: unbindParams.reason, farewellText: unbindParams.farewellText, @@ -379,7 +387,12 @@ 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({ record: removed, text: farewell, preferWebhook: false }); + void maybeSendBindingMessage({ + cfg, + record: removed, + text: farewell, + preferWebhook: false, + }); } return removed; }, @@ -433,10 +446,14 @@ export function createThreadBindingManager( } let rest; try { - rest = createDiscordRestClient({ - accountId, - token: resolveCurrentToken(), - }).rest; + const cfg = resolveCurrentCfg(); + rest = createDiscordRestClient( + { + accountId, + token: resolveCurrentToken(), + }, + cfg, + ).rest; } catch { return; } @@ -561,8 +578,10 @@ export function createThreadBindingManager( if (placement === "child") { createThread = true; if (!channelId && conversationId) { + const cfg = resolveCurrentCfg(); channelId = (await resolveChannelIdForBinding({ + cfg, accountId, token: resolveCurrentToken(), threadId: conversationId, diff --git a/src/infra/outbound/cfg-threading.guard.test.ts b/src/infra/outbound/cfg-threading.guard.test.ts index 306170281c8..ff4d0533c1b 100644 --- a/src/infra/outbound/cfg-threading.guard.test.ts +++ b/src/infra/outbound/cfg-threading.guard.test.ts @@ -59,6 +59,15 @@ function listExtensionFiles(): { }; } +function listHighRiskRuntimeCfgFiles(): string[] { + return [ + "src/agents/tools/telegram-actions.ts", + "src/discord/monitor/reply-delivery.ts", + "src/discord/monitor/thread-bindings.discord-api.ts", + "src/discord/monitor/thread-bindings.manager.ts", + ]; +} + function extractOutboundBlock(source: string, file: string): string { const outboundKeyIndex = source.indexOf("outbound:"); expect(outboundKeyIndex, `${file} should define outbound:`).toBeGreaterThanOrEqual(0); @@ -176,4 +185,12 @@ describe("outbound cfg-threading guard", () => { ); } }); + + it("keeps high-risk runtime delivery paths free of loadConfig calls", () => { + const runtimeFiles = listHighRiskRuntimeCfgFiles(); + for (const file of runtimeFiles) { + const source = readRepoFile(file); + expect(source, `${file} must not call loadConfig`).not.toMatch(loadConfigPattern); + } + }); }); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index fa26df0209a..44e18ee2340 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -80,6 +80,7 @@ type TelegramMessageLike = { }; type TelegramReactionOpts = { + cfg?: ReturnType; token?: string; accountId?: string; api?: TelegramApiOverride; @@ -1020,6 +1021,7 @@ export async function reactMessageTelegram( } type TelegramDeleteOpts = { + cfg?: ReturnType; token?: string; accountId?: string; verbose?: boolean; @@ -1234,6 +1236,7 @@ function inferFilename(kind: MediaKind) { } type TelegramStickerOpts = { + cfg?: ReturnType; token?: string; accountId?: string; verbose?: boolean; @@ -1426,9 +1429,10 @@ export async function sendPollTelegram( // --------------------------------------------------------------------------- type TelegramCreateForumTopicOpts = { + cfg?: ReturnType; token?: string; accountId?: string; - api?: Bot["api"]; + api?: TelegramApiOverride; verbose?: boolean; retry?: RetryConfig; /** Icon color for the topic (must be one of 0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F). */ @@ -1464,16 +1468,9 @@ export async function createForumTopicTelegram( throw new Error("Forum topic name must be 128 characters or fewer"); } - const cfg = loadConfig(); - const account = resolveTelegramAccount({ - cfg, - accountId: opts.accountId, - }); - const token = resolveToken(opts.token, account); + const { cfg, account, api } = resolveTelegramApiContext(opts); // Accept topic-qualified targets (e.g. telegram:group::topic:) // but createForumTopic must always target the base supergroup chat id. - const client = resolveTelegramClientOptions(account); - const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const target = parseTelegramTarget(chatId); const normalizedChatId = await resolveAndPersistChatId({ cfg,