mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(telegram): restore named-account DM fallback routing (from #32426)
Rebased and landed contributor work from @chengzhichao-xydt for the Telegram multi-account DM regression in #32351. Co-authored-by: Zhichao Cheng <cheng.zhichao@xydigit.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
147
src/telegram/bot-message-context.named-account-dm.test.ts
Normal file
147
src/telegram/bot-message-context.named-account-dm.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof resolveTelegramConversationRoute>["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
|
||||
|
||||
Reference in New Issue
Block a user