fix: thread runtime config through Discord/Telegram sends (#42352) (thanks @joshavant) (#42352)

This commit is contained in:
Josh Avant
2026-03-10 13:30:57 -05:00
committed by GitHub
parent c2d9386796
commit 0687e04760
21 changed files with 531 additions and 46 deletions

View File

@@ -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

View File

@@ -168,6 +168,7 @@ openclaw pairing approve discord <CODE>
<Note>
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.
</Note>
## Recommended: Set up a guild workspace

View File

@@ -410,6 +410,7 @@ curl "https://api.telegram.org/bot<bot_token>/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)

View File

@@ -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:<id>` (DM) or `channel:<id>` (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.

View File

@@ -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

View File

@@ -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<typeof captureEnv>;
vi.mock("../../telegram/send.js", () => ({
@@ -30,6 +40,10 @@ vi.mock("../../telegram/send.js", () => ({
sendStickerTelegram(...args),
deleteMessageTelegram: (...args: Parameters<typeof deleteMessageTelegram>) =>
deleteMessageTelegram(...args),
editMessageTelegram: (...args: Parameters<typeof editMessageTelegram>) =>
editMessageTelegram(...args),
createForumTopicTelegram: (...args: Parameters<typeof createForumTopicTelegram>) =>
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<string, unknown>,
) => 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<string, unknown>,
) => 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<string, unknown>,
) => 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<string, unknown>,
) => 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<string, unknown>,
) => 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<string, unknown>,
) => 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<string, unknown>,
) => readCallOpts(createForumTopicTelegram.mock.calls as unknown[][], 2),
},
])("forwards resolved cfg for $name action", async ({ params, cfg, assertCall }) => {
const readCallOpts = (calls: unknown[][], argIndex: number): Record<string, unknown> => {
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<string, unknown>;
};
await handleTelegramAction(params as Record<string, unknown>, cfg);
const opts = assertCall(readCallOpts);
expect(opts.cfg).toBe(cfg);
});
it.each([
{
name: "media",

View File

@@ -154,6 +154,7 @@ export async function handleTelegramAction(
let reactionResult: Awaited<ReturnType<typeof reactMessageTelegram>>;
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,

View File

@@ -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);
});
});

View File

@@ -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<typeof loadConfig>;
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<typeof loadConfig>;
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<typeof loadConfig>,
) {
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<typeof loadConfig>,
): { 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;
}

View File

@@ -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,

View File

@@ -684,6 +684,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
const replyToId = replyReference.use();
await deliverDiscordReply({
cfg,
replies: [payload],
target: deliverTarget,
token,

View File

@@ -441,6 +441,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
? createThreadBindingManager({
accountId: account.accountId,
token,
cfg,
idleTimeoutMs: threadBindingIdleTimeoutMs,
maxAgeMs: threadBindingMaxAgeMs,
})

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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,
},

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}
});
});

View File

@@ -80,6 +80,7 @@ type TelegramMessageLike = {
};
type TelegramReactionOpts = {
cfg?: ReturnType<typeof loadConfig>;
token?: string;
accountId?: string;
api?: TelegramApiOverride;
@@ -1020,6 +1021,7 @@ export async function reactMessageTelegram(
}
type TelegramDeleteOpts = {
cfg?: ReturnType<typeof loadConfig>;
token?: string;
accountId?: string;
verbose?: boolean;
@@ -1234,6 +1236,7 @@ function inferFilename(kind: MediaKind) {
}
type TelegramStickerOpts = {
cfg?: ReturnType<typeof loadConfig>;
token?: string;
accountId?: string;
verbose?: boolean;
@@ -1426,9 +1429,10 @@ export async function sendPollTelegram(
// ---------------------------------------------------------------------------
type TelegramCreateForumTopicOpts = {
cfg?: ReturnType<typeof loadConfig>;
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:<id>:topic:<thread>)
// 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,