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 = { 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 { return new Promise((r) => setTimeout(r, ms)); } export async function startPolling(): Promise { 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[]>("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) {} }