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