diff --git a/src/channels/plugins/normalize/telegram.ts b/src/channels/plugins/normalize/telegram.ts index b62f3ad0d7d..ebbb852e877 100644 --- a/src/channels/plugins/normalize/telegram.ts +++ b/src/channels/plugins/normalize/telegram.ts @@ -1,7 +1,23 @@ -import { normalizeTelegramLookupTarget } from "../../../telegram/targets.js"; - export function normalizeTelegramMessagingTarget(raw: string): string | undefined { - const normalized = normalizeTelegramLookupTarget(raw); + 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]}`; + } if (!normalized) { return undefined; } @@ -9,5 +25,15 @@ export function normalizeTelegramMessagingTarget(raw: string): string | undefine } export function looksLikeTelegramTargetId(raw: string): boolean { - return Boolean(normalizeTelegramLookupTarget(raw)); + 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); } diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 5b69ee62185..c5c0632475b 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -161,7 +161,7 @@ describe("applyJobPatch", () => { }); expect(() => applyJobPatch(job, { enabled: true })).toThrow( - 'Invalid Telegram delivery target "-10012345/6789". Use a numeric chat ID or resolvable username/t.me target (examples: 123456789, -1001234567890, @mychannel, t.me/mychannel, -1001234567890:123).', + 'Invalid Telegram delivery target "-10012345/6789". Use colon (:) as delimiter for topics, not slash. Valid formats: -1001234567890, -1001234567890:123, -1001234567890:topic:123, @username, https://t.me/username', ); }); @@ -233,16 +233,6 @@ describe("applyJobPatch", () => { expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); }); - - it("accepts Telegram delivery with bare username", () => { - const job = createIsolatedAgentTurnJob("job-telegram-bare-username", { - mode: "announce", - channel: "telegram", - to: "mybot", - }); - - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); - }); }); function createMockState(now: number): CronServiceState { diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index e7b38d2769e..db42b80ba54 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import { normalizeTelegramLookupTarget, parseTelegramTarget } from "../../telegram/targets.js"; import { parseAbsoluteTimeMs } from "../parse.js"; import { computeNextRunAtMs } from "../schedule.js"; import { @@ -84,14 +83,19 @@ export function assertSupportedJobSpec(job: Pick ({ 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", () => ({ @@ -67,20 +62,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -vi.mock("./target-writeback.js", () => ({ - maybePersistResolvedTelegramTarget, -})); - export function getTelegramSendTestMocks(): TelegramSendTestMocks { - return { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget }; + return { botApi, botCtorSpy, loadConfig, loadWebMedia }; } 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 37d881d843c..250f380509f 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -9,8 +9,7 @@ import { clearSentMessageCache, recordSentMessage, wasSentByBot } from "./sent-m installTelegramSendTestHooks(); -const { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget } = - getTelegramSendTestMocks(); +const { botApi, botCtorSpy, loadConfig, loadWebMedia } = getTelegramSendTestMocks(); const { buildInlineKeyboard, createForumTopicTelegram, @@ -370,48 +369,6 @@ 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({ @@ -1143,31 +1100,6 @@ 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 85327df22b5..56f666493c3 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -29,12 +29,7 @@ import { renderTelegramHtmlText } from "./format.js"; import { isRecoverableTelegramNetworkError } from "./network-errors.js"; import { makeProxyFetch } from "./proxy.js"; import { recordSentMessage } from "./sent-message-cache.js"; -import { maybePersistResolvedTelegramTarget } from "./target-writeback.js"; -import { - normalizeTelegramChatId, - normalizeTelegramLookupTarget, - parseTelegramTarget, -} from "./targets.js"; +import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; import { resolveTelegramVoiceSend } from "./voice.js"; type TelegramApi = Bot["api"]; @@ -141,56 +136,42 @@ function resolveToken(explicit: string | undefined, params: { accountId: string; return params.token.trim(); } -async function resolveChatId( - to: string, - params: { api: TelegramApiOverride; verbose?: boolean }, -): Promise { - const numericChatId = normalizeTelegramChatId(to); - if (numericChatId) { - return numericChatId; +function normalizeChatId(to: string): string { + const trimmed = to.trim(); + if (!trimmed) { + throw new Error("Recipient is required for Telegram sends"); } - 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 }, - ); - } -} -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; + // 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; } function normalizeMessageId(raw: string | number): number { @@ -453,13 +434,7 @@ export async function sendMessageTelegram( ): Promise { const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); - const chatId = await resolveAndPersistChatId({ - cfg, - api, - lookupTarget: target.chatId, - persistTarget: to, - verbose: opts.verbose, - }); + const chatId = normalizeChatId(target.chatId); const mediaUrl = opts.mediaUrl?.trim(); const replyMarkup = buildInlineKeyboard(opts.buttons); @@ -747,14 +722,7 @@ export async function reactMessageTelegram( opts: TelegramReactionOpts = {}, ): Promise<{ ok: true } | { ok: false; warning: string }> { const { cfg, account, api } = resolveTelegramApiContext(opts); - const rawTarget = String(chatIdInput); - const chatId = await resolveAndPersistChatId({ - cfg, - api, - lookupTarget: rawTarget, - persistTarget: rawTarget, - verbose: opts.verbose, - }); + const chatId = normalizeChatId(String(chatIdInput)); const messageId = normalizeMessageId(messageIdInput); const requestWithDiag = createTelegramRequestWithDiag({ cfg, @@ -800,14 +768,7 @@ export async function deleteMessageTelegram( opts: TelegramDeleteOpts = {}, ): Promise<{ ok: true }> { const { cfg, account, api } = resolveTelegramApiContext(opts); - const rawTarget = String(chatIdInput); - const chatId = await resolveAndPersistChatId({ - cfg, - api, - lookupTarget: rawTarget, - persistTarget: rawTarget, - verbose: opts.verbose, - }); + const chatId = normalizeChatId(String(chatIdInput)); const messageId = normalizeMessageId(messageIdInput); const requestWithDiag = createTelegramRequestWithDiag({ cfg, @@ -846,14 +807,7 @@ export async function editMessageTelegram( ...opts, cfg: opts.cfg, }); - const rawTarget = String(chatIdInput); - const chatId = await resolveAndPersistChatId({ - cfg, - api, - lookupTarget: rawTarget, - persistTarget: rawTarget, - verbose: opts.verbose, - }); + const chatId = normalizeChatId(String(chatIdInput)); const messageId = normalizeMessageId(messageIdInput); const requestWithDiag = createTelegramRequestWithDiag({ cfg, @@ -974,13 +928,7 @@ export async function sendStickerTelegram( const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); - const chatId = await resolveAndPersistChatId({ - cfg, - api, - lookupTarget: target.chatId, - persistTarget: to, - verbose: opts.verbose, - }); + const chatId = normalizeChatId(target.chatId); const threadParams = buildTelegramThreadReplyParams({ targetMessageThreadId: target.messageThreadId, @@ -1056,13 +1004,7 @@ export async function sendPollTelegram( ): Promise<{ messageId: string; chatId: string; pollId?: string }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); - const chatId = await resolveAndPersistChatId({ - cfg, - api, - lookupTarget: target.chatId, - persistTarget: to, - verbose: opts.verbose, - }); + const chatId = normalizeChatId(target.chatId); // Normalize the poll input (validates question, options, maxSelections) const normalizedPoll = normalizePollInput(poll, { maxOptions: 10 }); @@ -1188,16 +1130,10 @@ 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 deleted file mode 100644 index a9f1be73d03..00000000000 --- a/src/telegram/target-writeback.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index b4a7cd2bda9..00000000000 --- a/src/telegram/target-writeback.ts +++ /dev/null @@ -1,193 +0,0 @@ -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 1cd28fa094e..51d34206c6d 100644 --- a/src/telegram/targets.test.ts +++ b/src/telegram/targets.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - isNumericTelegramChatId, - normalizeTelegramChatId, - normalizeTelegramLookupTarget, - parseTelegramTarget, - stripTelegramInternalPrefixes, -} from "./targets.js"; +import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; describe("stripTelegramInternalPrefixes", () => { it("strips telegram prefix", () => { @@ -79,53 +73,3 @@ 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 f31c53dfd26..346bb3e35c5 100644 --- a/src/telegram/targets.ts +++ b/src/telegram/targets.ts @@ -4,9 +4,6 @@ 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; @@ -29,46 +26,6 @@ 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. * @@ -82,7 +39,7 @@ function resolveTelegramChatType(chatId: string): "direct" | "group" | "unknown" if (!trimmed) { return "unknown"; } - if (isNumericTelegramChatId(trimmed)) { + if (/^-?\d+$/.test(trimmed)) { return trimmed.startsWith("-") ? "group" : "direct"; } return "unknown";