Add Bun bundle docs and Telegram grammY support

This commit is contained in:
Peter Steinberger
2025-12-07 22:46:02 +01:00
parent 7b77e9f9ae
commit 4d3d9cca2a
16 changed files with 883 additions and 9 deletions

233
src/telegram/bot.ts Normal file
View File

@@ -0,0 +1,233 @@
import { Buffer } from "node:buffer";
import { Bot, InputFile, webhookCallback } from "grammy";
import type { ApiClientOptions } from "grammy";
import { chunkText } from "../auto-reply/chunk.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { loadConfig } from "../config/config.js";
import { danger, logVerbose } from "../globals.js";
import { getChildLogger } from "../logging.js";
import { mediaKindFromMime } from "../media/constants.js";
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
import type { RuntimeEnv } from "../runtime.js";
import { loadWebMedia } from "../web/media.js";
export type TelegramBotOptions = {
token: string;
runtime?: RuntimeEnv;
requireMention?: boolean;
allowFrom?: Array<string | number>;
mediaMaxMb?: number;
proxyFetch?: typeof fetch;
};
export function createTelegramBot(opts: TelegramBotOptions) {
const runtime: RuntimeEnv =
opts.runtime ?? {
log: console.log,
error: console.error,
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
const client: ApiClientOptions | undefined = opts.proxyFetch
? { fetch: opts.proxyFetch as unknown as ApiClientOptions["fetch"] }
: undefined;
const bot = new Bot(opts.token, { client });
const cfg = loadConfig();
const requireMention =
opts.requireMention ?? cfg.telegram?.requireMention ?? true;
const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom;
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024;
const logger = getChildLogger({ module: "telegram-auto-reply" });
bot.on("message", async (ctx) => {
try {
const msg = ctx.message;
if (!msg) return;
const chatId = msg.chat.id;
const isGroup =
msg.chat.type === "group" || msg.chat.type === "supergroup";
// allowFrom for direct chats
if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) {
const candidate = String(chatId);
const allowed = allowFrom.map(String);
const allowedWithPrefix = allowFrom.map((v) => `telegram:${String(v)}`);
const permitted =
allowed.includes(candidate) ||
allowedWithPrefix.includes(`telegram:${candidate}`) ||
allowed.includes("*");
if (!permitted) {
logVerbose(
`Blocked unauthorized telegram sender ${candidate} (not in allowFrom)`,
);
return;
}
}
const botUsername = ctx.me?.username?.toLowerCase();
if (
isGroup &&
requireMention &&
botUsername &&
!hasBotMention(msg, botUsername)
) {
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
return;
}
const media = await resolveMedia(ctx, mediaMaxBytes);
const body = (msg.text ?? msg.caption ?? media?.placeholder ?? "").trim();
if (!body) return;
const ctxPayload = {
Body: body,
From: isGroup ? `group:${chatId}` : `telegram:${chatId}`,
To: `telegram:${chatId}`,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? msg.chat.title ?? undefined : undefined,
SenderName: buildSenderName(msg),
Surface: "telegram",
MessageSid: String(msg.message_id),
Timestamp: msg.date ? msg.date * 1000 : undefined,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
};
const replyResult = await getReplyFromConfig(ctxPayload, {}, cfg);
const replies = replyResult
? Array.isArray(replyResult)
? replyResult
: [replyResult]
: [];
if (replies.length === 0) return;
await deliverReplies({
replies,
chatId: String(chatId),
token: opts.token,
runtime,
bot,
});
} catch (err) {
runtime.error?.(danger(`Telegram handler failed: ${String(err)}`));
}
});
return bot;
}
export function createTelegramWebhookCallback(
bot: Bot,
path = "/telegram-webhook",
) {
return { path, handler: webhookCallback(bot, "http") };
}
async function deliverReplies(params: {
replies: ReplyPayload[];
chatId: string;
token: string;
runtime: RuntimeEnv;
bot: Bot;
}) {
const { replies, chatId, runtime, bot } = params;
for (const reply of replies) {
if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) {
runtime.error?.(danger("Telegram reply missing text/media"));
continue;
}
const mediaList = reply.mediaUrls?.length
? reply.mediaUrls
: reply.mediaUrl
? [reply.mediaUrl]
: [];
if (mediaList.length === 0) {
for (const chunk of chunkText(reply.text || "", 4000)) {
await bot.api.sendMessage(chatId, chunk, { parse_mode: "Markdown" });
}
continue;
}
// media with optional caption on first item
let first = true;
for (const mediaUrl of mediaList) {
const media = await loadWebMedia(mediaUrl);
const kind = mediaKindFromMime(media.contentType ?? undefined);
const file = new InputFile(media.buffer, media.fileName ?? "file");
const caption = first ? reply.text ?? undefined : undefined;
first = false;
if (kind === "image") {
await bot.api.sendPhoto(chatId, file, { caption });
} else if (kind === "video") {
await bot.api.sendVideo(chatId, file, { caption });
} else if (kind === "audio") {
await bot.api.sendAudio(chatId, file, { caption });
} else {
await bot.api.sendDocument(chatId, file, { caption });
}
}
}
}
function buildSenderName(msg: any) {
const name =
[msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() ||
msg.from?.username;
return name || undefined;
}
function hasBotMention(msg: any, botUsername: string) {
const text = (msg.text ?? msg.caption ?? "").toLowerCase();
if (text.includes(`@${botUsername}`)) return true;
const entities = msg.entities ?? msg.caption_entities ?? [];
for (const ent of entities) {
if (ent.type !== "mention") continue;
const slice = (msg.text ?? msg.caption ?? "").slice(
ent.offset,
ent.offset + ent.length,
);
if (slice.toLowerCase() === `@${botUsername}`) return true;
}
return false;
}
async function resolveMedia(
ctx: any,
maxBytes: number,
): Promise<{ path: string; contentType?: string; placeholder: string } | null> {
const msg = ctx.message;
const m =
msg.photo?.[msg.photo.length - 1] ??
msg.video ??
msg.document ??
msg.audio ??
msg.voice;
if (!m?.file_id) return null;
const file = await ctx.getFile();
const url =
typeof file.getUrl === "function"
? file.getUrl(ctx.me?.token ?? ctx.api?.token ?? undefined)
: undefined;
const data =
url && typeof fetch !== "undefined"
? Buffer.from(await (await fetch(url)).arrayBuffer())
: Buffer.from(await file.download());
const mime = detectMime({
buffer: data,
filePath: file.file_path ?? undefined,
});
const saved = await saveMediaBuffer(data, mime, "inbound", maxBytes);
let placeholder = "<media:document>";
if (msg.photo) placeholder = "<media:image>";
else if (msg.video) placeholder = "<media:video>";
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
return { path: saved.path, contentType: saved.contentType, placeholder };
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import {
downloadTelegramFile,
getTelegramFile,
type TelegramFileInfo,
} from "./download.js";
describe("telegram download", () => {
it("fetches file info", async () => {
const json = vi.fn().mockResolvedValue({ ok: true, result: { file_path: "photos/1.jpg" } });
vi.spyOn(global, "fetch" as never).mockResolvedValueOnce({
ok: true,
status: 200,
statusText: "OK",
json,
} as Response);
const info = await getTelegramFile("tok", "fid");
expect(info.file_path).toBe("photos/1.jpg");
});
it("downloads and saves", async () => {
const info: TelegramFileInfo = { file_id: "fid", file_path: "photos/1.jpg" };
const arrayBuffer = async () => new Uint8Array([1, 2, 3, 4]).buffer;
vi.spyOn(global, "fetch" as never).mockResolvedValueOnce({
ok: true,
status: 200,
statusText: "OK",
body: true,
arrayBuffer,
headers: { get: () => "image/jpeg" },
} as Response);
const saved = await downloadTelegramFile("tok", info, 1024 * 1024);
expect(saved.path).toBeTruthy();
expect(saved.contentType).toBe("image/jpeg");
});
});

50
src/telegram/download.ts Normal file
View File

@@ -0,0 +1,50 @@
import { detectMime, extensionForMime } from "../media/mime.js";
import { saveMediaBuffer, type SavedMedia } from "../media/store.js";
export type TelegramFileInfo = {
file_id: string;
file_unique_id?: string;
file_size?: number;
file_path?: string;
};
export async function getTelegramFile(
token: string,
fileId: string,
): Promise<TelegramFileInfo> {
const res = await fetch(
`https://api.telegram.org/bot${token}/getFile?file_id=${encodeURIComponent(fileId)}`,
);
if (!res.ok) {
throw new Error(`getFile failed: ${res.status} ${res.statusText}`);
}
const json = (await res.json()) as { ok: boolean; result?: TelegramFileInfo };
if (!json.ok || !json.result?.file_path) {
throw new Error("getFile returned no file_path");
}
return json.result;
}
export async function downloadTelegramFile(
token: string,
info: TelegramFileInfo,
maxBytes?: number,
): Promise<SavedMedia> {
if (!info.file_path) throw new Error("file_path missing");
const url = `https://api.telegram.org/file/bot${token}/${info.file_path}`;
const res = await fetch(url);
if (!res.ok || !res.body) {
throw new Error(`Failed to download telegram file: HTTP ${res.status}`);
}
const array = Buffer.from(await res.arrayBuffer());
const mime = detectMime({
buffer: array,
headerMime: res.headers.get("content-type"),
filePath: info.file_path,
});
// save with inbound subdir
const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes);
// Ensure extension matches mime if possible
if (!saved.contentType && mime) saved.contentType = mime;
return saved;
}

4
src/telegram/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { sendMessageTelegram } from "./send.js";
export { monitorTelegramProvider } from "./monitor.js";
export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js";
export { startTelegramWebhook } from "./webhook.js";

View File

@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
import { monitorTelegramProvider } from "./monitor.js";
// Fake bot to capture handler and API calls
const handlers: Record<string, (ctx: any) => Promise<void> | void> = {};
const api = {
sendMessage: vi.fn(),
sendPhoto: vi.fn(),
sendVideo: vi.fn(),
sendAudio: vi.fn(),
sendDocument: vi.fn(),
setWebhook: vi.fn(),
deleteWebhook: vi.fn(),
};
vi.mock("./bot.js", () => ({
createTelegramBot: () => {
handlers.message = async (ctx: any) => {
const chatId = ctx.message.chat.id;
const isGroup = ctx.message.chat.type !== "private";
const text = ctx.message.text ?? ctx.message.caption ?? "";
if (isGroup && !text.includes("@mybot")) return;
if (!text.trim()) return;
await api.sendMessage(chatId, `echo:${text}`, { parse_mode: "Markdown" });
};
return {
on: vi.fn(),
api,
me: { username: "mybot" },
stop: vi.fn(),
start: vi.fn(),
};
},
createTelegramWebhookCallback: vi.fn(),
}));
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: async (ctx: any) => ({ text: `echo:${ctx.Body}` }),
}));
describe("monitorTelegramProvider (grammY)", () => {
it("processes a DM and sends reply", async () => {
Object.values(api).forEach((fn) => fn?.mockReset?.());
await monitorTelegramProvider({ token: "tok" });
expect(handlers.message).toBeDefined();
await handlers.message?.({
message: {
message_id: 1,
chat: { id: 123, type: "private" },
text: "hi",
},
me: { username: "mybot" },
getFile: vi.fn(),
});
expect(api.sendMessage).toHaveBeenCalledWith(123, "echo:hi", {
parse_mode: "Markdown",
});
});
it("requires mention in groups by default", async () => {
Object.values(api).forEach((fn) => fn?.mockReset?.());
await monitorTelegramProvider({ token: "tok" });
await handlers.message?.({
message: {
message_id: 2,
chat: { id: -99, type: "supergroup", title: "G" },
text: "hello all",
},
me: { username: "mybot" },
getFile: vi.fn(),
});
expect(api.sendMessage).not.toHaveBeenCalled();
});
});

63
src/telegram/monitor.ts Normal file
View File

@@ -0,0 +1,63 @@
import { Bot } from "grammy";
import { loadConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { createTelegramBot, createTelegramWebhookCallback } from "./bot.js";
import { makeProxyFetch } from "./proxy.js";
import { startTelegramWebhook } from "./webhook.js";
export type MonitorTelegramOpts = {
token?: string;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
useWebhook?: boolean;
webhookPath?: string;
webhookPort?: number;
webhookSecret?: string;
proxyFetch?: typeof fetch;
webhookUrl?: string;
};
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const token = (opts.token ?? process.env.TELEGRAM_BOT_TOKEN)?.trim();
if (!token) {
throw new Error("TELEGRAM_BOT_TOKEN or telegram.botToken is required for Telegram relay");
}
const proxyFetch =
opts.proxyFetch ??
(loadConfig().telegram?.proxy
? makeProxyFetch(loadConfig().telegram!.proxy as string)
: undefined);
const bot = createTelegramBot({
token,
runtime: opts.runtime,
proxyFetch,
});
if (opts.useWebhook) {
await startTelegramWebhook({
token,
path: opts.webhookPath,
port: opts.webhookPort,
secret: opts.webhookSecret,
runtime: opts.runtime as RuntimeEnv,
fetch: proxyFetch,
abortSignal: opts.abortSignal,
publicUrl: opts.webhookUrl,
});
return;
}
// Long polling
const stopOnAbort = () => {
if (opts.abortSignal?.aborted) bot.stop();
};
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
try {
await bot.start();
} finally {
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
}
}

7
src/telegram/proxy.ts Normal file
View File

@@ -0,0 +1,7 @@
import { ProxyAgent } from "undici";
export function makeProxyFetch(proxyUrl: string): typeof fetch {
const agent = new ProxyAgent(proxyUrl);
return (input: RequestInfo | URL, init?: RequestInit) =>
fetch(input, { ...(init as any), dispatcher: agent } as RequestInit);
}

87
src/telegram/send.test.ts Normal file
View File

@@ -0,0 +1,87 @@
import { beforeEach, afterAll, describe, expect, it, vi } from "vitest";
import { sendMessageTelegram } from "./send.js";
const originalEnv = process.env.TELEGRAM_BOT_TOKEN;
const loadWebMediaMock = vi.fn();
const apiMock = {
sendMessage: vi.fn(),
sendPhoto: vi.fn(),
sendVideo: vi.fn(),
sendAudio: vi.fn(),
sendDocument: vi.fn(),
};
vi.mock("grammy", async (orig) => {
const actual = await orig();
return {
...actual,
Bot: vi.fn().mockImplementation(() => ({ api: apiMock })),
InputFile: actual.InputFile,
};
});
vi.mock("../web/media.js", () => ({
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
}));
describe("sendMessageTelegram", () => {
beforeEach(() => {
vi.resetAllMocks();
process.env.TELEGRAM_BOT_TOKEN = "token123";
});
afterAll(() => {
process.env.TELEGRAM_BOT_TOKEN = originalEnv;
});
it("sends text and returns ids", async () => {
apiMock.sendMessage.mockResolvedValueOnce({
message_id: 42,
chat: { id: 999 },
});
const res = await sendMessageTelegram("12345", "hello", {
verbose: false,
api: apiMock as never,
});
expect(res).toEqual({ messageId: "42", chatId: "999" });
expect(apiMock.sendMessage).toHaveBeenCalled();
});
it("throws when token missing", async () => {
process.env.TELEGRAM_BOT_TOKEN = "";
await expect(sendMessageTelegram("1", "hi")).rejects.toThrow(
/TELEGRAM_BOT_TOKEN/,
);
});
it("throws on api error", async () => {
apiMock.sendMessage.mockRejectedValueOnce(new Error("bad token"));
await expect(sendMessageTelegram("1", "hi", { api: apiMock as never })).rejects.toThrow(
/bad token/i,
);
});
it("sends media via appropriate method", async () => {
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from([1, 2, 3]),
contentType: "image/jpeg",
kind: "image",
fileName: "pic.jpg",
});
apiMock.sendPhoto.mockResolvedValueOnce({
message_id: 99,
chat: { id: 123 },
});
const res = await sendMessageTelegram("123", "hello", {
mediaUrl: "http://example.com/pic.jpg",
api: apiMock as never,
});
expect(res).toEqual({ messageId: "99", chatId: "123" });
expect(loadWebMediaMock).toHaveBeenCalled();
expect(apiMock.sendPhoto).toHaveBeenCalled();
});
});

88
src/telegram/send.ts Normal file
View File

@@ -0,0 +1,88 @@
import { Bot, InputFile } from "grammy";
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;
};
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");
if (trimmed.startsWith("@")) return trimmed;
return trimmed;
}
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();
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;
if (kind === "image") {
result = await api.sendPhoto(chatId, file, { caption });
} else if (kind === "video") {
result = await api.sendVideo(chatId, file, { caption });
} else if (kind === "audio") {
result = await api.sendAudio(chatId, file, { caption });
} else {
result = await api.sendDocument(chatId, file, { caption });
}
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 api.sendMessage(chatId, text, {
parse_mode: "Markdown",
});
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";
}
}

View File

@@ -0,0 +1,19 @@
import { Bot } from "grammy";
export async function setTelegramWebhook(opts: {
token: string;
url: string;
secret?: string;
dropPendingUpdates?: boolean;
}) {
const bot = new Bot(opts.token);
await bot.api.setWebhook(opts.url, {
secret_token: opts.secret,
drop_pending_updates: opts.dropPendingUpdates ?? false,
});
}
export async function deleteTelegramWebhook(opts: { token: string }) {
const bot = new Bot(opts.token);
await bot.api.deleteWebhook();
}

69
src/telegram/webhook.ts Normal file
View File

@@ -0,0 +1,69 @@
import { createServer } from "node:http";
import { webhookCallback } from "grammy";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { createTelegramBot } from "./bot.js";
export async function startTelegramWebhook(opts: {
token: string;
path?: string;
port?: number;
host?: string;
secret?: string;
runtime?: RuntimeEnv;
fetch?: typeof fetch;
abortSignal?: AbortSignal;
healthPath?: string;
publicUrl?: string;
}) {
const path = opts.path ?? "/telegram-webhook";
const healthPath = opts.healthPath ?? "/healthz";
const port = opts.port ?? 8787;
const host = opts.host ?? "0.0.0.0";
const runtime = opts.runtime ?? defaultRuntime;
const bot = createTelegramBot({
token: opts.token,
runtime,
proxyFetch: opts.fetch,
});
const handler = webhookCallback(bot, "http", {
secretToken: opts.secret,
});
const server = createServer((req, res) => {
if (req.url === healthPath) {
res.writeHead(200);
res.end("ok");
return;
}
if (req.url !== path || req.method !== "POST") {
res.writeHead(404);
res.end();
return;
}
handler(req, res);
});
const publicUrl =
opts.publicUrl ??
`http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
await bot.api.setWebhook(publicUrl, {
secret_token: opts.secret,
});
await new Promise<void>((resolve) => server.listen(port, host, resolve));
runtime.log?.(`Telegram webhook listening on ${publicUrl}`);
const shutdown = () => {
server.close();
bot.stop();
};
if (opts.abortSignal) {
opts.abortSignal.addEventListener("abort", shutdown, { once: true });
}
return { server, bot, stop: shutdown };
}