From 4ab47a3e4713c0395d867382c760cacc8f277446 Mon Sep 17 00:00:00 2001 From: Vaica <94612053+zqlit@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:10:10 +0800 Subject: [PATCH] 1410 --- write-server/src/lib/bot/index.ts | 2 + write-server/src/lib/bot/notify.ts | 89 ++++++++++++++++++++++++++++++ write-server/src/lib/bot/poll.ts | 45 +++++++++++++++ write-server/src/lib/voice.ts | 35 ++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 write-server/src/lib/bot/notify.ts create mode 100644 write-server/src/lib/voice.ts diff --git a/write-server/src/lib/bot/index.ts b/write-server/src/lib/bot/index.ts index 6c6ecbe7..a5b2de2e 100644 --- a/write-server/src/lib/bot/index.ts +++ b/write-server/src/lib/bot/index.ts @@ -1,4 +1,5 @@ import { startPolling } from "./poll"; +import { startCommentNotifier } from "./notify"; import { TG_TOKEN } from "./config"; const g = globalThis as Record; @@ -14,6 +15,7 @@ export async function startBot(): Promise { console.log("[bot] initializing..."); await startPolling(); + startCommentNotifier(); } export { TG_TOKEN } from "./config"; diff --git a/write-server/src/lib/bot/notify.ts b/write-server/src/lib/bot/notify.ts new file mode 100644 index 00000000..c5bc1fff --- /dev/null +++ b/write-server/src/lib/bot/notify.ts @@ -0,0 +1,89 @@ +import { ARTALK_SERVER, ARTALK_SITE } from "@/lib/config"; +import { sendMessage } from "./tg"; +import { ALLOWED_IDS } from "./config"; + +interface ArtalkComment { + id: number; + content: string; + page_key: string; + page_title?: string; + nick: string; + created_at: string; +} + +let lastCheckedId = 0; +let notifierStarted = false; + +async function fetchLatestComments(): Promise { + try { + const url = `${ARTALK_SERVER}/api/v2/comments?site_name=${encodeURIComponent(ARTALK_SITE)}&limit=5&sort_by=created_at&sort_order=desc`; + const res = await fetch(url, { + headers: { "Authorization": "Bearer " }, + }); + if (!res.ok) return []; + const data = await res.json() as { comments?: ArtalkComment[] }; + return data.comments || []; + } catch (e) { + console.error("[comment-notify] fetch error:", e instanceof Error ? e.message : String(e)); + return []; + } +} + +async function checkNewComments(): Promise { + const comments = await fetchLatestComments(); + if (comments.length === 0) return; + + // 首次运行,记录最新 ID,不发通知 + if (lastCheckedId === 0) { + lastCheckedId = comments[0].id; + return; + } + + // 找出新评论 + const newComments = comments.filter(c => c.id > lastCheckedId); + if (newComments.length === 0) return; + + // 更新最新 ID + lastCheckedId = Math.max(lastCheckedId, comments[0].id); + + // 发送通知(倒序,最早的先发) + const targets = ALLOWED_IDS.length > 0 ? ALLOWED_IDS : []; + if (targets.length === 0) return; + + for (const comment of newComments.reverse()) { + const text = comment.content.length > 100 + ? comment.content.slice(0, 100) + "..." + : comment.content; + + const msg = [ + `💬 新评论`, + `👤 ${comment.nick}`, + `📄 ${comment.page_title || comment.page_key}`, + ``, + `${text}`, + ``, + `🕐 ${new Date(comment.created_at).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`, + ].join("\n"); + + for (const chatId of targets) { + try { + await sendMessage(chatId, msg, { parse_mode: "HTML" }); + } catch (e) { + console.error("[comment-notify] send error:", e instanceof Error ? e.message : String(e)); + } + } + } +} + +export function startCommentNotifier(): void { + if (notifierStarted) return; + notifierStarted = true; + + console.log("[comment-notify] starting comment notifier..."); + + // 首次检查(记录当前最新 ID,不发通知) + checkNewComments(); + + // 每 60 秒检查一次 + setInterval(checkNewComments, 60_000); +} diff --git a/write-server/src/lib/bot/poll.ts b/write-server/src/lib/bot/poll.ts index 203b333c..c07073e5 100644 --- a/write-server/src/lib/bot/poll.ts +++ b/write-server/src/lib/bot/poll.ts @@ -73,6 +73,8 @@ export async function startPolling(): Promise { 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 }; @@ -81,6 +83,15 @@ export async function startPolling(): Promise { 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); @@ -261,6 +272,40 @@ async function handleDocument(chatId: number, fileId: string, fileName?: string, } } +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; diff --git a/write-server/src/lib/voice.ts b/write-server/src/lib/voice.ts new file mode 100644 index 00000000..d7b728db --- /dev/null +++ b/write-server/src/lib/voice.ts @@ -0,0 +1,35 @@ +import { DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL } from "@/lib/config"; + +// 语音转文字:使用 OpenAI Whisper API 或其他兼容服务 +const WHISPER_API_KEY = process.env.WHISPER_API_KEY || DEEPSEEK_API_KEY || ""; +const WHISPER_API_BASE = process.env.WHISPER_API_BASE || (DEEPSEEK_API_KEY ? DEEPSEEK_BASE_URL : "https://api.openai.com/v1"); +const WHISPER_MODEL = process.env.WHISPER_MODEL || "whisper-1"; + +export async function transcribeAudio(audioBuffer: ArrayBuffer, filename: string): Promise { + if (!WHISPER_API_KEY) { + throw new Error("未配置语音转文字 API(设置 WHISPER_API_KEY 或 DEEPSEEK_API_KEY)"); + } + + const formData = new FormData(); + const blob = new Blob([audioBuffer], { type: "audio/ogg" }); + formData.append("file", blob, filename); + formData.append("model", WHISPER_MODEL); + formData.append("language", "zh"); + + const url = `${WHISPER_API_BASE}/audio/transcriptions`; + const res = await fetch(url, { + method: "POST", + headers: { + "Authorization": `Bearer ${WHISPER_API_KEY}`, + }, + body: formData, + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`语音转文字失败: ${res.status} ${err}`); + } + + const data = await res.json() as { text?: string }; + return data.text || ""; +}