diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d9b910d01..2a87df0e76f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,7 @@ Docs: https://docs.openclaw.ai - Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress `NO_REPLY` lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus. - Telegram/`groupAllowFrom` sender-ID validation: restore sender-only runtime validation so negative chat/group IDs remain invalid entries instead of appearing accepted while still being unable to authorize group access. (#37134) Thanks @qiuyuemartin-max and @vincentkoc. - Telegram/native group command auth: authorize native commands in groups and forum topics against `groupAllowFrom` and per-group/topic sender overrides, while keeping auth rejection replies in the originating topic thread. (#39267) Thanks @edwluo. +- Telegram/named-account DMs: restore non-default-account DM routing when a named Telegram account falls back to the default agent by keeping groups fail-closed but deriving a per-account session key for DMs, including identity-link canonicalization and regression coverage for account isolation. (from #32426; fixes #32351) Thanks @chengzhichao-xydt. - Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow. - Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow. - Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow. diff --git a/src/telegram/bot-message-context.named-account-dm.test.ts b/src/telegram/bot-message-context.named-account-dm.test.ts new file mode 100644 index 00000000000..e7ddf345505 --- /dev/null +++ b/src/telegram/bot-message-context.named-account-dm.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; +import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; + +describe("buildTelegramMessageContext named-account DM fallback", () => { + const baseCfg = { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + }; + + afterEach(() => { + clearRuntimeConfigSnapshot(); + }); + + it("allows DM through for a named account with no explicit binding", async () => { + setRuntimeConfigSnapshot(baseCfg); + + const ctx = await buildTelegramMessageContextForTest({ + cfg: baseCfg, + accountId: "atlas", + message: { + message_id: 1, + chat: { id: 814912386, type: "private" }, + date: 1700000000, + text: "hello", + from: { id: 814912386, first_name: "Alice" }, + }, + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.route.matchedBy).toBe("default"); + expect(ctx?.route.accountId).toBe("atlas"); + }); + + it("uses a per-account session key for named-account DMs", async () => { + setRuntimeConfigSnapshot(baseCfg); + + const ctx = await buildTelegramMessageContextForTest({ + cfg: baseCfg, + accountId: "atlas", + message: { + message_id: 1, + chat: { id: 814912386, type: "private" }, + date: 1700000000, + text: "hello", + from: { id: 814912386, first_name: "Alice" }, + }, + }); + + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386"); + }); + + it("isolates sessions between named accounts that share the default agent", async () => { + setRuntimeConfigSnapshot(baseCfg); + + const atlas = await buildTelegramMessageContextForTest({ + cfg: baseCfg, + accountId: "atlas", + message: { + message_id: 1, + chat: { id: 814912386, type: "private" }, + date: 1700000000, + text: "hello", + from: { id: 814912386, first_name: "Alice" }, + }, + }); + const skynet = await buildTelegramMessageContextForTest({ + cfg: baseCfg, + accountId: "skynet", + message: { + message_id: 2, + chat: { id: 814912386, type: "private" }, + date: 1700000001, + text: "hello", + from: { id: 814912386, first_name: "Alice" }, + }, + }); + + expect(atlas?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386"); + expect(skynet?.ctxPayload?.SessionKey).toBe("agent:main:telegram:skynet:direct:814912386"); + expect(atlas?.ctxPayload?.SessionKey).not.toBe(skynet?.ctxPayload?.SessionKey); + }); + + it("keeps identity-linked peer canonicalization in the named-account fallback path", async () => { + const cfg = { + ...baseCfg, + session: { + identityLinks: { + "alice-shared": ["telegram:814912386"], + }, + }, + }; + setRuntimeConfigSnapshot(cfg); + + const ctx = await buildTelegramMessageContextForTest({ + cfg, + accountId: "atlas", + message: { + message_id: 1, + chat: { id: 999999999, type: "private" }, + date: 1700000000, + text: "hello", + from: { id: 814912386, first_name: "Alice" }, + }, + }); + + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:alice-shared"); + }); + + it("still drops named-account group messages without an explicit binding", async () => { + setRuntimeConfigSnapshot(baseCfg); + + const ctx = await buildTelegramMessageContextForTest({ + cfg: baseCfg, + accountId: "atlas", + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + message: { + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000000, + text: "@bot hello", + from: { id: 814912386, first_name: "Alice" }, + }, + }); + + expect(ctx).toBeNull(); + }); + + it("does not change the default-account DM session key", async () => { + setRuntimeConfigSnapshot(baseCfg); + + const ctx = await buildTelegramMessageContextForTest({ + cfg: baseCfg, + message: { + message_id: 1, + chat: { id: 42, type: "private" }, + date: 1700000000, + text: "hello", + from: { id: 42, first_name: "Alice" }, + }, + }); + + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); + }); +}); diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index ab628dc0e0a..88e530a9828 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -39,6 +39,7 @@ import type { } from "../config/types.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; +import { buildAgentSessionKey } from "../routing/resolve-route.js"; import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; @@ -55,6 +56,7 @@ import { buildTelegramGroupFrom, buildTelegramGroupPeerId, buildTypingThreadParams, + resolveTelegramDirectPeerId, resolveTelegramMediaPlaceholder, expandTextLinks, normalizeForwardedContext, @@ -208,9 +210,10 @@ export const buildTelegramMessageContext = async ({ const requiresExplicitAccountBinding = ( candidate: ReturnType["route"], ): boolean => candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default"; - // Fail closed for named Telegram accounts when route resolution falls back to - // default-agent routing. This prevents cross-account DM/session contamination. - if (requiresExplicitAccountBinding(route)) { + const isNamedAccountFallback = requiresExplicitAccountBinding(route); + // Named-account groups still require an explicit binding; DMs get a + // per-account fallback session key below to preserve isolation. + if (isNamedAccountFallback && isGroup) { logInboundDrop({ log: logVerbose, channel: "telegram", @@ -337,7 +340,22 @@ export const buildTelegramMessageContext = async ({ return false; }; - const baseSessionKey = route.sessionKey; + const baseSessionKey = isNamedAccountFallback + ? buildAgentSessionKey({ + agentId: route.agentId, + channel: "telegram", + accountId: route.accountId, + peer: { + kind: "direct", + id: resolveTelegramDirectPeerId({ + chatId, + senderId, + }), + }, + dmScope: "per-account-channel-peer", + identityLinks: freshCfg.session?.identityLinks, + }).toLowerCase() + : route.sessionKey; // DMs: use thread suffix for session isolation (works regardless of dmScope) const threadKeys = dmThreadId != null