fix: persist resolved telegram delivery targets at runtime

This commit is contained in:
Ayaan Zaidi
2026-02-23 09:13:35 +05:30
committed by Ayaan Zaidi
parent 35fbf26d24
commit dcc52850c3
8 changed files with 632 additions and 77 deletions

View File

@@ -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));
}

View File

@@ -27,11 +27,16 @@ const { loadConfig } = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
}));
const { maybePersistResolvedTelegramTarget } = vi.hoisted(() => ({
maybePersistResolvedTelegramTarget: vi.fn(async () => {}),
}));
type TelegramSendTestMocks = {
botApi: Record<string, MockFn>;
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();

View File

@@ -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", () => {

View File

@@ -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<string> {
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:<id>`
// - group sessions often use `telegram:group:<id>`
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<typeof loadConfig>;
api: TelegramApiOverride;
lookupTarget: string;
persistTarget: string;
verbose?: boolean;
}): Promise<string> {
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<TelegramSendResult> {
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:<id>:topic:<thread>)
// 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,

View File

@@ -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<typeof import("../config/config.js")>();
return {
...actual,
readConfigFileSnapshotForWrite,
writeConfigFile,
};
});
vi.mock("../cron/store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../cron/store.js")>();
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),
);
});
});

View File

@@ -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<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
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<typeof parseTelegramTarget>;
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<string, unknown>, 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<void> {
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)}`);
}
}
}

View File

@@ -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);
});
});

View File

@@ -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";