mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
91
src/discord/client.test.ts
Normal file
91
src/discord/client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -684,6 +684,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
|
||||
const replyToId = replyReference.use();
|
||||
await deliverDiscordReply({
|
||||
cfg,
|
||||
replies: [payload],
|
||||
target: deliverTarget,
|
||||
token,
|
||||
|
||||
@@ -441,6 +441,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
? createThreadBindingManager({
|
||||
accountId: account.accountId,
|
||||
token,
|
||||
cfg,
|
||||
idleTimeoutMs: threadBindingIdleTimeoutMs,
|
||||
maxAgeMs: threadBindingMaxAgeMs,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user