diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index ff71c7fe4..c9677c7f7 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -20,7 +20,10 @@ vi.mock("../agents/model-catalog.js", () => ({ import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; -import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; +import { + parseTelegramTarget, + runCronIsolatedAgentTurn, +} from "./isolated-agent.js"; async function withTempHome(fn: (home: string) => Promise): Promise { const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-")); @@ -670,3 +673,47 @@ 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, + }); + }); +}); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index a7d756a1a..0cc056c5a 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -50,6 +50,36 @@ 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; +} { + const trimmed = to.trim(); + + // 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; @@ -487,7 +517,7 @@ export async function runCronIsolatedAgentTurn(params: { summary: "Delivery skipped (no Telegram chatId).", }; } - const chatId = resolvedDelivery.to; + const { chatId, topicId } = parseTelegramTarget(resolvedDelivery.to); const textLimit = resolveTextChunkLimit(params.cfg, "telegram"); try { for (const payload of payloads) { @@ -501,6 +531,7 @@ export async function runCronIsolatedAgentTurn(params: { await params.deps.sendMessageTelegram(chatId, chunk, { verbose: false, token: telegramToken || undefined, + messageThreadId: topicId, }); } } else { @@ -512,6 +543,7 @@ export async function runCronIsolatedAgentTurn(params: { verbose: false, mediaUrl: url, token: telegramToken || undefined, + messageThreadId: topicId, }); } }