diff --git a/src/agents/tools/nodes-tool.test.ts b/src/agents/tools/nodes-tool.test.ts index b7f787ff0..12ac63e44 100644 --- a/src/agents/tools/nodes-tool.test.ts +++ b/src/agents/tools/nodes-tool.test.ts @@ -25,20 +25,20 @@ const screenMocks = vi.hoisted(() => ({ })); vi.mock("./gateway.js", () => ({ - callGatewayTool: (...args: unknown[]) => gatewayMocks.callGatewayTool(...args), - readGatewayCallOptions: (...args: unknown[]) => gatewayMocks.readGatewayCallOptions(...args), + callGatewayTool: gatewayMocks.callGatewayTool, + readGatewayCallOptions: gatewayMocks.readGatewayCallOptions, })); vi.mock("./nodes-utils.js", () => ({ - resolveNodeId: (...args: unknown[]) => nodeUtilsMocks.resolveNodeId(...args), - listNodes: (...args: unknown[]) => nodeUtilsMocks.listNodes(...args), - resolveNodeIdFromList: (...args: unknown[]) => nodeUtilsMocks.resolveNodeIdFromList(...args), + resolveNodeId: nodeUtilsMocks.resolveNodeId, + listNodes: nodeUtilsMocks.listNodes, + resolveNodeIdFromList: nodeUtilsMocks.resolveNodeIdFromList, })); vi.mock("../../cli/nodes-screen.js", () => ({ - parseScreenRecordPayload: (...args: unknown[]) => screenMocks.parseScreenRecordPayload(...args), - screenRecordTempPath: (...args: unknown[]) => screenMocks.screenRecordTempPath(...args), - writeScreenRecordToFile: (...args: unknown[]) => screenMocks.writeScreenRecordToFile(...args), + parseScreenRecordPayload: screenMocks.parseScreenRecordPayload, + screenRecordTempPath: screenMocks.screenRecordTempPath, + writeScreenRecordToFile: screenMocks.writeScreenRecordToFile, })); import { createNodesTool } from "./nodes-tool.js"; diff --git a/src/channels/plugins/outbound/telegram.test.ts b/src/channels/plugins/outbound/telegram.test.ts index 13668f752..df81947fa 100644 --- a/src/channels/plugins/outbound/telegram.test.ts +++ b/src/channels/plugins/outbound/telegram.test.ts @@ -32,6 +32,32 @@ describe("telegramOutbound", () => { expect(result).toEqual({ channel: "telegram", messageId: "tg-text-1", chatId: "123" }); }); + it("parses scoped DM thread ids for sendText", async () => { + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-text-2", chatId: "12345" }); + const sendText = telegramOutbound.sendText; + expect(sendText).toBeDefined(); + + await sendText!({ + cfg: {}, + to: "12345", + text: "hello", + accountId: "work", + threadId: "12345:99", + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledWith( + "12345", + "hello", + expect.objectContaining({ + textMode: "html", + verbose: false, + accountId: "work", + messageThreadId: 99, + }), + ); + }); + it("passes media options for sendMedia", async () => { const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-media-1", chatId: "123" }); const sendMedia = telegramOutbound.sendMedia; diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index 1d8140f2e..641bdac31 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -20,6 +20,7 @@ import { normalizeAllowListLower } from "../../slack/monitor/allow-list.js"; import { parseSlackTarget } from "../../slack/targets.js"; import { buildTelegramGroupPeerId } from "../../telegram/bot/helpers.js"; import { resolveTelegramTargetChatType } from "../../telegram/inline-buttons.js"; +import { parseTelegramThreadId } from "../../telegram/outbound-params.js"; import { parseTelegramTarget } from "../../telegram/targets.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import type { ResolvedMessagingTarget } from "./target-resolver.js"; @@ -283,8 +284,7 @@ function resolveTelegramSession( } const parsedThreadId = parsed.messageThreadId; const fallbackThreadId = normalizeThreadId(params.threadId); - const resolvedThreadId = - parsedThreadId ?? (fallbackThreadId ? Number.parseInt(fallbackThreadId, 10) : undefined); + const resolvedThreadId = parsedThreadId ?? parseTelegramThreadId(fallbackThreadId); // Telegram topics are encoded in the peer id (chatId:topic:). const chatType = resolveTelegramTargetChatType(params.target); // If the target is a username and we lack a resolvedTarget, default to DM to avoid group keys. diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 01cdaf3e7..4ce6afd06 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -891,6 +891,7 @@ describe("resolveOutboundSessionRoute", () => { channel: string; target: string; replyToId?: string; + threadId?: string; expected: { sessionKey: string; from?: string; @@ -934,6 +935,20 @@ describe("resolveOutboundSessionRoute", () => { chatType: "direct", }, }, + { + name: "Telegram DM scoped threadId fallback", + cfg: perChannelPeerCfg, + channel: "telegram", + target: "12345", + threadId: "12345:99", + expected: { + sessionKey: "agent:main:telegram:direct:12345", + from: "telegram:12345", + to: "telegram:12345", + threadId: 99, + chatType: "direct", + }, + }, { name: "identity-links per-peer", cfg: identityLinksCfg, @@ -1018,6 +1033,7 @@ describe("resolveOutboundSessionRoute", () => { agentId: "main", target: testCase.target, replyToId: testCase.replyToId, + threadId: testCase.threadId, }); expect(route?.sessionKey, testCase.name).toBe(testCase.expected.sessionKey); if (testCase.expected.from !== undefined) { diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index 5350baae8..17be565c8 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -84,8 +84,11 @@ function resolveAccountConfig( } function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig { - const { accounts: _ignored, groups: channelGroups, ...base } = (cfg.channels?.telegram ?? - {}) as TelegramAccountConfig & { accounts?: unknown }; + const { + accounts: _ignored, + groups: channelGroups, + ...base + } = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & { accounts?: unknown }; const account = resolveAccountConfig(cfg, accountId) ?? {}; // In multi-account setups, channel-level `groups` must NOT be inherited by diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 096c7f6a7..59e84ab0f 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -290,7 +290,7 @@ export const registerTelegramHandlers = ({ const dmThreadId = !params.isGroup ? params.messageThreadId : undefined; const threadKeys = dmThreadId != null - ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) + ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` }) : null; const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId }); diff --git a/src/telegram/bot-message-context.dm-threads.test.ts b/src/telegram/bot-message-context.dm-threads.test.ts index 1132a2e07..26812b4c8 100644 --- a/src/telegram/bot-message-context.dm-threads.test.ts +++ b/src/telegram/bot-message-context.dm-threads.test.ts @@ -19,7 +19,7 @@ describe("buildTelegramMessageContext dm thread sessions", () => { expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); - expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:42"); + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42"); }); it("keeps legacy dm session key when no thread id", async () => { diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 495af5872..10aa207c7 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -204,7 +204,7 @@ export const buildTelegramMessageContext = async ({ const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; const threadKeys = dmThreadId != null - ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) + ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` }) : null; const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; const mentionRegexes = buildMentionRegexes(cfg, route.agentId); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index e2e615ea7..cc9846cbc 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -551,7 +551,7 @@ export const registerTelegramNativeCommands = ({ dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, - threadId: String(dmThreadId), + threadId: `${chatId}:${dmThreadId}`, }) : null; const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index fd1f6e63d..bbd506982 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -928,7 +928,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; - expect(payload.CommandTargetSessionKey).toBe("agent:main:main:thread:99"); + expect(payload.CommandTargetSessionKey).toBe("agent:main:main:thread:12345:99"); }); it("allows native DM commands for paired users", async () => { diff --git a/src/telegram/outbound-params.ts b/src/telegram/outbound-params.ts index 1ad18e647..7dd3b7f11 100644 --- a/src/telegram/outbound-params.ts +++ b/src/telegram/outbound-params.ts @@ -6,6 +6,14 @@ export function parseTelegramReplyToMessageId(replyToId?: string | null): number return Number.isFinite(parsed) ? parsed : undefined; } +function parseIntegerId(value: string): number | undefined { + if (!/^-?\d+$/.test(value)) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + export function parseTelegramThreadId(threadId?: string | number | null): number | undefined { if (threadId == null) { return undefined; @@ -17,6 +25,8 @@ export function parseTelegramThreadId(threadId?: string | number | null): number if (!trimmed) { return undefined; } - const parsed = Number.parseInt(trimmed, 10); - return Number.isFinite(parsed) ? parsed : undefined; + // DM topic session keys may scope thread ids as ":". + const scopedMatch = /^-?\d+:(-?\d+)$/.exec(trimmed); + const rawThreadId = scopedMatch ? scopedMatch[1] : trimmed; + return parseIntegerId(rawThreadId); }