376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
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\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\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\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) {}
|
||
}
|