Files
blog/write-server/src/lib/bot/notify.ts
T
2026-06-24 14:37:49 +08:00

240 lines
6.7 KiB
TypeScript

import { ARTALK_SERVER, ARTALK_SITE, ARTALK_ADMIN_EMAIL, ARTALK_ADMIN_PASSWORD } 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;
}
interface ArtalkPage {
id: number;
key: string;
title: string;
}
let lastCheckedId = 0;
let notifierStarted = false;
let authToken: string | null = null;
let tokenExpiry = 0;
// 登录 Artalk 获取管理员 token
async function loginToArtalk(): Promise<string | null> {
if (!ARTALK_ADMIN_EMAIL || !ARTALK_ADMIN_PASSWORD) {
console.error("[comment-notify] ARTALK_ADMIN_EMAIL or ARTALK_ADMIN_PASSWORD not configured");
return null;
}
try {
const url = `${ARTALK_SERVER}/api/v2/auth/email/login`;
console.log("[comment-notify] logging in to Artalk...");
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: ARTALK_ADMIN_EMAIL,
password: ARTALK_ADMIN_PASSWORD,
}),
});
if (!res.ok) {
console.error("[comment-notify] login failed:", res.status);
return null;
}
const data = await res.json() as { token?: string; user?: { name: string } };
if (!data.token) {
console.error("[comment-notify] no token in response");
return null;
}
console.log("[comment-notify] logged in as:", data.user?.name || "admin");
return data.token;
} catch (e) {
console.error("[comment-notify] login error:", e instanceof Error ? e.message : String(e));
return null;
}
}
// 获取或刷新 token
async function getAuthToken(): Promise<string | null> {
const now = Date.now();
// Token 有效(提前 5 分钟刷新)
if (authToken && now < tokenExpiry - 5 * 60 * 1000) {
return authToken;
}
// 登录获取新 token
authToken = await loginToArtalk();
if (authToken) {
// Token 有效期设为 24 小时
tokenExpiry = now + 24 * 60 * 60 * 1000;
}
return authToken;
}
// 获取所有页面
async function fetchAllPages(): Promise<ArtalkPage[]> {
try {
const token = await getAuthToken();
if (!token) {
console.error("[comment-notify] no auth token for fetching pages");
return [];
}
const url = `${ARTALK_SERVER}/api/v2/pages?site_name=${encodeURIComponent(ARTALK_SITE)}&limit=200`;
console.log("[comment-notify] fetching pages...");
const res = await fetch(url, {
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
});
if (!res.ok) {
console.error("[comment-notify] fetch pages failed:", res.status);
return [];
}
const data = await res.json() as { pages?: ArtalkPage[]; count?: number };
console.log("[comment-notify] found", data.pages?.length || 0, "pages (total:", data.count || 0, ")");
return data.pages || [];
} catch (e) {
console.error("[comment-notify] fetch pages error:", e instanceof Error ? e.message : String(e));
return [];
}
}
// 获取特定页面的评论
async function fetchCommentsForPage(pageKey: string): Promise<ArtalkComment[]> {
try {
const token = await getAuthToken();
if (!token) {
return [];
}
const url = `${ARTALK_SERVER}/api/v2/comments?site_name=${encodeURIComponent(ARTALK_SITE)}&page_key=${encodeURIComponent(pageKey)}&limit=5&sort_by=date&sort_order=desc`;
const res = await fetch(url, {
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
});
if (!res.ok) {
return [];
}
const data = await res.json() as { comments?: ArtalkComment[] };
return data.comments || [];
} catch (e) {
return [];
}
}
// 获取所有页面的最新评论
async function fetchLatestComments(): Promise<ArtalkComment[]> {
try {
// 1. 获取所有页面
const pages = await fetchAllPages();
if (pages.length === 0) {
console.log("[comment-notify] no pages found");
return [];
}
console.log("[comment-notify] checking comments from", pages.length, "pages...");
// 2. 并行获取每个页面的评论(只查最新的 20 个页面,避免过多请求)
const recentPages = pages.slice(0, 20);
const promises = recentPages.map(async (page) => {
const comments = await fetchCommentsForPage(page.key);
return comments;
});
const results = await Promise.all(promises);
const allComments: ArtalkComment[] = [];
results.forEach(comments => allComments.push(...comments));
// 3. 按时间排序,取最新的 5 条
allComments.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
const latestComments = allComments.slice(0, 5);
console.log("[comment-notify] fetched", latestComments.length, "latest comments from", recentPages.length, "recent pages");
return latestComments;
} catch (e) {
console.error("[comment-notify] fetch error:", e instanceof Error ? e.message : String(e));
return [];
}
}
async function checkNewComments(): Promise<void> {
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 = [
`💬 <b>新评论</b>`,
`👤 ${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);
}