Add Bun bundle docs and Telegram grammY support
This commit is contained in:
233
src/telegram/bot.ts
Normal file
233
src/telegram/bot.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user