Files
blog/write-server/src/lib/bot/poll.ts
T
2026-06-24 15:55:06 +08:00

376 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
};
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 }[];
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 };
voice?: { file_id: string; duration?: number };
audio?: { file_id: string; file_name?: string; duration?: number };
text?: string;
caption?: string;
reply_to_message?: { text?: string };
} | undefined;
if (!msg?.chat?.id) continue;
const chatId = msg.chat.id;
if (!allowed(chatId)) continue;
// 处理语音消息(转文字写入文章)
if ((msg.voice || msg.audio) && hasSession(chatId)) {
const voiceFile = msg.voice || msg.audio;
if (voiceFile) {
await handleVoice(chatId, voiceFile.file_id);
}
continue;
}
// 处理图片
if (msg.photo && hasSession(chatId)) {
await handlePhoto(chatId, msg.photo, msg.caption);
continue;
}
// 处理 GIF / 文档 / 视频
if ((msg.animation || msg.document || msg.video) && hasSession(chatId)) {
const fileType = msg.animation ? "animation" : msg.video ? "video" : "document";
const file = msg.animation || msg.document || msg.video;
if (file) {
await handleDocument(chatId, file.file_id, file.file_name, msg.caption, fileType);
}
continue;
}
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 handlers.handleLinkEditReply(chatId, fullText); continue; }
if (original.includes("请输入订阅源 URL")) { await handleCommand(chatId, "feed_add", fullText); continue; }
if (original.includes("请输入新的订阅信息")) { await handlers.handleFeedEditReply(chatId, 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: "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, "❌ 图片保存失败");
}
}
async function handleDocument(chatId: number, fileId: string, fileName?: string, caption?: string, fileType?: string) {
try {
const fs = await import("fs");
const path = await import("path");
const { execSync } = await import("child_process");
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 });
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));
const fileCaption = caption || fileName || "file";
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,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" -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}`);
}
return;
} else if (fileType === "video") {
appendContent(chatId, `\n{{< video src="${filename}" caption="${fileCaption}" >}}\n`);
await sendMessage(chatId, `🎬 视频已保存:${filename}`);
return;
} else {
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}`);
}
} catch (e) {
console.error("[bot:document]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 文件保存失败");
}
}
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 : "未知错误"));
}
}
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) {}
}