Files
blog/write-server/src/lib/bot/poll.ts
T

381 lines
15 KiB
TypeScript
Raw Normal View History

2026-06-21 20:42:44 +08:00
import { TG_TOKEN } from "./config";
import { tg, allowed, sendMessage } from "./tg";
import { hasSession, getSession, appendContent } from "./sessions";
import handlers from "./handlers";
const ARG_COMMANDS = new Set([
"new", "title", "categories", "tags", "author",
"edit", "view", "delete",
"link_add", "link_edit", "link_del", "link_toggle",
"feed_add", "feed_edit", "feed_del",
"list", "links", "feeds", "read",
]);
const CALLBACK_PREFIXES: Record<string, string> = {
edit: "edit",
confirm_del: "delete",
cancel_del: "noop",
list: "list",
links: "links",
feeds: "feeds",
skip_cat: "skip_categories",
skip_tag: "skip_tags",
confirm_shutdown: "confirm_shutdown",
cancel_shutdown: "cancel_shutdown",
confirm_reboot: "confirm_reboot",
cancel_reboot: "cancel_reboot",
};
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
export async function startPolling(): Promise<void> {
if (!TG_TOKEN) {
console.log("[bot] TG_BOT_TOKEN not set, skipping bot startup");
return;
}
console.log("[bot] starting Telegram polling...");
let lastOffset = 0;
let backoff = 3000;
async function poll() {
try {
const data = await tg<Record<string, unknown>[]>("getUpdates", {
offset: lastOffset + 1,
timeout: 30,
allowed_updates: ["message", "callback_query"],
});
if (data.ok && data.result) {
for (const update of data.result) {
lastOffset = update.update_id as number;
if (update.callback_query) {
const cb = update.callback_query as {
message?: { chat?: { id: number } };
from?: { id: number };
id: string;
data?: string;
};
const cbChatId = cb.message?.chat?.id ?? cb.from?.id ?? 0;
if (cb.data && allowed(cbChatId)) {
await handleInlineCallback(cbChatId, cb.id, cb.data);
}
continue;
}
const msg = update.message as {
chat?: { id: number };
photo?: { file_id: string }[];
2026-06-21 23:58:29 +08:00
document?: { file_id: string; file_name?: string; mime_type?: string };
animation?: { file_id: string; file_name?: string; mime_type?: string };
video?: { file_id: string; file_name?: string };
2026-06-22 14:10:10 +08:00
voice?: { file_id: string; duration?: number };
audio?: { file_id: string; file_name?: string; duration?: number };
2026-06-21 20:42:44 +08:00
text?: string;
caption?: string;
reply_to_message?: { text?: string };
} | undefined;
if (!msg?.chat?.id) continue;
const chatId = msg.chat.id;
if (!allowed(chatId)) continue;
2026-06-22 14:10:10 +08:00
// 处理语音消息(转文字写入文章)
if ((msg.voice || msg.audio) && hasSession(chatId)) {
const voiceFile = msg.voice || msg.audio;
if (voiceFile) {
await handleVoice(chatId, voiceFile.file_id);
}
continue;
}
2026-06-21 23:58:29 +08:00
// 处理图片
2026-06-21 20:42:44 +08:00
if (msg.photo && hasSession(chatId)) {
await handlePhoto(chatId, msg.photo, msg.caption);
continue;
}
2026-06-21 23:58:29 +08:00
// 处理 GIF / 文档 / 视频
if ((msg.animation || msg.document || msg.video) && hasSession(chatId)) {
2026-06-22 17:21:57 +08:00
const fileType = msg.animation ? "animation" : msg.video ? "video" : "document";
2026-06-21 23:58:29 +08:00
const file = msg.animation || msg.document || msg.video;
if (file) {
2026-06-22 17:21:57 +08:00
await handleDocument(chatId, file.file_id, file.file_name, msg.caption, fileType);
2026-06-21 23:58:29 +08:00
}
continue;
}
2026-06-21 20:42:44 +08:00
if (!msg.text) continue;
const fullText = msg.text.trim();
// ForceReply routing
if (msg.reply_to_message?.text) {
const original = msg.reply_to_message.text;
if (original.includes("请输入文章标题")) { await handleCommand(chatId, "new", fullText); continue; }
if (original.includes("请输入新标题")) { await handleCommand(chatId, "title", fullText); continue; }
if (original.includes("请输入分类")) { await handleCommand(chatId, "categories", fullText); continue; }
if (original.includes("请输入标签")) { await handleCommand(chatId, "tags", fullText); continue; }
if (original.includes("请输入友链信息")) { await handleCommand(chatId, "link_add", fullText); continue; }
if (original.includes("请输入新名称")) { await handleCommand(chatId, "link_edit", fullText); continue; }
if (original.includes("请输入订阅源 URL")) { await handleCommand(chatId, "feed_add", fullText); continue; }
if (original.includes("请输入新的订阅信息")) { await handleCommand(chatId, "feed_edit", fullText); continue; }
}
const parts = fullText.split("@")[0].split(/\s+/);
const cmdRaw = parts[0].toLowerCase();
const slashCmd = cmdRaw.startsWith("/") ? cmdRaw.slice(1) : "";
const slashArg = parts.slice(1).join(" ");
// In writing session, non-slash text appends to content
if (hasSession(chatId) && !cmdRaw.startsWith("/")) {
try { appendContent(chatId, fullText); }
catch (e) { console.error("[bot:append]", e instanceof Error ? e.message : String(e)); }
continue;
}
if (cmdRaw.startsWith("/")) {
await handleCommand(chatId, slashCmd, slashArg);
}
}
}
backoff = 3000;
} catch (e) {
console.error("[bot:poll]", e instanceof Error ? e.message : String(e));
await sleep(backoff);
backoff = Math.min(backoff * 2, 60000);
}
await sleep(100);
setImmediate(poll);
}
// Register commands with Telegram
try {
await tg("setMyCommands", {
commands: [
{ command: "new", description: "写一篇文章" },
{ command: "edit", description: "编辑文章" },
{ command: "delete", description: "删除文章" },
{ command: "list", description: "文章列表" },
{ command: "publish", description: "保存发布" },
{ command: "draft", description: "保存草稿" },
{ command: "deploy", description: "推送到线上" },
{ command: "sync", description: "同步远程" },
{ command: "undo", description: "撤销最后一段" },
{ command: "preview", description: "预览" },
{ command: "links", description: "友链管理" },
{ command: "link_add", description: "添加友链" },
{ command: "link_edit", description: "编辑友链" },
{ command: "link_del", description: "删除友链" },
{ command: "link_toggle", description: "显示/隐藏友链" },
{ command: "feeds", description: "订阅管理" },
{ command: "feed_add", description: "添加订阅源" },
{ command: "feed_edit", description: "编辑订阅源" },
{ command: "feed_del", description: "删除订阅源" },
{ command: "feed_health", description: "订阅源健康检查" },
{ command: "read", description: "友链最新文章" },
{ command: "stats", description: "网站数据" },
{ command: "shutdown", description: "关机" },
{ command: "reboot", description: "重启" },
{ command: "help", description: "帮助" },
],
});
console.log("[bot] commands registered");
} catch (e) {
console.error("[bot] failed to register commands:", e instanceof Error ? e.message : String(e));
}
poll();
}
async function handlePhoto(chatId: number, photos: { file_id: string }[], caption?: string) {
try {
const fs = await import("fs");
const path = await import("path");
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s.dirPath) {
await sendMessage(chatId, "❌ 请先发 /new 或 /edit 开始写作");
return;
}
const largestPhoto = photos[photos.length - 1];
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: largestPhoto.file_id });
if (!fileRes.ok || !fileRes.result?.file_path) return;
const filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const imgBuffer = await fetch(fileUrl).then((r) => r.arrayBuffer());
// Save to article's own folder (Hugo page bundle)
const articleDir = s.dirPath;
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
const ext = filePath.split(".").pop() || "jpg";
const filename = Date.now() + "-" + largestPhoto.file_id.slice(0, 8) + "." + ext;
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(imgBuffer));
const imgCaption = caption || "image";
appendContent(chatId, `\n![${imgCaption}](${filename})\n`);
await sendMessage(chatId, `📷 图片已保存:${filename}`);
} catch (e) {
console.error("[bot:photo]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 图片保存失败");
}
}
2026-06-22 17:21:57 +08:00
async function handleDocument(chatId: number, fileId: string, fileName?: string, caption?: string, fileType?: string) {
2026-06-21 23:58:29 +08:00
try {
const fs = await import("fs");
const path = await import("path");
2026-06-22 18:21:28 +08:00
const { execSync } = await import("child_process");
2026-06-21 23:58:29 +08:00
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s.dirPath) {
await sendMessage(chatId, "❌ 请先发 /new 或 /edit 开始写作");
return;
}
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: fileId });
if (!fileRes.ok || !fileRes.result?.file_path) {
await sendMessage(chatId, "❌ 文件获取失败(可能文件太大,Telegram 限制 20MB)");
return;
}
const filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const fileBuffer = await fetch(fileUrl).then((r) => r.arrayBuffer());
const articleDir = s.dirPath;
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
2026-06-22 18:21:28 +08:00
const ext = fileName?.split(".").pop() || filePath.split(".").pop() || "bin";
const baseFilename = Date.now() + "-" + fileId.slice(0, 8);
const filename = baseFilename + "." + ext;
const fullPath = path.join(articleDir, filename);
fs.writeFileSync(fullPath, Buffer.from(fileBuffer));
2026-06-21 23:58:29 +08:00
const fileCaption = caption || fileName || "file";
2026-06-22 17:21:57 +08:00
2026-06-22 18:21:28 +08:00
if (fileType === "animation") {
// Telegram GIF → ffmpeg 转成真正的 GIF
const gifFilename = baseFilename + ".gif";
const gifPath = path.join(articleDir, gifFilename);
try {
await sendMessage(chatId, "🎞️ 正在转换 GIF...");
execSync(`ffmpeg -y -i "${fullPath}" -vf "fps=15,scale=480:-1:flags=lanczos" -loop 0 "${gifPath}"`, { timeout: 60000 });
fs.unlinkSync(fullPath);
appendContent(chatId, `\n![${fileCaption}](${gifFilename})\n`);
await sendMessage(chatId, `🎞️ GIF 已转换:${gifFilename}`);
} catch (e) {
console.error("[bot:gif-convert]", e instanceof Error ? e.message : String(e));
appendContent(chatId, `\n{{< video src="${filename}" loop="true" muted="true" >}}\n`);
await sendMessage(chatId, `🎞️ GIF 转换失败,已保存为视频:${filename}`);
}
2026-06-22 17:21:57 +08:00
return;
2026-06-22 18:21:28 +08:00
} else if (fileType === "video") {
2026-06-22 16:23:10 +08:00
appendContent(chatId, `\n{{< video src="${filename}" caption="${fileCaption}" >}}\n`);
await sendMessage(chatId, `🎬 视频已保存:${filename}`);
return;
2026-06-21 23:58:29 +08:00
} else {
2026-06-22 18:21:28 +08:00
const extLower = ext.toLowerCase();
const isImage = ["gif", "webp", "png", "jpg", "jpeg"].includes(extLower);
const isVideo = ["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(extLower);
if (isImage) {
appendContent(chatId, `\n![${fileCaption}](${filename})\n`);
} else if (isVideo) {
appendContent(chatId, `\n{{< video src="${filename}" caption="${fileCaption}" >}}\n`);
} else {
appendContent(chatId, `\n[${fileCaption}](${filename})\n`);
}
await sendMessage(chatId, `📎 文件已保存:${filename}`);
2026-06-21 23:58:29 +08:00
}
} catch (e) {
console.error("[bot:document]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 文件保存失败");
}
}
2026-06-22 14:10:10 +08:00
async function handleVoice(chatId: number, fileId: string) {
try {
const { transcribeAudio } = await import("@/lib/voice");
await sendMessage(chatId, "🎤 正在识别语音...");
// 获取文件路径
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: fileId });
if (!fileRes.ok || !fileRes.result?.file_path) {
await sendMessage(chatId, "❌ 语音文件获取失败");
return;
}
// 下载语音文件
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + fileRes.result.file_path;
const audioBuffer = await fetch(fileUrl).then(r => r.arrayBuffer());
// 转文字
const text = await transcribeAudio(audioBuffer, fileRes.result.file_path);
if (!text.trim()) {
await sendMessage(chatId, "❌ 语音识别结果为空");
return;
}
// 追加到文章内容
appendContent(chatId, text.trim());
const preview = text.trim().length > 80 ? text.trim().slice(0, 80) + "..." : text.trim();
await sendMessage(chatId, `✅ 语音已转文字:\n\n${preview}`);
} catch (e) {
console.error("[bot:voice]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 语音转文字失败:" + (e instanceof Error ? e.message : "未知错误"));
}
}
2026-06-21 20:42:44 +08:00
async function handleCommand(chatId: number, cmd: string, arg?: string) {
try {
if (cmd === "noop") return;
if (ARG_COMMANDS.has(cmd)) {
const handler = handlers[cmd];
if (handler) await handler(chatId, arg);
else await sendMessage(chatId, "❌ 未知命令:/" + cmd);
} else {
const handler = handlers[cmd];
if (handler) await handler(chatId);
else if (cmd) await sendMessage(chatId, "❌ 未知命令:/" + cmd);
}
} catch (e) {
console.error("[bot:" + cmd + "]", e instanceof Error ? e.message : String(e));
try {
await sendMessage(chatId, "❌ " + (e instanceof Error ? e.message : "error").slice(0, 200));
} catch (e) { /* ignore */ }
}
}
async function handleInlineCallback(chatId: number, queryId: string, data: string) {
let prefix = "";
let arg = "";
// First try prefix match (e.g. "edit_slug", "list_2")
for (const p of Object.keys(CALLBACK_PREFIXES)) {
if (data.startsWith(p + "_")) {
prefix = p;
arg = data.slice(p.length + 1);
break;
}
}
// Then try exact match (e.g. "confirm_shutdown", "skip_cat")
if (!prefix && CALLBACK_PREFIXES[data]) {
prefix = data;
}
if (!prefix) {
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
return;
}
const cmd = CALLBACK_PREFIXES[prefix];
await handleCommand(chatId, cmd, arg);
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
}