diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index dc37e762e..ff71c7fe4 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -20,10 +20,7 @@ vi.mock("../agents/model-catalog.js", () => ({ import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; -import { - parseTelegramTarget, - runCronIsolatedAgentTurn, -} from "./isolated-agent.js"; +import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; async function withTempHome(fn: (home: string) => Promise): Promise { const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-")); @@ -673,63 +670,3 @@ describe("runCronIsolatedAgentTurn", () => { }); }); }); - -describe("parseTelegramTarget", () => { - it("parses plain chatId", () => { - expect(parseTelegramTarget("-1001234567890")).toEqual({ - chatId: "-1001234567890", - topicId: undefined, - }); - }); - - it("parses @username", () => { - expect(parseTelegramTarget("@mychannel")).toEqual({ - chatId: "@mychannel", - topicId: undefined, - }); - }); - - it("parses chatId:topicId format", () => { - expect(parseTelegramTarget("-1001234567890:123")).toEqual({ - chatId: "-1001234567890", - topicId: 123, - }); - }); - - it("parses chatId:topic:topicId format", () => { - expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({ - chatId: "-1001234567890", - topicId: 456, - }); - }); - - it("trims whitespace", () => { - expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({ - chatId: "-1001234567890", - topicId: 99, - }); - }); - - it("does not treat non-numeric suffix as topicId", () => { - expect(parseTelegramTarget("-1001234567890:abc")).toEqual({ - chatId: "-1001234567890:abc", - topicId: undefined, - }); - }); - - it("strips internal telegram prefix", () => { - expect(parseTelegramTarget("telegram:123")).toEqual({ - chatId: "123", - topicId: undefined, - }); - }); - - it("strips internal telegram + group prefixes before parsing topic", () => { - expect( - parseTelegramTarget("telegram:group:-1001234567890:topic:456"), - ).toEqual({ - chatId: "-1001234567890", - topicId: 456, - }); - }); -}); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index fa093719f..aa803dcac 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -50,45 +50,6 @@ import { resolveTelegramToken } from "../telegram/token.js"; import { normalizeE164 } from "../utils.js"; import type { CronJob } from "./types.js"; -/** - * Parse a Telegram delivery target into chatId and optional topicId. - * Supports formats: - * - `chatId` (plain chat ID or @username) - * - `chatId:topicId` (chat ID with topic/thread ID) - * - `chatId:topic:topicId` (alternative format with explicit "topic" marker) - */ -export function parseTelegramTarget(to: string): { - chatId: string; - topicId: number | undefined; -} { - let trimmed = to.trim(); - - // Cron "lastTo" values can include internal prefixes like `telegram:...` or - // `telegram:group:...` (see normalizeChatId in telegram/send.ts). - // Strip these before parsing `:topic:` / `:` suffixes. - while (true) { - const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim(); - if (next === trimmed) break; - trimmed = next; - } - - // Try format: chatId:topic:topicId - const topicMatch = /^(.+?):topic:(\d+)$/.exec(trimmed); - if (topicMatch) { - return { chatId: topicMatch[1], topicId: parseInt(topicMatch[2], 10) }; - } - - // Try format: chatId:topicId (where topicId is numeric) - // Be careful not to match @username or other non-numeric suffixes - const colonMatch = /^(.+):(\d+)$/.exec(trimmed); - if (colonMatch) { - return { chatId: colonMatch[1], topicId: parseInt(colonMatch[2], 10) }; - } - - // Plain chatId, no topic - return { chatId: trimmed, topicId: undefined }; -} - export type RunCronAgentTurnResult = { status: "ok" | "error" | "skipped"; summary?: string; @@ -526,7 +487,6 @@ export async function runCronIsolatedAgentTurn(params: { summary: "Delivery skipped (no Telegram chatId).", }; } - const { chatId, topicId } = parseTelegramTarget(resolvedDelivery.to); const textLimit = resolveTextChunkLimit(params.cfg, "telegram"); try { for (const payload of payloads) { @@ -537,23 +497,29 @@ export async function runCronIsolatedAgentTurn(params: { payload.text ?? "", textLimit, )) { - await params.deps.sendMessageTelegram(chatId, chunk, { - verbose: false, - token: telegramToken || undefined, - messageThreadId: topicId, - }); + await params.deps.sendMessageTelegram( + resolvedDelivery.to, + chunk, + { + verbose: false, + token: telegramToken || undefined, + }, + ); } } else { let first = true; for (const url of mediaList) { const caption = first ? (payload.text ?? "") : ""; first = false; - await params.deps.sendMessageTelegram(chatId, caption, { - verbose: false, - mediaUrl: url, - token: telegramToken || undefined, - messageThreadId: topicId, - }); + await params.deps.sendMessageTelegram( + resolvedDelivery.to, + caption, + { + verbose: false, + mediaUrl: url, + token: telegramToken || undefined, + }, + ); } } } diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index ed192f85d..dfcb5c7f6 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -302,6 +302,31 @@ describe("sendMessageTelegram", () => { }); }); + it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => { + const chatId = "-1001234567890"; + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 55, + chat: { id: chatId }, + }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + await sendMessageTelegram( + `telegram:group:${chatId}:topic:271`, + "hello forum", + { + token: "tok", + api, + }, + ); + + expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", { + parse_mode: "HTML", + message_thread_id: 271, + }); + }); + it("includes reply_to_message_id for threaded replies", async () => { const chatId = "123"; const sendMessage = vi.fn().mockResolvedValue({ diff --git a/src/telegram/send.ts b/src/telegram/send.ts index d0715c22c..4a72747fa 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -11,6 +11,10 @@ import { loadWebMedia } from "../web/media.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramFetch } from "./fetch.js"; import { markdownToTelegramHtml } from "./format.js"; +import { + parseTelegramTarget, + stripTelegramInternalPrefixes, +} from "./targets.js"; type TelegramSendOpts = { token?: string; @@ -65,7 +69,7 @@ function normalizeChatId(to: string): string { // Common internal prefixes that sometimes leak into outbound sends. // - ctx.To uses `telegram:` // - group sessions often use `telegram:group:` - let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim(); + let normalized = stripTelegramInternalPrefixes(trimmed); // Accept t.me links for public chats/channels. // (Invite links like `t.me/+...` are not resolvable via Bot API.) @@ -110,7 +114,8 @@ export async function sendMessageTelegram( accountId: opts.accountId, }); const token = resolveToken(opts.token, account); - const chatId = normalizeChatId(to); + const target = parseTelegramTarget(to); + const chatId = normalizeChatId(target.chatId); // Use provided api or create a new Bot instance. The nullish coalescing // operator ensures api is always defined (Bot.api is always non-null). const fetchImpl = resolveTelegramFetch(); @@ -123,8 +128,12 @@ export async function sendMessageTelegram( // Build optional params for forum topics and reply threading. // Only include these if actually provided to keep API calls clean. const threadParams: Record = {}; - if (opts.messageThreadId != null) { - threadParams.message_thread_id = Math.trunc(opts.messageThreadId); + const messageThreadId = + opts.messageThreadId != null + ? opts.messageThreadId + : target.messageThreadId; + if (messageThreadId != null) { + threadParams.message_thread_id = Math.trunc(messageThreadId); } if (opts.replyToMessageId != null) { threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId); diff --git a/src/telegram/targets.test.ts b/src/telegram/targets.test.ts new file mode 100644 index 000000000..e2bbd6c9f --- /dev/null +++ b/src/telegram/targets.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { + parseTelegramTarget, + stripTelegramInternalPrefixes, +} from "./targets.js"; + +describe("stripTelegramInternalPrefixes", () => { + it("strips telegram prefix", () => { + expect(stripTelegramInternalPrefixes("telegram:123")).toBe("123"); + }); + + it("strips telegram+group prefixes", () => { + expect(stripTelegramInternalPrefixes("telegram:group:-100123")).toBe( + "-100123", + ); + }); + + it("is idempotent", () => { + expect(stripTelegramInternalPrefixes("@mychannel")).toBe("@mychannel"); + }); +}); + +describe("parseTelegramTarget", () => { + it("parses plain chatId", () => { + expect(parseTelegramTarget("-1001234567890")).toEqual({ + chatId: "-1001234567890", + }); + }); + + it("parses @username", () => { + expect(parseTelegramTarget("@mychannel")).toEqual({ + chatId: "@mychannel", + }); + }); + + it("parses chatId:topicId format", () => { + expect(parseTelegramTarget("-1001234567890:123")).toEqual({ + chatId: "-1001234567890", + messageThreadId: 123, + }); + }); + + it("parses chatId:topic:topicId format", () => { + expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({ + chatId: "-1001234567890", + messageThreadId: 456, + }); + }); + + it("trims whitespace", () => { + expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({ + chatId: "-1001234567890", + messageThreadId: 99, + }); + }); + + it("does not treat non-numeric suffix as topicId", () => { + expect(parseTelegramTarget("-1001234567890:abc")).toEqual({ + chatId: "-1001234567890:abc", + }); + }); + + it("strips internal prefixes before parsing", () => { + expect( + parseTelegramTarget("telegram:group:-1001234567890:topic:456"), + ).toEqual({ + chatId: "-1001234567890", + messageThreadId: 456, + }); + }); +}); diff --git a/src/telegram/targets.ts b/src/telegram/targets.ts new file mode 100644 index 000000000..fd0f0e379 --- /dev/null +++ b/src/telegram/targets.ts @@ -0,0 +1,43 @@ +export type TelegramTarget = { + chatId: string; + messageThreadId?: number; +}; + +export function stripTelegramInternalPrefixes(to: string): string { + let trimmed = to.trim(); + while (true) { + const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim(); + if (next === trimmed) return trimmed; + trimmed = next; + } +} + +/** + * Parse a Telegram delivery target into chatId and optional topic/thread ID. + * + * Supported formats: + * - `chatId` (plain chat ID, t.me link, @username, or internal prefixes like `telegram:...`) + * - `chatId:topicId` (numeric topic/thread ID) + * - `chatId:topic:topicId` (explicit topic marker; preferred) + */ +export function parseTelegramTarget(to: string): TelegramTarget { + const normalized = stripTelegramInternalPrefixes(to); + + const topicMatch = /^(.+?):topic:(\d+)$/.exec(normalized); + if (topicMatch) { + return { + chatId: topicMatch[1], + messageThreadId: Number.parseInt(topicMatch[2], 10), + }; + } + + const colonMatch = /^(.+):(\d+)$/.exec(normalized); + if (colonMatch) { + return { + chatId: colonMatch[1], + messageThreadId: Number.parseInt(colonMatch[2], 10), + }; + } + + return { chatId: normalized }; +}