From 4cd98f7fb3758045f98df63dd4dff1092568aafe Mon Sep 17 00:00:00 2001 From: Orion Date: Tue, 5 May 2026 00:11:45 +0800 Subject: [PATCH] =?UTF-8?q?refactor(core):=20=E2=99=BB=EF=B8=8F=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20Telegram=20Bot=20=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现更灵活的消息管理功能,包括支持单条删除、批量删除以及全量清空话题历史。 - 重构 tg-bot.js 中的删除指令解析逻辑 - 新增 handleAdminDeleteAll 处理全量删除请求 - 支持通过 /del N 指令进行批量删除 - 更新 README.md 文档,补充管理员侧的操作说明和权限说明 - 优化管理员编辑消息时的通知逻辑 此更改提高了管理员维护群组话题的效率,并清晰化了用户与管理员的权限边界。 --- telegram/README.md | 16 +++-- telegram/tg-bot.js | 149 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 156 insertions(+), 9 deletions(-) diff --git a/telegram/README.md b/telegram/README.md index 8c44fb1..e009ba8 100644 --- a/telegram/README.md +++ b/telegram/README.md @@ -66,9 +66,12 @@ - 内置 Telegram 管理面板,主管理员通过 `/start` 打开 ### 双向消息删除 -- **用户侧**:引用自己发送的消息,发送 `/del` 命令,可以删除该消息并通知管理员 -- **管理员侧**:在 topic 中引用消息,发送 `/del` 命令,可以同时删除用户侧和管理员侧的消息 -- **权限控制**:用户只能删除自己发送的消息,无法删除管理员回复的消息 +- **用户侧**:引用自己发送的消息,发送 `/del` 命令,可以删除该消息并通知管理员。 +- **管理员侧**: + - **单条删除**:在 topic 中引用消息,发送 `/del` 命令,可以同时删除用户侧和管理员侧的消息。 + - **批量删除**:直接发送 `/del N`(如 `/del 3`),可删除当前话题内最近的 N 条消息(仅限管理员使用)。 + - **全量清空**:主管理员可使用 `/del all` 清空当前话题的所有历史消息。 +- **权限控制**:用户只能删除自己发送的消息,无法删除管理员回复的消息;批量删除功能仅对管理员开放。 ## 运行环境 @@ -227,8 +230,11 @@ curl -X POST "https://api.telegram.org/bot/setWebhook" \ 8. **引用消息**:用户可以使用 `>`、`》` 或 `>` 开头来引用之前的内容。 9. **删除消息**: - **用户侧**:引用自己发送的消息,发送 `/del` 命令,可以删除该消息并通知管理员。 - - **管理员侧**:在 topic 中引用消息,发送 `/del` 命令,可以同时删除用户侧和管理员侧的消息。 - - **注意**:用户只能删除自己发送的消息,无法删除管理员回复的消息。 + - **管理员侧**: + - 引用消息发送 `/del`:删除指定的单条双向记录。 + - 直接发送 `/del N`:删除当前话题内最近的 N 条消息(例如 `/del 5`)。 + - 主管理员发送 `/del all`:清空当前话题的所有历史记录。 + - **注意**:用户只能删除自己发送的消息,无法删除管理员回复的消息;批量删除功能仅对管理员开放。 ### 特殊语法 - **引用块**:以 `>`、`》` 或 `>` 开头的文本会被渲染为 HTML 引用块 diff --git a/telegram/tg-bot.js b/telegram/tg-bot.js index de8054a..d39b524 100644 --- a/telegram/tg-bot.js +++ b/telegram/tg-bot.js @@ -221,8 +221,10 @@ async function handleUpdate(update, env, ctx) { // 会话路由 if (msg.chat.type === "private") await handlePrivate(msg, env, ctx); else if (msg.chat.id.toString() === env.ADMIN_GROUP_ID) { - // 检查是否是管理员的 /del 命令 - if ((msg.text === "/del" || msg.caption === "/del") && msg.reply_to_message) { + const delCmd = parseDelCommand(msg.text || msg.caption || ""); + if (delCmd === "all") { + await handleAdminDeleteAll(msg, env); + } else if (delCmd === "single" && msg.reply_to_message) { await handleAdminDelete(msg, env); } else { await handleAdminReply(msg, env); @@ -340,11 +342,62 @@ async function handleUserDelete(msg, u, env) { async function handleAdminDelete(msg, env) { if (!msg.message_thread_id || !(await isAuthAdmin(msg.from.id, env))) return; + const text = msg.text || ""; + // 解析 /del N 格式 + const match = text.match(/^\/del\s+(\d+)$/i); + const count = match ? parseInt(match[1]) : 0; + + // 场景 1: 批量删除最近 N 条 + if (count > 0 && count <= 20) { // 限制单次最多删除 20 条以防 API 限流 + const rows = await sql(env, "SELECT message_id, topic_message_id FROM messages WHERE user_id=(SELECT user_id FROM users WHERE topic_id=?) ORDER BY date DESC LIMIT ?", + [msg.message_thread_id.toString(), count], 'all'); + + if (!rows || !rows.results || rows.results.length === 0) { + return api(env.BOT_TOKEN, "sendMessage", { + chat_id: msg.chat.id, + message_thread_id: msg.message_thread_id, + text: "❌ 当前话题没有可删除的消息记录" + }); + } + + let deletedCount = 0; + for (const r of rows.results) { + try { + // 删除管理员侧消息 + await api(env.BOT_TOKEN, "deleteMessage", { + chat_id: msg.chat.id, + message_id: parseInt(r.topic_message_id) + }).catch(() => {}); + + // 删除用户侧消息 + await api(env.BOT_TOKEN, "deleteMessage", { + chat_id: (await sql(env, "SELECT user_id FROM users WHERE topic_id=?", msg.message_thread_id.toString(), 'first')).user_id, + message_id: parseInt(r.message_id) + }).catch(() => {}); + + deletedCount++; + } catch (e) { console.error("Batch Delete Error:", e); } + } + + // 清理数据库记录 + await sql(env, "DELETE FROM messages WHERE user_id=(SELECT user_id FROM users WHERE topic_id=?) AND rowid IN (SELECT rowid FROM messages WHERE user_id=(SELECT user_id FROM users WHERE topic_id=?) ORDER BY date DESC LIMIT ?)", + [msg.message_thread_id.toString(), msg.message_thread_id.toString(), count]); + + // 删除触发命令本身 + await api(env.BOT_TOKEN, "deleteMessage", { + chat_id: msg.chat.id, + message_id: msg.message_id + }).catch(() => {}); + + return; // 批量删除完成后直接返回,不发送额外提示以保持界面整洁 + } + + // 场景 2: 删除单条(回复模式) if (!msg.reply_to_message) { return api(env.BOT_TOKEN, "sendMessage", { chat_id: msg.chat.id, message_thread_id: msg.message_thread_id, - text: "⚠️ 请回复要删除的消息后使用 /del 命令" + text: "⚠️ 请回复要删除的消息后使用 /del 命令,或使用 /del N 删除最近 N 条" }); } @@ -392,6 +445,84 @@ async function handleAdminDelete(msg, env) { } } +// 管理员侧批量删除:清空当前话题内用户会话消息(保留用户信息卡片) +async function handleAdminDeleteAll(msg, env) { + if (!msg.message_thread_id) return; + if (!(await isPrimaryAdmin(msg.from.id, env))) { + return api(env.BOT_TOKEN, "sendMessage", { + chat_id: msg.chat.id, + message_thread_id: msg.message_thread_id, + text: "❌ 仅主管理员可使用 /del all" + }); + } + + const userRef = await sql(env, "SELECT user_id FROM users WHERE topic_id = ?", msg.message_thread_id.toString(), 'first'); + if (!userRef?.user_id) { + return api(env.BOT_TOKEN, "sendMessage", { + chat_id: msg.chat.id, + message_thread_id: msg.message_thread_id, + text: "❌ 当前话题未绑定用户" + }); + } + + const u = await getUser(userRef.user_id, env); + const keepCardMsgId = u?.user_info?.card_msg_id ? parseInt(u.user_info.card_msg_id) : null; + const rows = await sql(env, "SELECT message_id, topic_message_id FROM messages WHERE user_id=?", [u.user_id], 'all'); + const mapped = rows?.results || []; + + let adminDeleted = 0; + let userDeleted = 0; + + try { + // 删除管理员侧命令消息本身 + await api(env.BOT_TOKEN, "deleteMessage", { + chat_id: msg.chat.id, + message_id: msg.message_id + }).catch(() => {}); + + for (const r of mapped) { + const topicMid = parseInt(r.topic_message_id); + const userMid = parseInt(r.message_id); + + if (Number.isInteger(topicMid) && (!keepCardMsgId || topicMid !== keepCardMsgId)) { + await api(env.BOT_TOKEN, "deleteMessage", { + chat_id: msg.chat.id, + message_id: topicMid + }).then(() => { adminDeleted += 1; }).catch(() => {}); + } + + if (Number.isInteger(userMid)) { + await api(env.BOT_TOKEN, "deleteMessage", { + chat_id: u.user_id, + message_id: userMid + }).then(() => { userDeleted += 1; }).catch(() => {}); + } + } + + await sql(env, "DELETE FROM messages WHERE user_id=?", [u.user_id]); + + const confirmMsg = await api(env.BOT_TOKEN, "sendMessage", { + chat_id: msg.chat.id, + message_thread_id: msg.message_thread_id, + text: `🧹 已清空当前话题消息\n管理员侧: ${adminDeleted} 条\n用户侧: ${userDeleted} 条\n(用户信息卡片已保留)` + }).catch(() => null); + + if (confirmMsg?.message_id) { + await api(env.BOT_TOKEN, "deleteMessage", { + chat_id: msg.chat.id, + message_id: confirmMsg.message_id + }).catch(() => {}); + } + } catch (e) { + console.error("Admin Delete All Failed:", e); + await api(env.BOT_TOKEN, "sendMessage", { + chat_id: msg.chat.id, + message_thread_id: msg.message_thread_id, + text: "❌ /del all 执行失败,请稍后重试" + }).catch(() => {}); + } +} + // 私聊消息处理总线 (用户侧逻辑入口) async function handlePrivate(msg, env, ctx) { const id = msg.chat.id.toString(); @@ -427,7 +558,7 @@ async function handlePrivate(msg, env, ctx) { } return isAdm ? handleAdminConfig(id, null, 'menu', null, null, env) : sendStart(id, msg, env); } - if (text === "/help" && isAdm) return api(env.BOT_TOKEN, "sendMessage", { chat_id: id, text: "ℹ️ 帮助\n• 回复消息即对话\n• /start 打开面板\n• /del 删除消息", parse_mode: "HTML" }); + if (text === "/help" && isAdm) return api(env.BOT_TOKEN, "sendMessage", { chat_id: id, text: "ℹ️ 帮助\n• 回复消息即对话\n• /start 打开面板\n• /del 删除单条消息\n• /del all 清空当前话题消息(仅主管理员)", parse_mode: "HTML" }); if (text === "/del" && !isAdm) return handleUserDelete(msg, u, env); // 2. 封禁拦截层 @@ -1110,6 +1241,16 @@ async function getAdminSets(env) { const isPrimaryAdmin = async (id, e) => (await getAdminSets(e)).primary.has(id.toString()); const isAuthAdmin = async (id, e) => (await getAdminSets(e)).auth.has(id.toString()); +function parseDelCommand(raw) { + const s = (raw || "").trim().toLowerCase(); + if (!s.startsWith("/del")) return null; + const cmd = s.split(/\s+/, 2)[0]; + if (!/^\/del(@[a-z0-9_]+)?$/i.test(cmd)) return null; + const rest = s.slice(cmd.length).trim(); + if (!rest) return "single"; + return rest === "all" ? "all" : null; +} + function safeRegexTest(pattern, text) { try { if (!pattern || typeof pattern !== "string") return false;