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

@@ -329,3 +329,29 @@ export function saveFeatureSettings(data: {
}): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/features', data);
}
export type TelegramSettingsResponse = {
botToken: string | null;
chatId: string | null;
notifyEnabled: boolean;
};
export function fetchTelegramSettings(): Promise<TelegramSettingsResponse> {
return get<TelegramSettingsResponse>('/admin/settings/telegram');
}
export function saveTelegramSettings(data: {
botToken?: string;
chatId?: string;
notifyEnabled?: boolean;
}): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/telegram', data);
}
export function setupTelegramWebhook(): Promise<{ message: string; webhookUrl: string }> {
return post<{ message: string; webhookUrl: string }>('/admin/settings/telegram/setup', {});
}
export function sendTelegramTestMessage(): Promise<{ message: string }> {
return post<{ message: string }>('/admin/settings/telegram/test', {});
}

View File

@@ -151,15 +151,6 @@
<span class="slider" />
</label>
</div>
<div class="form-item">
<label class="form-label">管理员通知邮箱</label>
<input
v-model="email"
class="form-input"
type="email"
placeholder="接收新评论提醒的邮箱"
/>
</div>
<div class="divider"></div>
<h4 class="card-subtitle">1. SMTP 发件配置</h4>
@@ -216,7 +207,7 @@
<input
v-model="smtpPass"
class="form-input"
type="password"
type="text"
placeholder="QQ邮箱请使用授权码"
/>
<div v-if="smtpProvider === 'qq'" class="form-hint">
@@ -291,6 +282,80 @@
</div>
</transition>
</div>
<div class="card">
<div class="card-header" @click="toggleCard('telegram')">
<div class="card-title">Telegram 通知设置</div>
<div class="card-icon" :class="{ expanded: cardsExpanded.telegram }"></div>
</div>
<transition name="collapse">
<div v-show="cardsExpanded.telegram" class="card-body">
<div class="form-item">
<label class="form-label">开启 Telegram 通知</label>
<label class="switch">
<input v-model="telegramNotifyEnabled" type="checkbox" />
<span class="slider" />
</label>
</div>
<div class="form-item">
<label class="form-label">Bot Token</label>
<input
v-model="telegramBotToken"
class="form-input"
type="text"
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
/>
<div class="form-hint">
Telegram 中搜索
<a href="https://t.me/BotFather" target="_blank">@BotFather</a>
创建机器人获取 Token
</div>
</div>
<div class="form-item">
<label class="form-label">Chat ID</label>
<input
v-model="telegramChatId"
class="form-input"
type="text"
placeholder="123456789"
/>
<div class="form-hint">
这是接收通知的用户 ID 或群组 ID可以先给机器人发消息然后通过 API 获取
ID或者使用
<a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> 查询
</div>
</div>
<div class="card-actions" style="justify-content: space-between">
<button
class="card-button secondary"
:disabled="settingUpWebhook"
@click="doSetupWebhook"
>
<span v-if="settingUpWebhook">设置中...</span>
<span v-else>一键设置 Webhook</span>
</button>
<button
class="card-button secondary"
:disabled="testingTelegram"
@click="testTelegram"
style="margin-right:auto;"
>
<span v-if="testingTelegram">发送中...</span>
<span v-else>发送测试消息</span>
</button>
<button
class="card-button"
:disabled="savingTelegram"
@click="saveTelegram"
>
<span v-if="savingTelegram">保存中...</span>
<span v-else>保存</span>
</button>
</div>
</div>
</transition>
</div>
</div>
</div>
</template>
@@ -298,8 +363,6 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
fetchAdminEmail,
saveAdminEmail,
fetchCommentSettings,
saveCommentSettings,
fetchEmailNotifySettings,
@@ -307,6 +370,10 @@ import {
sendTestEmail,
fetchFeatureSettings,
saveFeatureSettings,
fetchTelegramSettings,
saveTelegramSettings,
setupTelegramWebhook,
sendTelegramTestMessage,
} from "../api/admin";
const DEFAULT_REPLY_TEMPLATE = `<div style="background-color:#f4f4f5;padding:24px 0;">
@@ -384,7 +451,6 @@ const DEFAULT_ADMIN_TEMPLATE = `<div style="background-color:#f4f4f5;padding:24p
</div>
`;
const email = ref("");
const emailGlobalEnabled = ref(true);
const commentAdminEmail = ref("");
const commentAdminBadge = ref("");
@@ -398,6 +464,13 @@ const adminKeySet = ref(false);
const requireReview = ref(false);
const enableArticleLike = ref(true);
const enableCommentLike = ref(true);
const telegramBotToken = ref("");
const telegramChatId = ref("");
const telegramNotifyEnabled = ref(false);
const savingTelegram = ref(false);
const settingUpWebhook = ref(false);
const testingTelegram = ref(false);
const savingEmail = ref(false);
const testingEmail = ref(false);
const savingComment = ref(false);
@@ -420,7 +493,7 @@ function loadCardsExpanded() {
} catch {
// 忽略错误
}
return { comment: true, feature: false, email: false };
return { comment: true, feature: false, email: false, telegram: false };
}
const cardsExpanded = ref(loadCardsExpanded());
@@ -472,13 +545,12 @@ function resetTemplatesToDefault() {
async function load() {
loading.value = true;
try {
const [notifyRes, commentRes, emailNotifyRes, featureRes] = await Promise.all([
fetchAdminEmail(),
const [commentRes, emailNotifyRes, featureRes, telegramRes] = await Promise.all([
fetchCommentSettings(),
fetchEmailNotifySettings(),
fetchFeatureSettings(),
fetchTelegramSettings(),
]);
email.value = notifyRes.email || "";
commentAdminEmail.value = commentRes.adminEmail || "";
commentAdminBadge.value = commentRes.adminBadge ?? "";
avatarPrefix.value = commentRes.avatarPrefix || "";
@@ -497,6 +569,10 @@ async function load() {
enableArticleLike.value = featureRes.enableArticleLike;
enableCommentLike.value = featureRes.enableCommentLike;
telegramBotToken.value = telegramRes.botToken || "";
telegramChatId.value = telegramRes.chatId || "";
telegramNotifyEnabled.value = telegramRes.notifyEnabled;
if (emailNotifyRes.templates) {
templateAdmin.value = emailNotifyRes.templates.admin || DEFAULT_ADMIN_TEMPLATE;
templateReply.value = emailNotifyRes.templates.reply || DEFAULT_REPLY_TEMPLATE;
@@ -527,32 +603,24 @@ async function load() {
}
async function saveEmail() {
if (!email.value) {
message.value = "请输入邮箱";
messageType.value = "error";
return;
}
savingEmail.value = true;
message.value = "";
try {
const [emailRes] = await Promise.all([
saveAdminEmail(email.value),
saveEmailNotifySettings({
globalEnabled: emailGlobalEnabled.value,
smtp: {
host: smtpHost.value,
port: smtpPort.value,
user: smtpUser.value,
pass: smtpPass.value,
secure: smtpSecure.value,
},
templates: {
reply: templateReply.value,
admin: templateAdmin.value,
},
}),
]);
showToast(emailRes.message || "保存成功", "success");
const res = await saveEmailNotifySettings({
globalEnabled: emailGlobalEnabled.value,
smtp: {
host: smtpHost.value,
port: smtpPort.value,
user: smtpUser.value,
pass: smtpPass.value,
secure: smtpSecure.value,
},
templates: {
reply: templateReply.value,
admin: templateAdmin.value,
},
});
showToast(res.message || "保存成功", "success");
} catch (e: any) {
message.value = e.message || "保存失败";
messageType.value = "error";
@@ -562,8 +630,8 @@ async function saveEmail() {
}
async function testEmail() {
if (!email.value) {
message.value = "请输入管理员通知邮箱作为测试接收邮箱";
if (!commentAdminEmail.value) {
message.value = "请先在上方“评论显示配置”中设置管理员邮箱";
messageType.value = "error";
return;
}
@@ -577,7 +645,7 @@ async function testEmail() {
message.value = "";
try {
const res = await sendTestEmail({
toEmail: email.value,
toEmail: commentAdminEmail.value,
smtp: {
host: smtpHost.value,
port: smtpPort.value,
@@ -664,6 +732,68 @@ async function saveFeature() {
}
}
async function saveTelegram() {
savingTelegram.value = true;
message.value = "";
try {
const res = await saveTelegramSettings({
botToken: telegramBotToken.value,
chatId: telegramChatId.value,
notifyEnabled: telegramNotifyEnabled.value,
});
showToast(res.message || "保存成功", "success");
} catch (e: any) {
message.value = e.message || "保存失败";
messageType.value = "error";
} finally {
savingTelegram.value = false;
}
}
async function testTelegram() {
if (!telegramBotToken.value || !telegramChatId.value) {
showToast("请先填写 Bot Token 和 Chat ID 并保存", "error");
return;
}
testingTelegram.value = true;
try {
await saveTelegramSettings({
botToken: telegramBotToken.value,
chatId: telegramChatId.value,
notifyEnabled: telegramNotifyEnabled.value,
});
const res = await sendTelegramTestMessage();
showToast(res.message || "测试消息已发送", "success");
} catch (e: any) {
showToast(e.message || "发送失败", "error");
} finally {
testingTelegram.value = false;
}
}
async function doSetupWebhook() {
if (!telegramBotToken.value) {
showToast("请先填写 Bot Token 并保存", "error");
return;
}
settingUpWebhook.value = true;
try {
// First save settings to ensure backend has latest token
await saveTelegramSettings({
botToken: telegramBotToken.value,
chatId: telegramChatId.value,
notifyEnabled: telegramNotifyEnabled.value,
});
const res = await setupTelegramWebhook();
showToast(res.message || "Webhook 设置成功", "success");
} catch (e: any) {
showToast(e.message || "设置失败", "error");
} finally {
settingUpWebhook.value = false;
}
}
onMounted(() => {
load();
});

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, '评论已删除');
}
}

View File

@@ -47,6 +47,13 @@ import {
getFeatureSettings,
updateFeatureSettings
} from './api/admin/featureSettings';
import {
getTelegramSettings,
updateTelegramSettings,
setupTelegramWebhook,
testTelegramMessage
} from './api/admin/telegramSettings';
import { telegramWebhook } from './api/telegram/webhook';
const app = new Hono<{ Bindings: Bindings }>();
const VERSION = `v${packageJson.version}`;
@@ -242,6 +249,7 @@ app.post('/api/analytics/visit', trackVisit);
app.get('/api/like', getLikeStatus);
app.post('/api/like', likePage);
app.post('/api/comments/like', likeComment);
app.post('/api/telegram/webhook', telegramWebhook);
app.get('/api/config/comments', async (c) => {
try {
const settings = await loadCommentSettings(c.env);
@@ -313,6 +321,12 @@ app.put('/admin/settings/email-notify', async (c) => {
});
app.post('/admin/settings/email-test', testEmail);
app.get('/admin/settings/telegram', getTelegramSettings);
app.put('/admin/settings/telegram', updateTelegramSettings);
app.post('/admin/settings/telegram/setup', setupTelegramWebhook);
app.post('/admin/settings/telegram/test', testTelegramMessage);
app.get('/admin/settings/comments', async (c) => {
try {
const settings = await loadCommentSettings(c.env);

View File

@@ -409,12 +409,22 @@ export async function getAdminNotifyEmail(env: Bindings): Promise<string> {
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const row = await env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
.bind('admin_notify_email')
.first<{ value: string }>();
if (row?.value && isValidEmail(row.value)) {
const cleanEmail = row.value.trim();
return cleanEmail;
const rows = await env.CWD_DB.prepare(
'SELECT key, value FROM Settings WHERE key IN (?, ?)'
)
.bind('comment_admin_email', 'admin_notify_email')
.all<{ key: string; value: string }>();
let email: string | null = null;
if (rows && Array.isArray(rows.results)) {
const commentEmailRow = rows.results.find((row) => row.key === 'comment_admin_email');
const legacyEmailRow = rows.results.find((row) => row.key === 'admin_notify_email');
email = commentEmailRow?.value || legacyEmailRow?.value || null;
}
if (email && isValidEmail(email)) {
return email.trim();
}
throw new Error('未配置管理员通知邮箱或格式不正确');
}

View File

@@ -0,0 +1,120 @@
import { Bindings } from '../bindings';
export const TG_BOT_TOKEN_KEY = 'telegram_bot_token';
export const TG_CHAT_ID_KEY = 'telegram_chat_id';
export const TG_NOTIFY_ENABLED_KEY = 'telegram_notify_enabled';
export interface TelegramSettings {
botToken: string | null;
chatId: string | null;
notifyEnabled: boolean;
}
export async function loadTelegramSettings(env: Bindings): Promise<TelegramSettings> {
const keys = [TG_BOT_TOKEN_KEY, TG_CHAT_ID_KEY, TG_NOTIFY_ENABLED_KEY];
const { results } = await env.CWD_DB.prepare(
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?)'
)
.bind(...keys)
.all<{ key: string; value: string }>();
const map = new Map<string, string>();
for (const row of results) {
map.set(row.key, row.value);
}
return {
botToken: map.get(TG_BOT_TOKEN_KEY) ?? null,
chatId: map.get(TG_CHAT_ID_KEY) ?? null,
notifyEnabled: map.get(TG_NOTIFY_ENABLED_KEY) === '1',
};
}
export async function saveTelegramSettings(env: Bindings, settings: TelegramSettings) {
const entries = [
{ key: TG_BOT_TOKEN_KEY, value: settings.botToken },
{ key: TG_CHAT_ID_KEY, value: settings.chatId },
{ key: TG_NOTIFY_ENABLED_KEY, value: settings.notifyEnabled ? '1' : '0' },
];
for (const entry of entries) {
if (entry.value !== undefined && entry.value !== null) {
await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)')
.bind(entry.key, entry.value)
.run();
} else if (entry.value === null) { // Explicit null means delete
await env.CWD_DB.prepare('DELETE FROM Settings WHERE key = ?').bind(entry.key).run();
}
}
}
export async function sendTelegramMessage(
token: string,
chatId: string,
text: string,
options: any = {}
) {
const url = `https://api.telegram.org/bot${token}/sendMessage`;
const body = {
chat_id: chatId,
text,
parse_mode: 'Markdown',
...options,
};
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return response.json();
}
export async function setTelegramWebhook(token: string, webhookUrl: string) {
const url = `https://api.telegram.org/bot${token}/setWebhook`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: webhookUrl }),
});
return response.json();
}
export async function deleteMessage(token: string, chatId: string | number, messageId: number) {
const url = `https://api.telegram.org/bot${token}/deleteMessage`;
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, message_id: messageId }),
});
}
export async function editMessageText(token: string, chatId: string | number, messageId: number, text: string, options: any = {}) {
const url = `https://api.telegram.org/bot${token}/editMessageText`;
const body = {
chat_id: chatId,
message_id: messageId,
text,
parse_mode: 'Markdown',
...options,
};
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return response.json();
}
export async function answerCallbackQuery(token: string, callbackQueryId: string, text?: string) {
const url = `https://api.telegram.org/bot${token}/answerCallbackQuery`;
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ callback_query_id: callbackQueryId, text }),
});
}

View File

@@ -13,6 +13,7 @@ export const rootSidebar = [
items: [
{ text: '后台设置', link: '/function/admin-panel' },
{ text: '通知邮箱', link: '/function/email-reminder' },
{ text: 'Telegram 通知', link: '/function/telegram-notify' },
{ text: '安全设置', link: '/function/security-settings' },
{ text: '数据管理', link: '/function/data-migration' },
{ text: '点赞开关', link: '/function/feature-settings' },
@@ -37,6 +38,7 @@ export const apiSidebar = [
{ text: '数据管理', link: '/api/admin/data-migration' },
{ text: '评论设置', link: '/api/admin/settings' },
{ text: '邮件通知', link: '/api/admin/email-notify' },
{ text: 'Telegram 通知', link: '/api/admin/telegram-notify' },
{ text: '统计数据', link: '/api/admin/stats' },
{ text: '访问统计', link: '/api/admin/analytics' },
{ text: '点赞开关', link: '/api/admin/feature-settings' },

View File

@@ -2,6 +2,8 @@
邮件通知配置接口用于获取和更新 SMTP 配置、邮件模板以及测试邮件发送功能。
新评论的邮件通知会发送到「评论设置」(`/admin/settings/comments`) 中配置的管理员邮箱(`adminEmail`),无需在本接口中单独设置收件邮箱。
所有接口都需要在请求头中携带 Bearer Token。
```http
@@ -194,10 +196,10 @@ POST /admin/settings/email-test
字段说明:
| 字段名 | 类型 | 必填 | 说明 |
| ----------- | ------ | ---- | ------------------------------------ |
| `toEmail` | string | 是 | 接收测试邮件的邮箱地址 |
| `smtp` | object | 是 | SMTP 配置对象(与更新配置接口相同) |
| 字段名 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ------------------------------------ |
| `toEmail` | string | 是 | 接收测试邮件的邮箱地址 |
| `smtp` | object | 是 | SMTP 配置对象(与更新配置接口相同) |
**成功响应**
@@ -247,7 +249,7 @@ GET /admin/settings/email
获取当前通知邮箱配置。
> [!NOTE]
> 此接口已被 `/admin/settings/email-notify` 替代,建议使用接口获取完整的邮件通知配置
> 此接口已被 `/admin/settings/email-notify` 替代,且管理员通知邮箱已统一由 `/admin/settings/comments` 中的 `adminEmail` 提供,不再推荐使用接口。
- 方法:`GET`
- 路径:`/admin/settings/email`
@@ -282,7 +284,7 @@ PUT /admin/settings/email
设置通知邮箱,用于接收新评论提醒。
> [!NOTE]
> 此接口已被 `/admin/settings/email-notify` 替代,建议使用新接口获取完整的邮件通知配置
> 此接口已被 `/admin/settings/email-notify` 替代,且通知收件邮箱已统一使用评论配置中的 `adminEmail`,不再推荐调用本接口
- 方法:`PUT`
- 路径:`/admin/settings/email`

View File

@@ -43,7 +43,7 @@ GET /admin/settings/comments
| 字段名 | 类型 | 说明 |
| ---------------- | --------------- | --------------------------------------------------------- |
| `adminEmail` | string | 博主邮箱地址,用于显示"博主"标识以及管理员身份验证 |
| `adminEmail` | string | 博主邮箱地址,用于显示"博主"标识管理员身份验证以及接收新评论邮件通知 |
| `adminBadge` | string | 博主标识文字,例如 `"博主"` |
| `avatarPrefix` | string | 头像地址前缀,如 Gravatar 或 Cravatar 镜像地址 |
| `adminEnabled` | boolean | 是否启用博主标识相关展示 |

View File

@@ -0,0 +1,78 @@
# Telegram 通知设置
Telegram 通知设置接口用于获取和更新 Telegram 机器人的配置,包括 Bot Token、Chat ID 以及 Webhook 设置。
所有接口都需要在请求头中携带 Bearer Token。
```http
Authorization: Bearer <token>
```
## 1.1 获取 Telegram 配置
```
GET /admin/settings/telegram
```
获取当前的 Telegram 通知配置。
- 方法:`GET`
- 路径:`/admin/settings/telegram`
- 鉴权需要Bearer Token
**成功响应**
- 状态码:`200`
```json
{
"botToken": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"chatId": "123456789",
"notifyEnabled": true
}
```
## 1.2 更新 Telegram 配置
```
PUT /admin/settings/telegram
```
更新 Telegram 通知配置。
- 方法:`PUT`
- 路径:`/admin/settings/telegram`
- 鉴权需要Bearer Token
**请求体**
```json
{
"botToken": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"chatId": "123456789",
"notifyEnabled": true
}
```
## 1.3 一键设置 Webhook
```
POST /admin/settings/telegram/setup
```
自动设置 Telegram Webhook URL。此接口会使用当前 Worker 的域名构建 Webhook URL 并调用 Telegram API。
- 方法:`POST`
- 路径:`/admin/settings/telegram/setup`
- 鉴权需要Bearer Token
**成功响应**
- 状态码:`200`
```json
{
"message": "Webhook 设置成功",
"webhookUrl": "https://your-domain.com/api/telegram/webhook"
}
```

View File

@@ -15,7 +15,7 @@
## 邮件通知配置
在设置页面可以配置邮件通知功能,包括:
后台「设置页面可以配置邮件通知功能,包括:
### 全局开关
@@ -58,4 +58,6 @@
### 测试邮件
配置完成后,可以使用"测试邮件"功能发送一封测试邮件到指定邮箱,验证配置是否正确。
配置完成后,可以使用「发送测试邮件功能验证配置是否正确。
测试邮件和实际的新评论通知收件人,都会使用「评论显示配置」中的管理员邮箱(`adminEmail`)。无需在「通知邮箱设置」中单独配置收件邮箱。

View File

@@ -0,0 +1,33 @@
# Telegram 通知与交互
系统支持通过 Telegram 机器人接收新评论通知,并在 Telegram 中对评论进行批准或删除操作。
## 功能特性
1. **实时通知**:新评论会立即推送到指定的 Telegram 账号或群组。
2. **快捷审核**:通知消息包含“批准”和“删除”按钮,无需登录后台即可审核。
## 配置步骤
1. **创建机器人**
- 在 Telegram 中搜索 `@BotFather`
- 发送 `/newbot` 命令,按照提示创建新机器人。
- 获取 **API Token**
2. **获取 Chat ID**
- 如果是个人接收,给你的机器人发送一条消息。
- 访问 `https://api.telegram.org/bot<YourBOTToken>/getUpdates` 查看 `chat.id`
- 或者使用 `@userinfobot` 获取你的 ID。
- 如果是群组,将机器人拉入群组,并获取群组 ID通常以 `-` 开头)。
3. **后台设置**
- 登录评论系统后台。
- 进入“设置” -> “Telegram 通知设置”。
- 填入 **Bot Token****Chat ID**
- 开启 **Telegram 通知** 开关。
- 点击 **保存**
- 点击 **一键设置 Webhook**(重要:必须执行此步骤,机器人才能接收按钮点击事件)。
## 使用说明
- **审核**:点击消息下方的按钮即可(批准 / 删除)。

View File

@@ -41,12 +41,13 @@ CWD 评论组件采用 **Shadow DOM** 技术构建,基于独立根节点渲染
头像前缀、博主邮箱和标识等信息由后端接口 `/api/config/comments` 提供,无需在前端进行配置。
`/admin/settings/comments` 中配置了“评论博主邮箱”时:
`/admin/settings/comments` 中配置了“评论博主邮箱”`adminEmail`时:
- 前台组件会将该邮箱视为“管理员邮箱”;
- 使用该邮箱发表评论时,会在邮箱输入框失焦后触发“管理员身份验证”弹窗;
- 验证通过后,会在浏览器本地保存一次管理员密钥(仅用于本机后续请求携带 `adminToken`
- 后端会在 `/api/comments` 中校验此密钥,确保管理员身份的评论需要额外验证
- 后端会在 `/api/comments` 中校验此密钥,确保管理员身份的评论需要额外验证
- 后端邮件通知会将新评论提醒发送到该邮箱,无需再单独配置通知收件人。
## 实例方法