feat(telegram): 新增 Telegram 机器人通知与快捷审核功能
- 新增 Telegram 通知功能,支持通过机器人接收新评论通知 - 在 Telegram 消息中提供“批准”和“删除”按钮,支持快捷审核 - 新增后台 Telegram 设置页面,支持配置 Bot Token、Chat ID 和开关 - 新增 `/api/telegram/webhook` 端点处理按钮回调 - 更新邮件通知逻辑,统一使用评论设置中的 `adminEmail` 作为收件人 - 更新相关文档,包括功能说明和 API 文档
This commit is contained in:
@@ -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', {});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
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, '评论已删除');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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('未配置管理员通知邮箱或格式不正确');
|
||||
}
|
||||
|
||||
120
cwd-api/src/utils/telegram.ts
Normal file
120
cwd-api/src/utils/telegram.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -43,7 +43,7 @@ GET /admin/settings/comments
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| ---------------- | --------------- | --------------------------------------------------------- |
|
||||
| `adminEmail` | string | 博主邮箱地址,用于显示"博主"标识以及管理员身份验证 |
|
||||
| `adminEmail` | string | 博主邮箱地址,用于显示"博主"标识、管理员身份验证以及接收新评论邮件通知 |
|
||||
| `adminBadge` | string | 博主标识文字,例如 `"博主"` |
|
||||
| `avatarPrefix` | string | 头像地址前缀,如 Gravatar 或 Cravatar 镜像地址 |
|
||||
| `adminEnabled` | boolean | 是否启用博主标识相关展示 |
|
||||
|
||||
78
docs/api/admin/telegram-notify.md
Normal file
78
docs/api/admin/telegram-notify.md
Normal 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"
|
||||
}
|
||||
```
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## 邮件通知配置
|
||||
|
||||
在设置页面可以配置邮件通知功能,包括:
|
||||
在后台「设置」页面可以配置邮件通知功能,包括:
|
||||
|
||||
### 全局开关
|
||||
|
||||
@@ -58,4 +58,6 @@
|
||||
|
||||
### 测试邮件
|
||||
|
||||
配置完成后,可以使用"测试邮件"功能发送一封测试邮件到指定邮箱,验证配置是否正确。
|
||||
配置完成后,可以使用「发送测试邮件」功能验证配置是否正确。
|
||||
|
||||
测试邮件和实际的新评论通知收件人,都会使用「评论显示配置」中的管理员邮箱(`adminEmail`)。无需在「通知邮箱设置」中单独配置收件邮箱。
|
||||
|
||||
33
docs/function/telegram-notify.md
Normal file
33
docs/function/telegram-notify.md
Normal 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**(重要:必须执行此步骤,机器人才能接收按钮点击事件)。
|
||||
|
||||
## 使用说明
|
||||
|
||||
- **审核**:点击消息下方的按钮即可(批准 / 删除)。
|
||||
@@ -41,12 +41,13 @@ CWD 评论组件采用 **Shadow DOM** 技术构建,基于独立根节点渲染
|
||||
|
||||
头像前缀、博主邮箱和标识等信息由后端接口 `/api/config/comments` 提供,无需在前端进行配置。
|
||||
|
||||
当 `/admin/settings/comments` 中配置了“评论博主邮箱”时:
|
||||
当 `/admin/settings/comments` 中配置了“评论博主邮箱”(`adminEmail`)时:
|
||||
|
||||
- 前台组件会将该邮箱视为“管理员邮箱”;
|
||||
- 使用该邮箱发表评论时,会在邮箱输入框失焦后触发“管理员身份验证”弹窗;
|
||||
- 验证通过后,会在浏览器本地保存一次管理员密钥(仅用于本机后续请求携带 `adminToken`);
|
||||
- 后端会在 `/api/comments` 中校验此密钥,确保管理员身份的评论需要额外验证。
|
||||
- 后端会在 `/api/comments` 中校验此密钥,确保管理员身份的评论需要额外验证;
|
||||
- 后端邮件通知会将新评论提醒发送到该邮箱,无需再单独配置通知收件人。
|
||||
|
||||
## 实例方法
|
||||
|
||||
|
||||
Reference in New Issue
Block a user