From dcc52850c33c6f8cfd65de7a6c29bcd92ed86d0d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 23 Feb 2026 09:13:35 +0530 Subject: [PATCH] fix: persist resolved telegram delivery targets at runtime --- src/channels/plugins/normalize/telegram.ts | 34 +--- src/telegram/send.test-harness.ts | 13 +- src/telegram/send.test.ts | 70 +++++++- src/telegram/send.ts | 150 +++++++++++----- src/telegram/target-writeback.test.ts | 146 ++++++++++++++++ src/telegram/target-writeback.ts | 193 +++++++++++++++++++++ src/telegram/targets.test.ts | 58 ++++++- src/telegram/targets.ts | 45 ++++- 8 files changed, 632 insertions(+), 77 deletions(-) create mode 100644 src/telegram/target-writeback.test.ts create mode 100644 src/telegram/target-writeback.ts diff --git a/src/channels/plugins/normalize/telegram.ts b/src/channels/plugins/normalize/telegram.ts index ebbb852e877..b62f3ad0d7d 100644 --- a/src/channels/plugins/normalize/telegram.ts +++ b/src/channels/plugins/normalize/telegram.ts @@ -1,23 +1,7 @@ +import { normalizeTelegramLookupTarget } from "../../../telegram/targets.js"; + export function normalizeTelegramMessagingTarget(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) { - return undefined; - } - let normalized = trimmed; - if (normalized.startsWith("telegram:")) { - normalized = normalized.slice("telegram:".length).trim(); - } else if (normalized.startsWith("tg:")) { - normalized = normalized.slice("tg:".length).trim(); - } - if (!normalized) { - return undefined; - } - const tmeMatch = - /^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ?? - /^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized); - if (tmeMatch?.[1]) { - normalized = `@${tmeMatch[1]}`; - } + const normalized = normalizeTelegramLookupTarget(raw); if (!normalized) { return undefined; } @@ -25,15 +9,5 @@ export function normalizeTelegramMessagingTarget(raw: string): string | undefine } export function looksLikeTelegramTargetId(raw: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - if (/^(telegram|tg):/i.test(trimmed)) { - return true; - } - if (trimmed.startsWith("@")) { - return true; - } - return /^-?\d{6,}$/.test(trimmed); + return Boolean(normalizeTelegramLookupTarget(raw)); } diff --git a/src/telegram/send.test-harness.ts b/src/telegram/send.test-harness.ts index f211d39368d..57f47ac20d9 100644 --- a/src/telegram/send.test-harness.ts +++ b/src/telegram/send.test-harness.ts @@ -27,11 +27,16 @@ const { loadConfig } = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({})), })); +const { maybePersistResolvedTelegramTarget } = vi.hoisted(() => ({ + maybePersistResolvedTelegramTarget: vi.fn(async () => {}), +})); + type TelegramSendTestMocks = { botApi: Record; botCtorSpy: MockFn; loadConfig: MockFn; loadWebMedia: MockFn; + maybePersistResolvedTelegramTarget: MockFn; }; vi.mock("../web/media.js", () => ({ @@ -62,14 +67,20 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("./target-writeback.js", () => ({ + maybePersistResolvedTelegramTarget, +})); + export function getTelegramSendTestMocks(): TelegramSendTestMocks { - return { botApi, botCtorSpy, loadConfig, loadWebMedia }; + return { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget }; } export function installTelegramSendTestHooks() { beforeEach(() => { loadConfig.mockReturnValue({}); loadWebMedia.mockReset(); + maybePersistResolvedTelegramTarget.mockReset(); + maybePersistResolvedTelegramTarget.mockResolvedValue(undefined); botCtorSpy.mockReset(); for (const fn of Object.values(botApi)) { fn.mockReset(); diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 250f380509f..37d881d843c 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -9,7 +9,8 @@ import { clearSentMessageCache, recordSentMessage, wasSentByBot } from "./sent-m installTelegramSendTestHooks(); -const { botApi, botCtorSpy, loadConfig, loadWebMedia } = getTelegramSendTestMocks(); +const { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget } = + getTelegramSendTestMocks(); const { buildInlineKeyboard, createForumTopicTelegram, @@ -369,6 +370,48 @@ describe("sendMessageTelegram", () => { }); }); + it("resolves t.me targets to numeric chat ids via getChat", async () => { + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 1, + chat: { id: "-100123" }, + }); + const getChat = vi.fn().mockResolvedValue({ id: -100123 }); + const api = { sendMessage, getChat } as unknown as { + sendMessage: typeof sendMessage; + getChat: typeof getChat; + }; + + await sendMessageTelegram("https://t.me/mychannel", "hi", { + token: "tok", + api, + }); + + expect(getChat).toHaveBeenCalledWith("@mychannel"); + expect(sendMessage).toHaveBeenCalledWith("-100123", "hi", { + parse_mode: "HTML", + }); + expect(maybePersistResolvedTelegramTarget).toHaveBeenCalledWith( + expect.objectContaining({ + rawTarget: "https://t.me/mychannel", + resolvedChatId: "-100123", + }), + ); + }); + + it("fails clearly when a legacy target cannot be resolved", async () => { + const getChat = vi.fn().mockRejectedValue(new Error("400: Bad Request: chat not found")); + const api = { getChat } as unknown as { + getChat: typeof getChat; + }; + + await expect( + sendMessageTelegram("@missingchannel", "hi", { + token: "tok", + api, + }), + ).rejects.toThrow(/could not be resolved to a numeric chat ID/i); + }); + it("includes thread params in media messages", async () => { const chatId = "-1001234567890"; const sendPhoto = vi.fn().mockResolvedValue({ @@ -1100,6 +1143,31 @@ describe("reactMessageTelegram", () => { expect(setMessageReaction).toHaveBeenCalledWith("123", 456, testCase.expected); }); + + it("resolves legacy telegram targets before reacting", async () => { + const setMessageReaction = vi.fn().mockResolvedValue(undefined); + const getChat = vi.fn().mockResolvedValue({ id: -100123 }); + const api = { setMessageReaction, getChat } as unknown as { + setMessageReaction: typeof setMessageReaction; + getChat: typeof getChat; + }; + + await reactMessageTelegram("@mychannel", 456, "✅", { + token: "tok", + api, + }); + + expect(getChat).toHaveBeenCalledWith("@mychannel"); + expect(setMessageReaction).toHaveBeenCalledWith("-100123", 456, [ + { type: "emoji", emoji: "✅" }, + ]); + expect(maybePersistResolvedTelegramTarget).toHaveBeenCalledWith( + expect.objectContaining({ + rawTarget: "@mychannel", + resolvedChatId: "-100123", + }), + ); + }); }); describe("sendStickerTelegram", () => { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 56f666493c3..85327df22b5 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -29,7 +29,12 @@ import { renderTelegramHtmlText } from "./format.js"; import { isRecoverableTelegramNetworkError } from "./network-errors.js"; import { makeProxyFetch } from "./proxy.js"; import { recordSentMessage } from "./sent-message-cache.js"; -import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; +import { maybePersistResolvedTelegramTarget } from "./target-writeback.js"; +import { + normalizeTelegramChatId, + normalizeTelegramLookupTarget, + parseTelegramTarget, +} from "./targets.js"; import { resolveTelegramVoiceSend } from "./voice.js"; type TelegramApi = Bot["api"]; @@ -136,42 +141,56 @@ function resolveToken(explicit: string | undefined, params: { accountId: string; return params.token.trim(); } -function normalizeChatId(to: string): string { - const trimmed = to.trim(); - if (!trimmed) { - throw new Error("Recipient is required for Telegram sends"); +async function resolveChatId( + to: string, + params: { api: TelegramApiOverride; verbose?: boolean }, +): Promise { + const numericChatId = normalizeTelegramChatId(to); + if (numericChatId) { + return numericChatId; } + const lookupTarget = normalizeTelegramLookupTarget(to); + const getChat = params.api.getChat; + if (!lookupTarget || typeof getChat !== "function") { + throw new Error("Telegram recipient must be a numeric chat ID"); + } + try { + const chat = await getChat.call(params.api, lookupTarget); + const resolved = normalizeTelegramChatId(String(chat?.id ?? "")); + if (!resolved) { + throw new Error(`resolved chat id is not numeric (${String(chat?.id ?? "")})`); + } + if (params.verbose) { + sendLogger.warn(`telegram recipient ${lookupTarget} resolved to numeric chat id ${resolved}`); + } + return resolved; + } catch (err) { + const detail = formatErrorMessage(err); + throw new Error( + `Telegram recipient ${lookupTarget} could not be resolved to a numeric chat ID (${detail})`, + { cause: err }, + ); + } +} - // Common internal prefixes that sometimes leak into outbound sends. - // - ctx.To uses `telegram:` - // - group sessions often use `telegram:group:` - let normalized = stripTelegramInternalPrefixes(trimmed); - - // Accept t.me links for public chats/channels. - // (Invite links like `t.me/+...` are not resolvable via Bot API.) - const m = - /^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ?? - /^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized); - if (m?.[1]) { - normalized = `@${m[1]}`; - } - - if (!normalized) { - throw new Error("Recipient is required for Telegram sends"); - } - if (normalized.startsWith("@")) { - return normalized; - } - if (/^-?\d+$/.test(normalized)) { - return normalized; - } - - // If the user passed a username without `@`, assume they meant a public chat/channel. - if (/^[A-Za-z0-9_]{5,}$/i.test(normalized)) { - return `@${normalized}`; - } - - return normalized; +async function resolveAndPersistChatId(params: { + cfg: ReturnType; + api: TelegramApiOverride; + lookupTarget: string; + persistTarget: string; + verbose?: boolean; +}): Promise { + const chatId = await resolveChatId(params.lookupTarget, { + api: params.api, + verbose: params.verbose, + }); + await maybePersistResolvedTelegramTarget({ + cfg: params.cfg, + rawTarget: params.persistTarget, + resolvedChatId: chatId, + verbose: params.verbose, + }); + return chatId; } function normalizeMessageId(raw: string | number): number { @@ -434,7 +453,13 @@ export async function sendMessageTelegram( ): Promise { const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); - const chatId = normalizeChatId(target.chatId); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: target.chatId, + persistTarget: to, + verbose: opts.verbose, + }); const mediaUrl = opts.mediaUrl?.trim(); const replyMarkup = buildInlineKeyboard(opts.buttons); @@ -722,7 +747,14 @@ export async function reactMessageTelegram( opts: TelegramReactionOpts = {}, ): Promise<{ ok: true } | { ok: false; warning: string }> { const { cfg, account, api } = resolveTelegramApiContext(opts); - const chatId = normalizeChatId(String(chatIdInput)); + const rawTarget = String(chatIdInput); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: rawTarget, + persistTarget: rawTarget, + verbose: opts.verbose, + }); const messageId = normalizeMessageId(messageIdInput); const requestWithDiag = createTelegramRequestWithDiag({ cfg, @@ -768,7 +800,14 @@ export async function deleteMessageTelegram( opts: TelegramDeleteOpts = {}, ): Promise<{ ok: true }> { const { cfg, account, api } = resolveTelegramApiContext(opts); - const chatId = normalizeChatId(String(chatIdInput)); + const rawTarget = String(chatIdInput); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: rawTarget, + persistTarget: rawTarget, + verbose: opts.verbose, + }); const messageId = normalizeMessageId(messageIdInput); const requestWithDiag = createTelegramRequestWithDiag({ cfg, @@ -807,7 +846,14 @@ export async function editMessageTelegram( ...opts, cfg: opts.cfg, }); - const chatId = normalizeChatId(String(chatIdInput)); + const rawTarget = String(chatIdInput); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: rawTarget, + persistTarget: rawTarget, + verbose: opts.verbose, + }); const messageId = normalizeMessageId(messageIdInput); const requestWithDiag = createTelegramRequestWithDiag({ cfg, @@ -928,7 +974,13 @@ export async function sendStickerTelegram( const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); - const chatId = normalizeChatId(target.chatId); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: target.chatId, + persistTarget: to, + verbose: opts.verbose, + }); const threadParams = buildTelegramThreadReplyParams({ targetMessageThreadId: target.messageThreadId, @@ -1004,7 +1056,13 @@ export async function sendPollTelegram( ): Promise<{ messageId: string; chatId: string; pollId?: string }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); - const chatId = normalizeChatId(target.chatId); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: target.chatId, + persistTarget: to, + verbose: opts.verbose, + }); // Normalize the poll input (validates question, options, maxSelections) const normalizedPoll = normalizePollInput(poll, { maxOptions: 10 }); @@ -1130,10 +1188,16 @@ export async function createForumTopicTelegram( const token = resolveToken(opts.token, account); // Accept topic-qualified targets (e.g. telegram:group::topic:) // but createForumTopic must always target the base supergroup chat id. - const target = parseTelegramTarget(chatId); - const normalizedChatId = normalizeChatId(target.chatId); const client = resolveTelegramClientOptions(account); const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; + const target = parseTelegramTarget(chatId); + const normalizedChatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: target.chatId, + persistTarget: chatId, + verbose: opts.verbose, + }); const request = createTelegramRetryRunner({ retry: opts.retry, diff --git a/src/telegram/target-writeback.test.ts b/src/telegram/target-writeback.test.ts new file mode 100644 index 00000000000..a9f1be73d03 --- /dev/null +++ b/src/telegram/target-writeback.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const readConfigFileSnapshotForWrite = vi.fn(); +const writeConfigFile = vi.fn(); +const loadCronStore = vi.fn(); +const resolveCronStorePath = vi.fn(); +const saveCronStore = vi.fn(); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readConfigFileSnapshotForWrite, + writeConfigFile, + }; +}); + +vi.mock("../cron/store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCronStore, + resolveCronStorePath, + saveCronStore, + }; +}); + +const { maybePersistResolvedTelegramTarget } = await import("./target-writeback.js"); + +describe("maybePersistResolvedTelegramTarget", () => { + beforeEach(() => { + readConfigFileSnapshotForWrite.mockReset(); + writeConfigFile.mockReset(); + loadCronStore.mockReset(); + resolveCronStorePath.mockReset(); + saveCronStore.mockReset(); + resolveCronStorePath.mockReturnValue("/tmp/cron/jobs.json"); + }); + + it("skips writeback when target is already numeric", async () => { + await maybePersistResolvedTelegramTarget({ + cfg: {} as OpenClawConfig, + rawTarget: "-100123", + resolvedChatId: "-100123", + }); + + expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled(); + expect(loadCronStore).not.toHaveBeenCalled(); + }); + + it("writes back matching config and cron targets", async () => { + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { + config: { + channels: { + telegram: { + defaultTo: "t.me/mychannel", + accounts: { + alerts: { + defaultTo: "@mychannel", + }, + }, + }, + }, + }, + }, + writeOptions: { expectedConfigPath: "/tmp/openclaw.json" }, + }); + loadCronStore.mockResolvedValue({ + version: 1, + jobs: [ + { id: "a", delivery: { channel: "telegram", to: "https://t.me/mychannel" } }, + { id: "b", delivery: { channel: "slack", to: "C123" } }, + ], + }); + + await maybePersistResolvedTelegramTarget({ + cfg: { + cron: { store: "/tmp/cron/jobs.json" }, + } as OpenClawConfig, + rawTarget: "t.me/mychannel", + resolvedChatId: "-100123", + }); + + expect(writeConfigFile).toHaveBeenCalledTimes(1); + expect(writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { + telegram: { + defaultTo: "-100123", + accounts: { + alerts: { + defaultTo: "-100123", + }, + }, + }, + }, + }), + expect.objectContaining({ expectedConfigPath: "/tmp/openclaw.json" }), + ); + expect(saveCronStore).toHaveBeenCalledTimes(1); + expect(saveCronStore).toHaveBeenCalledWith( + "/tmp/cron/jobs.json", + expect.objectContaining({ + jobs: [ + { id: "a", delivery: { channel: "telegram", to: "-100123" } }, + { id: "b", delivery: { channel: "slack", to: "C123" } }, + ], + }), + ); + }); + + it("preserves topic suffix style in writeback target", async () => { + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { + config: { + channels: { + telegram: { + defaultTo: "t.me/mychannel:topic:9", + }, + }, + }, + }, + writeOptions: {}, + }); + loadCronStore.mockResolvedValue({ version: 1, jobs: [] }); + + await maybePersistResolvedTelegramTarget({ + cfg: {} as OpenClawConfig, + rawTarget: "t.me/mychannel:topic:9", + resolvedChatId: "-100123", + }); + + expect(writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { + telegram: { + defaultTo: "-100123:topic:9", + }, + }, + }), + expect.any(Object), + ); + }); +}); diff --git a/src/telegram/target-writeback.ts b/src/telegram/target-writeback.ts new file mode 100644 index 00000000000..b4a7cd2bda9 --- /dev/null +++ b/src/telegram/target-writeback.ts @@ -0,0 +1,193 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { readConfigFileSnapshotForWrite, writeConfigFile } from "../config/config.js"; +import { loadCronStore, resolveCronStorePath, saveCronStore } from "../cron/store.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + normalizeTelegramChatId, + normalizeTelegramLookupTarget, + parseTelegramTarget, +} from "./targets.js"; + +const writebackLogger = createSubsystemLogger("telegram/target-writeback"); + +function asObjectRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function normalizeTelegramTargetForMatch(raw: string): string | undefined { + const parsed = parseTelegramTarget(raw); + const normalized = normalizeTelegramLookupTarget(parsed.chatId); + if (!normalized) { + return undefined; + } + const threadKey = parsed.messageThreadId == null ? "" : String(parsed.messageThreadId); + return `${normalized}|${threadKey}`; +} + +function buildResolvedTelegramTarget(params: { + raw: string; + parsed: ReturnType; + resolvedChatId: string; +}): string { + const { raw, parsed, resolvedChatId } = params; + if (parsed.messageThreadId == null) { + return resolvedChatId; + } + return raw.includes(":topic:") + ? `${resolvedChatId}:topic:${parsed.messageThreadId}` + : `${resolvedChatId}:${parsed.messageThreadId}`; +} + +function resolveLegacyRewrite(params: { + raw: string; + resolvedChatId: string; +}): { matchKey: string; resolvedTarget: string } | null { + const parsed = parseTelegramTarget(params.raw); + if (normalizeTelegramChatId(parsed.chatId)) { + return null; + } + const normalized = normalizeTelegramLookupTarget(parsed.chatId); + if (!normalized) { + return null; + } + const threadKey = parsed.messageThreadId == null ? "" : String(parsed.messageThreadId); + return { + matchKey: `${normalized}|${threadKey}`, + resolvedTarget: buildResolvedTelegramTarget({ + raw: params.raw, + parsed, + resolvedChatId: params.resolvedChatId, + }), + }; +} + +function rewriteTargetIfMatch(params: { + rawValue: unknown; + matchKey: string; + resolvedTarget: string; +}): string | null { + if (typeof params.rawValue !== "string" && typeof params.rawValue !== "number") { + return null; + } + const value = String(params.rawValue).trim(); + if (!value) { + return null; + } + if (normalizeTelegramTargetForMatch(value) !== params.matchKey) { + return null; + } + return params.resolvedTarget; +} + +function replaceTelegramDefaultToTargets(params: { + cfg: OpenClawConfig; + matchKey: string; + resolvedTarget: string; +}): boolean { + let changed = false; + const telegram = asObjectRecord(params.cfg.channels?.telegram); + if (!telegram) { + return changed; + } + + const maybeReplace = (holder: Record, key: string) => { + const nextTarget = rewriteTargetIfMatch({ + rawValue: holder[key], + matchKey: params.matchKey, + resolvedTarget: params.resolvedTarget, + }); + if (!nextTarget) { + return; + } + holder[key] = nextTarget; + changed = true; + }; + + maybeReplace(telegram, "defaultTo"); + const accounts = asObjectRecord(telegram.accounts); + if (!accounts) { + return changed; + } + for (const accountId of Object.keys(accounts)) { + const account = asObjectRecord(accounts[accountId]); + if (!account) { + continue; + } + maybeReplace(account, "defaultTo"); + } + return changed; +} + +export async function maybePersistResolvedTelegramTarget(params: { + cfg: OpenClawConfig; + rawTarget: string; + resolvedChatId: string; + verbose?: boolean; +}): Promise { + const raw = params.rawTarget.trim(); + if (!raw) { + return; + } + const rewrite = resolveLegacyRewrite({ + raw, + resolvedChatId: params.resolvedChatId, + }); + if (!rewrite) { + return; + } + const { matchKey, resolvedTarget } = rewrite; + + try { + const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite(); + const nextConfig = structuredClone(snapshot.config ?? {}); + const configChanged = replaceTelegramDefaultToTargets({ + cfg: nextConfig, + matchKey, + resolvedTarget, + }); + if (configChanged) { + await writeConfigFile(nextConfig, writeOptions); + if (params.verbose) { + writebackLogger.warn(`resolved Telegram defaultTo target ${raw} -> ${resolvedTarget}`); + } + } + } catch (err) { + if (params.verbose) { + writebackLogger.warn(`failed to persist Telegram defaultTo target ${raw}: ${String(err)}`); + } + } + + try { + const storePath = resolveCronStorePath(params.cfg.cron?.store); + const store = await loadCronStore(storePath); + let cronChanged = false; + for (const job of store.jobs) { + if (job.delivery?.channel !== "telegram") { + continue; + } + const nextTarget = rewriteTargetIfMatch({ + rawValue: job.delivery.to, + matchKey, + resolvedTarget, + }); + if (!nextTarget) { + continue; + } + job.delivery.to = nextTarget; + cronChanged = true; + } + if (cronChanged) { + await saveCronStore(storePath, store); + if (params.verbose) { + writebackLogger.warn(`resolved Telegram cron delivery target ${raw} -> ${resolvedTarget}`); + } + } + } catch (err) { + if (params.verbose) { + writebackLogger.warn(`failed to persist Telegram cron target ${raw}: ${String(err)}`); + } + } +} diff --git a/src/telegram/targets.test.ts b/src/telegram/targets.test.ts index 51d34206c6d..1cd28fa094e 100644 --- a/src/telegram/targets.test.ts +++ b/src/telegram/targets.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; +import { + isNumericTelegramChatId, + normalizeTelegramChatId, + normalizeTelegramLookupTarget, + parseTelegramTarget, + stripTelegramInternalPrefixes, +} from "./targets.js"; describe("stripTelegramInternalPrefixes", () => { it("strips telegram prefix", () => { @@ -73,3 +79,53 @@ describe("parseTelegramTarget", () => { }); }); }); + +describe("normalizeTelegramChatId", () => { + it("rejects username and t.me forms", () => { + expect(normalizeTelegramChatId("telegram:https://t.me/MyChannel")).toBeUndefined(); + expect(normalizeTelegramChatId("tg:t.me/mychannel")).toBeUndefined(); + expect(normalizeTelegramChatId("@MyChannel")).toBeUndefined(); + expect(normalizeTelegramChatId("MyChannel")).toBeUndefined(); + }); + + it("keeps numeric chat ids unchanged", () => { + expect(normalizeTelegramChatId("-1001234567890")).toBe("-1001234567890"); + expect(normalizeTelegramChatId("123456789")).toBe("123456789"); + }); + + it("returns undefined for empty input", () => { + expect(normalizeTelegramChatId(" ")).toBeUndefined(); + }); +}); + +describe("normalizeTelegramLookupTarget", () => { + it("normalizes legacy t.me and username targets", () => { + expect(normalizeTelegramLookupTarget("telegram:https://t.me/MyChannel")).toBe("@MyChannel"); + expect(normalizeTelegramLookupTarget("tg:t.me/mychannel")).toBe("@mychannel"); + expect(normalizeTelegramLookupTarget("@MyChannel")).toBe("@MyChannel"); + expect(normalizeTelegramLookupTarget("MyChannel")).toBe("@MyChannel"); + }); + + it("keeps numeric chat ids unchanged", () => { + expect(normalizeTelegramLookupTarget("-1001234567890")).toBe("-1001234567890"); + expect(normalizeTelegramLookupTarget("123456789")).toBe("123456789"); + }); + + it("rejects invalid username forms", () => { + expect(normalizeTelegramLookupTarget("@bad-handle")).toBeUndefined(); + expect(normalizeTelegramLookupTarget("bad-handle")).toBeUndefined(); + expect(normalizeTelegramLookupTarget("ab")).toBeUndefined(); + }); +}); + +describe("isNumericTelegramChatId", () => { + it("matches numeric telegram chat ids", () => { + expect(isNumericTelegramChatId("-1001234567890")).toBe(true); + expect(isNumericTelegramChatId("123456789")).toBe(true); + }); + + it("rejects non-numeric chat ids", () => { + expect(isNumericTelegramChatId("@mychannel")).toBe(false); + expect(isNumericTelegramChatId("t.me/mychannel")).toBe(false); + }); +}); diff --git a/src/telegram/targets.ts b/src/telegram/targets.ts index 346bb3e35c5..f31c53dfd26 100644 --- a/src/telegram/targets.ts +++ b/src/telegram/targets.ts @@ -4,6 +4,9 @@ export type TelegramTarget = { chatType: "direct" | "group" | "unknown"; }; +const TELEGRAM_NUMERIC_CHAT_ID_REGEX = /^-?\d+$/; +const TELEGRAM_USERNAME_REGEX = /^[A-Za-z0-9_]{5,}$/i; + export function stripTelegramInternalPrefixes(to: string): string { let trimmed = to.trim(); let strippedTelegramPrefix = false; @@ -26,6 +29,46 @@ export function stripTelegramInternalPrefixes(to: string): string { } } +export function normalizeTelegramChatId(raw: string): string | undefined { + const stripped = stripTelegramInternalPrefixes(raw); + if (!stripped) { + return undefined; + } + if (TELEGRAM_NUMERIC_CHAT_ID_REGEX.test(stripped)) { + return stripped; + } + return undefined; +} + +export function isNumericTelegramChatId(raw: string): boolean { + return TELEGRAM_NUMERIC_CHAT_ID_REGEX.test(raw.trim()); +} + +export function normalizeTelegramLookupTarget(raw: string): string | undefined { + const stripped = stripTelegramInternalPrefixes(raw); + if (!stripped) { + return undefined; + } + if (isNumericTelegramChatId(stripped)) { + return stripped; + } + const tmeMatch = /^(?:https?:\/\/)?t\.me\/([A-Za-z0-9_]+)$/i.exec(stripped); + if (tmeMatch?.[1]) { + return `@${tmeMatch[1]}`; + } + if (stripped.startsWith("@")) { + const handle = stripped.slice(1); + if (!handle || !TELEGRAM_USERNAME_REGEX.test(handle)) { + return undefined; + } + return `@${handle}`; + } + if (TELEGRAM_USERNAME_REGEX.test(stripped)) { + return `@${stripped}`; + } + return undefined; +} + /** * Parse a Telegram delivery target into chatId and optional topic/thread ID. * @@ -39,7 +82,7 @@ function resolveTelegramChatType(chatId: string): "direct" | "group" | "unknown" if (!trimmed) { return "unknown"; } - if (/^-?\d+$/.test(trimmed)) { + if (isNumericTelegramChatId(trimmed)) { return trimmed.startsWith("-") ? "group" : "direct"; } return "unknown";