198 lines
6.1 KiB
TypeScript
198 lines
6.1 KiB
TypeScript
// @ts-nocheck
|
|
import { Bot, InputFile } from "grammy";
|
|
import { formatErrorMessage } from "../infra/errors.js";
|
|
import { mediaKindFromMime } from "../media/constants.js";
|
|
import { loadWebMedia } from "../web/media.js";
|
|
|
|
type TelegramSendOpts = {
|
|
token?: string;
|
|
verbose?: boolean;
|
|
mediaUrl?: string;
|
|
maxBytes?: number;
|
|
api?: Bot["api"];
|
|
};
|
|
|
|
type TelegramSendResult = {
|
|
messageId: string;
|
|
chatId: string;
|
|
};
|
|
|
|
const PARSE_ERR_RE =
|
|
/can't parse entities|parse entities|find end of the entity/i;
|
|
|
|
function resolveToken(explicit?: string): string {
|
|
const token = explicit ?? process.env.TELEGRAM_BOT_TOKEN;
|
|
if (!token) {
|
|
throw new Error(
|
|
"TELEGRAM_BOT_TOKEN is required for Telegram sends (Bot API)",
|
|
);
|
|
}
|
|
return token.trim();
|
|
}
|
|
|
|
function normalizeChatId(to: string): string {
|
|
const trimmed = to.trim();
|
|
if (!trimmed) throw new Error("Recipient is required for Telegram sends");
|
|
|
|
// Common internal prefixes that sometimes leak into outbound sends.
|
|
// - ctx.To uses `telegram:<id>`
|
|
// - group sessions often use `telegram:group:<id>`
|
|
let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
|
|
|
// 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;
|
|
}
|
|
|
|
export async function sendMessageTelegram(
|
|
to: string,
|
|
text: string,
|
|
opts: TelegramSendOpts = {},
|
|
): Promise<TelegramSendResult> {
|
|
const token = resolveToken(opts.token);
|
|
const chatId = normalizeChatId(to);
|
|
const bot = opts.api ? null : new Bot(token);
|
|
const api = opts.api ?? bot?.api;
|
|
const mediaUrl = opts.mediaUrl?.trim();
|
|
|
|
const sleep = (ms: number) =>
|
|
new Promise((resolve) => setTimeout(resolve, ms));
|
|
const sendWithRetry = async <T>(fn: () => Promise<T>, label: string) => {
|
|
let lastErr: unknown;
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
lastErr = err;
|
|
const errText = formatErrorMessage(err);
|
|
const terminal =
|
|
attempt === 3 ||
|
|
!/429|timeout|connect|reset|closed|unavailable|temporarily/i.test(
|
|
errText,
|
|
);
|
|
if (terminal) break;
|
|
const backoff = 400 * attempt;
|
|
if (opts.verbose) {
|
|
console.warn(
|
|
`telegram send retry ${attempt}/2 for ${label} in ${backoff}ms: ${errText}`,
|
|
);
|
|
}
|
|
await sleep(backoff);
|
|
}
|
|
}
|
|
throw lastErr ?? new Error(`Telegram send failed (${label})`);
|
|
};
|
|
|
|
const wrapChatNotFound = (err: unknown) => {
|
|
if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err)))
|
|
return err;
|
|
return new Error(
|
|
[
|
|
`Telegram send failed: chat not found (chat_id=${chatId}).`,
|
|
"Likely: bot not started in DM, bot removed from group/channel, group migrated (new -100… id), or wrong bot token.",
|
|
`Input was: ${JSON.stringify(to)}.`,
|
|
].join(" "),
|
|
);
|
|
};
|
|
|
|
if (mediaUrl) {
|
|
const media = await loadWebMedia(mediaUrl, opts.maxBytes);
|
|
const kind = mediaKindFromMime(media.contentType ?? undefined);
|
|
const file = new InputFile(
|
|
media.buffer,
|
|
media.fileName ?? inferFilename(kind) ?? "file",
|
|
);
|
|
const caption = text?.trim() || undefined;
|
|
let result:
|
|
| Awaited<ReturnType<typeof api.sendPhoto>>
|
|
| Awaited<ReturnType<typeof api.sendVideo>>
|
|
| Awaited<ReturnType<typeof api.sendAudio>>
|
|
| Awaited<ReturnType<typeof api.sendDocument>>;
|
|
if (kind === "image") {
|
|
result = await sendWithRetry(
|
|
() => api.sendPhoto(chatId, file, { caption }),
|
|
"photo",
|
|
).catch((err) => {
|
|
throw wrapChatNotFound(err);
|
|
});
|
|
} else if (kind === "video") {
|
|
result = await sendWithRetry(
|
|
() => api.sendVideo(chatId, file, { caption }),
|
|
"video",
|
|
).catch((err) => {
|
|
throw wrapChatNotFound(err);
|
|
});
|
|
} else if (kind === "audio") {
|
|
result = await sendWithRetry(
|
|
() => api.sendAudio(chatId, file, { caption }),
|
|
"audio",
|
|
).catch((err) => {
|
|
throw wrapChatNotFound(err);
|
|
});
|
|
} else {
|
|
result = await sendWithRetry(
|
|
() => api.sendDocument(chatId, file, { caption }),
|
|
"document",
|
|
).catch((err) => {
|
|
throw wrapChatNotFound(err);
|
|
});
|
|
}
|
|
const messageId = String(result?.message_id ?? "unknown");
|
|
return { messageId, chatId: String(result?.chat?.id ?? chatId) };
|
|
}
|
|
|
|
if (!text || !text.trim()) {
|
|
throw new Error("Message must be non-empty for Telegram sends");
|
|
}
|
|
const res = await sendWithRetry(
|
|
() => api.sendMessage(chatId, text, { parse_mode: "Markdown" }),
|
|
"message",
|
|
).catch(async (err) => {
|
|
// Telegram rejects malformed Markdown (e.g., unbalanced '_' or '*').
|
|
// When that happens, fall back to plain text so the message still delivers.
|
|
const errText = formatErrorMessage(err);
|
|
if (PARSE_ERR_RE.test(errText)) {
|
|
if (opts.verbose) {
|
|
console.warn(
|
|
`telegram markdown parse failed, retrying as plain text: ${errText}`,
|
|
);
|
|
}
|
|
return await sendWithRetry(
|
|
() => api.sendMessage(chatId, text),
|
|
"message-plain",
|
|
).catch((err2) => {
|
|
throw wrapChatNotFound(err2);
|
|
});
|
|
}
|
|
throw wrapChatNotFound(err);
|
|
});
|
|
const messageId = String(res?.message_id ?? "unknown");
|
|
return { messageId, chatId: String(res?.chat?.id ?? chatId) };
|
|
}
|
|
|
|
function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
|
switch (kind) {
|
|
case "image":
|
|
return "image.jpg";
|
|
case "video":
|
|
return "video.mp4";
|
|
case "audio":
|
|
return "audio.ogg";
|
|
default:
|
|
return "file.bin";
|
|
}
|
|
}
|
|
// @ts-nocheck
|