feat(telegram): 新增 Telegram 机器人通知与快捷审核功能
- 新增 Telegram 通知功能,支持通过机器人接收新评论通知 - 在 Telegram 消息中提供“批准”和“删除”按钮,支持快捷审核 - 新增后台 Telegram 设置页面,支持配置 Bot Token、Chat ID 和开关 - 新增 `/api/telegram/webhook` 端点处理按钮回调 - 更新邮件通知逻辑,统一使用评论设置中的 `adminEmail` 作为收件人 - 更新相关文档,包括功能说明和 API 文档
This commit is contained in:
73
cwd-api/src/api/admin/telegramSettings.ts
Normal file
73
cwd-api/src/api/admin/telegramSettings.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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({
|
||||
|
||||
54
cwd-api/src/api/telegram/webhook.ts
Normal file
54
cwd-api/src/api/telegram/webhook.ts
Normal 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, '评论已删除');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user