feat(telegram): 新增 Telegram 机器人通知与快捷审核功能

- 新增 Telegram 通知功能,支持通过机器人接收新评论通知
- 在 Telegram 消息中提供“批准”和“删除”按钮,支持快捷审核
- 新增后台 Telegram 设置页面,支持配置 Bot Token、Chat ID 和开关
- 新增 `/api/telegram/webhook` 端点处理按钮回调
- 更新邮件通知逻辑,统一使用评论设置中的 `adminEmail` 作为收件人
- 更新相关文档,包括功能说明和 API 文档
This commit is contained in:
anghunk
2026-01-30 17:49:47 +08:00
parent c4954fca89
commit e1568f60ff
16 changed files with 678 additions and 85 deletions

View File

@@ -0,0 +1,73 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
import {
loadTelegramSettings,
saveTelegramSettings,
setTelegramWebhook,
sendTelegramMessage
} from '../../utils/telegram';
export const getTelegramSettings = async (c: Context<{ Bindings: Bindings }>) => {
try {
const settings = await loadTelegramSettings(c.env);
return c.json(settings);
} catch (e: any) {
return c.json({ message: e.message || '加载配置失败' }, 500);
}
};
export const updateTelegramSettings = async (c: Context<{ Bindings: Bindings }>) => {
try {
const body = await c.req.json();
const botToken = typeof body.botToken === 'string' ? body.botToken.trim() : null;
const chatId = typeof body.chatId === 'string' ? body.chatId.trim() : null;
const notifyEnabled = !!body.notifyEnabled;
await saveTelegramSettings(c.env, { botToken, chatId, notifyEnabled });
return c.json({ message: '保存成功' });
} catch (e: any) {
return c.json({ message: e.message || '保存失败' }, 500);
}
};
export const setupTelegramWebhook = async (c: Context<{ Bindings: Bindings }>) => {
try {
const settings = await loadTelegramSettings(c.env);
if (!settings.botToken) {
return c.json({ message: '请先保存机器人 Token' }, 400);
}
const url = new URL(c.req.url);
const webhookUrl = `${url.protocol}//${url.host}/api/telegram/webhook`;
const result = await setTelegramWebhook(settings.botToken, webhookUrl);
if (!result.ok) {
return c.json({ message: `Webhook 设置失败: ${result.description}` }, 400);
}
return c.json({ message: 'Webhook 设置成功', webhookUrl });
} catch (e: any) {
return c.json({ message: e.message || '设置失败' }, 500);
}
};
export const testTelegramMessage = async (c: Context<{ Bindings: Bindings }>) => {
try {
const settings = await loadTelegramSettings(c.env);
if (!settings.botToken || !settings.chatId) {
return c.json({ message: '请先配置 Bot Token 和 Chat ID' }, 400);
}
const text = `CWD 评论系统测试消息\n时间: ${new Date().toISOString()}`;
const result = await sendTelegramMessage(settings.botToken, settings.chatId, text);
if (!result.ok) {
return c.json({ message: `发送失败: ${result.description || '未知错误'}` }, 400);
}
return c.json({ message: '测试消息已发送' });
} catch (e: any) {
return c.json({ message: e.message || '发送失败' }, 500);
}
};

View File

@@ -53,18 +53,25 @@ export const getComments = async (c: Context<{ Bindings: Bindings }>) => {
`
}
// 并行获取评论和管理员邮箱
// 对 adminEmail 查询进行错误捕获,防止因 Settings 表不存在导致整个接口失败
const [commentsResult, adminEmailRow] = await Promise.all([
c.env.CWD_DB.prepare(query).bind(...slugList).all(),
c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
.bind('admin_notify_email')
.first<{ value: string }>()
.catch(() => null)
const [commentsResult, adminEmailRows] = await Promise.all([
c.env.CWD_DB.prepare(query).bind(...slugList).all(),
c.env.CWD_DB.prepare('SELECT key, value FROM Settings WHERE key IN (?, ?)')
.bind('comment_admin_email', 'admin_notify_email')
.all<{ key: string; value: string }>()
.catch(() => null)
]);
const results = commentsResult.results;
const adminEmail = adminEmailRow?.value || null;
let adminEmail: string | null = null;
if (adminEmailRows && Array.isArray(adminEmailRows.results)) {
const commentEmailRow = adminEmailRows.results.find(
(row) => row.key === 'comment_admin_email'
);
const legacyEmailRow = adminEmailRows.results.find(
(row) => row.key === 'admin_notify_email'
);
adminEmail = commentEmailRow?.value || legacyEmailRow?.value || null;
}
// 2. 批量处理头像并格式化
const allComments = await Promise.all(results.map(async (row: any) => ({

View File

@@ -11,6 +11,7 @@ import {
loadEmailNotificationSettings,
EmailNotificationSettings
} from '../../utils/email';
import { loadTelegramSettings, sendTelegramMessage } from '../../utils/telegram';
// 检查内容,将<script>标签之间的内容删除
export function checkContent(content: string): string {
@@ -156,7 +157,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
const defaultStatus = requireReview && !isAdminComment ? "pending" : "approved";
try {
const { success } = await c.env.CWD_DB.prepare(`
const result = await c.env.CWD_DB.prepare(`
INSERT INTO Comment (
created, post_slug, name, email, url, ip_address,
os, browser, device, ua, content_text, content_html,
@@ -179,12 +180,14 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
defaultStatus
).run();
if (!success) throw new Error("Database insert failed");
if (!result.success) throw new Error("Database insert failed");
const commentId = result.meta?.last_row_id;
console.log('PostComment:inserted', {
postSlug: post_slug,
hasParent: parentId !== null && parentId !== undefined,
ip
ip,
commentId
});
let notifySettings: EmailNotificationSettings = {
@@ -196,14 +199,19 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
console.error('PostComment:mailDispatch:loadEmailSettingsFailed', e);
}
if (!notifySettings.globalEnabled) {
console.log('PostComment:mailDispatch:disabledByGlobalConfig');
} else {
console.log('PostComment:mailDispatch:start', {
hasParent: parentId !== null && parentId !== undefined
});
c.executionCtx.waitUntil((async () => {
try {
console.log('PostComment:notify:start', {
hasParent: parentId !== null && parentId !== undefined,
emailEnabled: notifySettings.globalEnabled
});
c.executionCtx.waitUntil((async () => {
try {
if (!notifySettings.globalEnabled) {
console.log('PostComment:mailDispatch:disabledByGlobalConfig');
} else {
console.log('PostComment:mailDispatch:start', {
hasParent: parentId !== null && parentId !== undefined
});
if (parentId !== null && parentId !== undefined) {
let adminEmail: string | null = null;
try {
@@ -247,11 +255,44 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
}, notifySettings.smtp, notifySettings.templates?.admin);
console.log('PostComment:mailDispatch:admin:sent');
}
} catch (mailError) {
console.error("Mail Notification Failed:", mailError);
}
})());
}
} catch (mailError) {
console.error("Mail Notification Failed:", mailError);
}
try {
const tgSettings = await loadTelegramSettings(c.env);
if (tgSettings.notifyEnabled && tgSettings.botToken && tgSettings.chatId && commentId) {
const buttons: { text: string; callback_data: string }[] = [];
if (defaultStatus === 'pending') {
buttons.push({ text: "批准", callback_data: `approve:${commentId}` });
buttons.push({ text: "删除", callback_data: `delete:${commentId}` });
} else {
buttons.push({ text: "删除", callback_data: `delete:${commentId}` });
}
const message = `
💬 *新评论*
文章: [${data.post_title || 'Untitled'}](${data.post_url || '#'})
作者: ${name} (${email})
状态: ${defaultStatus === 'pending' ? '⏳ 待审核' : '✅ 已通过'}
${contentText}
#ID:${commentId}
`.trim();
await sendTelegramMessage(tgSettings.botToken, tgSettings.chatId, message, {
reply_markup: {
inline_keyboard: [buttons]
}
});
}
} catch (tgError) {
console.error('Telegram Notification Failed:', tgError);
}
})());
if (defaultStatus === "pending") {
return c.json({

View File

@@ -0,0 +1,54 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
import { loadTelegramSettings, editMessageText, answerCallbackQuery } from '../../utils/telegram';
export const telegramWebhook = async (c: Context<{ Bindings: Bindings }>) => {
try {
const settings = await loadTelegramSettings(c.env);
if (!settings.botToken) {
return c.text('Bot token not configured', 400);
}
const update = await c.req.json();
const { callback_query } = update;
if (callback_query) {
await handleCallbackQuery(c, settings.botToken, callback_query);
}
return c.text('OK');
} catch (e: any) {
console.error('Telegram Webhook Error:', e);
return c.text('Internal Server Error', 500);
}
};
async function handleCallbackQuery(c: Context<{ Bindings: Bindings }>, token: string, query: any) {
const { data, message, id } = query;
const chatId = message.chat.id;
const messageId = message.message_id;
if (!data) return;
const [action, commentIdStr] = data.split(':');
const commentId = parseInt(commentIdStr);
if (isNaN(commentId)) {
await answerCallbackQuery(token, id, 'Invalid Comment ID');
return;
}
if (action === 'approve') {
await c.env.CWD_DB.prepare('UPDATE Comment SET status = ? WHERE id = ?').bind('approved', commentId).run();
const newText = message.text + '\n\n✅ 已批准 (Approved)';
await editMessageText(token, chatId, messageId, newText);
await answerCallbackQuery(token, id, '评论已批准');
} else if (action === 'delete') {
await c.env.CWD_DB.prepare('DELETE FROM Comment WHERE id = ?').bind(commentId).run();
const newText = message.text + '\n\n🗑 已删除 (Deleted)';
await editMessageText(token, chatId, messageId, newText);
await answerCallbackQuery(token, id, '评论已删除');
}
}