1725 功能大改

This commit is contained in:
Vaica committed 2026-06-24 17:25:54 +08:00
1 parent 21de080b39
commit af41bcd2bb
10 files changed
+1743 -10

No files matched your search

+413 -8
View File
@@ -79,18 +79,21 @@ export async function startPolling(): Promise<void> {
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);
}
// 处理语音消息(增强版:保存文件 + 转文字 + 生成短代码)
if (msg.voice && hasSession(chatId)) {
await handleVoiceEnhanced(chatId, msg.voice.file_id, msg.voice.duration);
continue;
}
// 处理图片
// 处理音频文件(增强版:支持封面图)
if (msg.audio && hasSession(chatId)) {
await handleAudioEnhanced(chatId, msg.audio.file_id, msg.audio.file_name);
continue;
}
// 处理图片(增强版:支持封面图上传 + 实况照片引导)
if (msg.photo && hasSession(chatId)) {
await handlePhoto(chatId, msg.photo, msg.caption);
await handlePhotoEnhanced(chatId, msg.photo, msg.caption);
continue;
}
@@ -110,6 +113,13 @@ export async function startPolling(): Promise<void> {
// ForceReply routing
if (msg.reply_to_message?.text) {
const original = msg.reply_to_message.text;
// 处理音频信息回复(音乐卡片)
if (original.includes("请输入歌曲信息")) {
const handled = await handleAudioInfo(chatId, fullText);
if (handled) continue;
}
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; }
@@ -351,6 +361,13 @@ async function handleCommand(chatId: number, cmd: string, arg?: string) {
}
async function handleInlineCallback(chatId: number, queryId: string, data: string) {
// 处理音频封面图回调
if (data === "audio_add_cover" || data === "audio_skip_cover") {
await handleAudioCoverCallback(chatId, data);
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
return;
}
let prefix = "";
let arg = "";
// First try prefix match (e.g. "edit_slug", "list_2")
@@ -373,3 +390,391 @@ async function handleInlineCallback(chatId: number, queryId: string, data: strin
await handleCommand(chatId, cmd, arg);
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
}
const s = getSession(chatId);
if (!s.dirPath) {
await sendMessage(chatId, "❌ 请先发 /new 或 /edit 开始写作");
return;
}
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 filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const audioBuffer = await fetch(fileUrl).then(r => r.arrayBuffer());
const articleDir = s.dirPath;
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
const ext = filePath.split(".").pop() || "ogg";
const filename = Date.now() + "-voice." + ext;
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(audioBuffer));
const voiceShortcode = `\n\n{{< voice src="${filename}" >}}\n\n`;
appendContent(chatId, voiceShortcode);
try {
const text = await transcribeAudio(audioBuffer, filePath);
if (text.trim()) {
appendContent(chatId, `\n> ${text.trim()}\n\n`);
const preview = text.trim().length > 80 ? text.trim().slice(0, 80) + "..." : text.trim();
await sendMessage(chatId, `✅ 语音已保存:${filename}\n\n📝 转文字:${preview}`);
} else {
await sendMessage(chatId, `✅ 语音已保存:${filename}\n\n⚠️ 语音转文字失败,已插入语音播放器`);
}
} catch (transcribeError) {
console.error("[bot:voice-transcribe]", transcribeError);
await sendMessage(chatId, `✅ 语音已保存:${filename}\n\n⚠️ 语音转文字失败,已插入语音播放器`);
}
} catch (e) {
console.error("[bot:voice]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 语音处理失败:" + (e instanceof Error ? e.message : "未知错误"));
}
}
async function handleAudio(chatId: number, fileId: string, fileName?: string, mimeType?: 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;
}
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 filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const audioBuffer = 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() || "mp3";
const filename = Date.now() + "-music." + ext;
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(audioBuffer));
const session = getSession(chatId);
session._pendingAudio = { filename, fileId };
await tg("sendMessage", {
chat_id: chatId,
text: `✅ 音频已保存:${filename}\n\n请输入歌曲信息(格式:歌名 - 歌手):`,
reply_markup: {
force_reply: true,
input_field_placeholder: "例如:晴天 - 周杰伦"
}
});
} catch (e) {
console.error("[bot:audio]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 音频处理失败:" + (e instanceof Error ? e.message : "未知错误"));
}
}
async function handleAudioInfo(chatId: number, text: string): Promise<boolean> {
try {
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s._pendingAudio) {
return false;
}
const { filename } = s._pendingAudio;
delete s._pendingAudio;
let name = "未知歌曲";
let artist = "未知艺术家";
const parts = text.split(/[-–—]/);
if (parts.length >= 2) {
name = parts[0].trim();
artist = parts.slice(1).join("-").trim();
} else if (text.trim()) {
name = text.trim();
}
co// ============================================================
// 增强版 handlePhoto:支持封面图上传 + 实况照片引导
// ============================================================
async function handlePhotoEnhanced(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;
}
// 检查是否在等待封面图
if (s._pendingAudio) {
await handleAudioCover(chatId, photos);
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());
// 保存图片
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}
💡 <b>提示:</b>如果您发送的是 iOS 实况照片(Live Photo),
动态效果无法自动提取。如需保留动态效果,请:
1. 将实况照片转为视频/GIF 后重新发送
2. 或者单独发送视频部分
继续发送其他内容...</b>`, { parse_mode: "HTML" });
} catch (e) {
console.error("[bot:photo]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 图片保存失败");
}
}
// ============================================================
// 处理音乐封面图
// ============================================================
async function handleAudioCover(chatId: number, photos: { file_id: string }[]) {
try {
const fs = await import("fs");
const path = await import("path");
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s._pendingAudio) {
await sendMessage(chatId, "❌ 没有待处理的音频文件");
return;
}
const { filename } = s._pendingAudio;
// 获取最大的图片
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) {
await sendMessage(chatId, "❌ 封面图获取失败");
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());
// 保存封面图
const articleDir = s.dirPath;
const ext = filePath.split(".").pop() || "jpg";
const coverFilename = Date.now() + "-cover." + ext;
fs.writeFileSync(path.join(articleDir, coverFilename), Buffer.from(imgBuffer));
// 更新音频信息
const audioInfo = s._pendingAudio;
delete s._pendingAudio;
// 生成带封面的音乐卡片
let name = "未知歌曲";
let artist = "未知艺术家";
if (audioInfo.name && audioInfo.artist) {
name = audioInfo.name;
artist = audioInfo.artist;
}
const musicCardShortcode = `\n\n{{< music-card src="${filename}" name="${name}" artist="${artist}" cover="${coverFilename}" >}}\n\n`;
appendContent(chatId, musicCardShortcode);
await sendMessage(chatId, `✅ 封面图已保存:${coverFilename}
🎵 <b>音乐卡片已插入:</b>
📌 歌曲:${name}
🎤 歌手:${artist}
🖼️ 封面:${coverFilename}`, { parse_mode: "HTML" });
} catch (e) {
console.error("[bot:audio-cover]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 封面图保存失败");
}
}
// ============================================================
// 增强版 handleAudio:支持封面图询问
// ============================================================
async function handleAudioEnhanced(chatId: number, fileId: string, fileName?: string, mimeType?: 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;
}
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 filePath = fileRes.result.file_path;
const fileUrl = "https://api.telegram.org/file/bot" + TG_TOKEN + "/" + filePath;
const audioBuffer = 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() || "mp3";
const filename = Date.now() + "-music." + ext;
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(audioBuffer));
// 询问歌曲信息
const session = getSession(chatId);
session._pendingAudio = { filename, fileId, name: null, artist: null };
await tg("sendMessage", {
chat_id: chatId,
text: `✅ 音频已保存:${filename}\n\n请输入歌曲信息(格式:歌名 - 歌手):`,
reply_markup: {
force_reply: true,
input_field_placeholder: "例如:晴天 - 周杰伦"
}
});
} catch (e) {
console.error("[bot:audio]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 音频处理失败:" + (e instanceof Error ? e.message : "未知错误"));
}
}
// ============================================================
// 增强版 handleAudioInfo:支持封面图询问
// ============================================================
async function handleAudioInfoEnhanced(chatId: number, text: string): Promise<boolean> {
try {
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s._pendingAudio) {
return false;
}
// 解析歌名和歌手
let name = "未知歌曲";
let artist = "未知艺术家";
const parts = text.split(/[-–—]/);
if (parts.length >= 2) {
name = parts[0].trim();
artist = parts.slice(1).join("-").trim();
} else if (text.trim()) {
name = text.trim();
}
// 保存歌曲信息
s._pendingAudio.name = name;
s._pendingAudio.artist = artist;
// 询问是否添加封面图
await tg("sendMessage", {
chat_id: chatId,
text: `🎵 歌曲:${name}\n🎤 歌手:${artist}\n\n是否添加封面图?`,
reply_markup: {
inline_keyboard: [
[
{ text: "📷 添加封面图", callback_data: "audio_add_cover" },
{ text: "⏭️ 跳过", callback_data: "audio_skip_cover" }
]
]
}
});
return true;
} catch (e) {
console.error("[bot:audio-info]", e instanceof Error ? e.message : String(e));
return false;
}
}
// 处理封面图选择回调
async function handleAudioCoverCallback(chatId: number, data: string) {
try {
const { getSession } = await import("./sessions");
const s = getSession(chatId);
if (!s._pendingAudio) {
await sendMessage(chatId, "❌ 没有待处理的音频文件");
return;
}
if (data === "audio_skip_cover") {
// 跳过封面图,直接生成音乐卡片
const { filename, name, artist } = s._pendingAudio;
delete s._pendingAudio;
const musicCardShortcode = `\n\n{{< music-card src="${filename}" name="${name}" artist="${artist}" >}}\n\n`;
appendContent(chatId, musicCardShortcode);
await sendMessage(chatId, `🎵 <b>音乐卡片已插入:</b>
📌 歌曲:${name}
🎤 歌手:${artist}`, { parse_mode: "HTML" });
} else if (data === "audio_add_cover") {
// 等待用户发送封面图
await tg("sendMessage", {
chat_id: chatId,
text: "🖼️ 请发送封面图片:",
reply_markup: {
force_reply: true,
input_field_placeholder: "发送一张图片作为封面"
}
});
}
} catch (e) {
console.error("[bot:audio-cover-callback]", e instanceof Error ? e.message : String(e));
}
}
+376
View File
@@ -0,0 +1,376 @@
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) {}
}