fix(telegram): parse scoped dm thread ids in outbound recovery

This commit is contained in:
Brian Le
2026-03-01 21:00:00 -05:00
committed by Peter Steinberger
parent 0b0c26eb9d
commit 0bcbd749ba
4 changed files with 56 additions and 4 deletions

View File

@@ -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: "<b>hello</b>",
accountId: "work",
threadId: "12345:99",
deps: { sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledWith(
"12345",
"<b>hello</b>",
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;

View File

@@ -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:<id>).
const chatType = resolveTelegramTargetChatType(params.target);
// If the target is a username and we lack a resolvedTarget, default to DM to avoid group keys.

View File

@@ -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) {

View File

@@ -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 "<chatId>:<threadId>".
const scopedMatch = /^-?\d+:(-?\d+)$/.exec(trimmed);
const rawThreadId = scopedMatch ? scopedMatch[1] : trimmed;
return parseIntegerId(rawThreadId);
}