feat: add whitelist moderation flow

This commit is contained in:
ngfchl
2026-07-07 22:17:43 +08:00
parent 2f6739685f
commit 6aa41c2c0e
11 changed files with 443 additions and 346 deletions
+14
View File
@@ -47,6 +47,20 @@ async def ensure_runtime_schema():
ALTER TABLE verify_queue ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0; ALTER TABLE verify_queue ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0;
ALTER TABLE warnings ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0; ALTER TABLE warnings ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0;
ALTER TABLE game_scores ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0; ALTER TABLE game_scores ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS whitelists (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
chat_id BIGINT NOT NULL DEFAULT 0,
user_id BIGINT NOT NULL,
username VARCHAR(255) NULL,
first_name VARCHAR(255) NULL,
reason VARCHAR(500) NULL,
operator_id BIGINT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE(chat_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_whitelists_user_enabled ON whitelists(user_id, enabled);
CREATE TABLE IF NOT EXISTS shop_items ( CREATE TABLE IF NOT EXISTS shop_items (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+17
View File
@@ -62,6 +62,23 @@ class Action(TimestampMixin, Model):
indexes = (("action", "created_at"), ("user_id", "created_at"), ("operator_id", "created_at")) indexes = (("action", "created_at"), ("user_id", "created_at"), ("operator_id", "created_at"))
class Whitelist(TimestampMixin, Model):
"""白名单用户:命中垃圾规则时只删除消息,不封禁账号。"""
id = fields.BigIntField(pk=True, generated=True)
chat_id = fields.BigIntField(default=0)
user_id = fields.BigIntField()
username = fields.CharField(max_length=255, null=True)
first_name = fields.CharField(max_length=255, null=True)
reason = fields.CharField(max_length=500, null=True)
operator_id = fields.BigIntField(null=True)
enabled = fields.BooleanField(default=True)
class Meta:
table = "whitelists"
indexes = (("chat_id", "user_id"), ("user_id", "enabled"))
unique_together = (("chat_id", "user_id"),)
class VerifyQueue(TimestampMixin, Model): class VerifyQueue(TimestampMixin, Model):
"""入群验证队列""" """入群验证队列"""
id = fields.BigIntField(pk=True, generated=True) id = fields.BigIntField(pk=True, generated=True)
+19 -1
View File
@@ -556,7 +556,7 @@ function openChatContextMenu(event: MouseEvent, item: ChatItem) {
chatContextMenu.item = item; chatContextMenu.item = item;
chatContextMenu.open = true; chatContextMenu.open = true;
const menuWidth = 188; const menuWidth = 188;
const menuHeight = item.deleted ? 152 : 192; const menuHeight = item.deleted ? 184 : 224;
chatContextMenu.x = Math.min(event.clientX, window.innerWidth - menuWidth - 8); chatContextMenu.x = Math.min(event.clientX, window.innerWidth - menuWidth - 8);
chatContextMenu.y = Math.min(event.clientY, window.innerHeight - menuHeight - 8); chatContextMenu.y = Math.min(event.clientY, window.innerHeight - menuHeight - 8);
} }
@@ -569,6 +569,23 @@ async function contextAddToRules() {
closeChatContextMenu(); closeChatContextMenu();
if (item) await addChatMessageToRules(item); if (item) await addChatMessageToRules(item);
} }
async function contextAddWhitelist() {
const item = chatContextMenu.item;
closeChatContextMenu();
if (!item?.user_id) return showToast('当前消息缺少用户 ID,无法加入白名单');
if (!await askConfirm('加入白名单', `确认将 @${item.username || item.first_name || item.user_id} 加入白名单?
白名单用户命中垃圾规则时只删除消息,不封禁账号。`, { confirmText: '加入白名单' })) return;
try {
await fetchJson(`/api/messages/${item.id}/whitelist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: 'Web 右键加入白名单' })
});
showToast('已加入白名单:后续只删消息,不封禁账号');
} catch (e: any) {
showToast(`加入白名单失败:${e.message}`);
}
}
async function contextDeleteMessage(ban = false) { async function contextDeleteMessage(ban = false) {
const item = chatContextMenu.item; const item = chatContextMenu.item;
closeChatContextMenu(); closeChatContextMenu();
@@ -885,6 +902,7 @@ onBeforeUnmount(() => {
<button class="danger" :disabled="!!chatContextMenu.item?.deleted" @click="contextDeleteMessage(false)">🗑 删除</button> <button class="danger" :disabled="!!chatContextMenu.item?.deleted" @click="contextDeleteMessage(false)">🗑 删除</button>
<button class="warning" :disabled="!chatContextMenu.item?.user_id" @click="contextDeleteAndBanMessage"> 删除并封禁</button> <button class="warning" :disabled="!chatContextMenu.item?.user_id" @click="contextDeleteAndBanMessage"> 删除并封禁</button>
<button class="warning" :disabled="!chatContextMenu.item?.user_id" @click="contextBanMessage">🚫 封印用户</button> <button class="warning" :disabled="!chatContextMenu.item?.user_id" @click="contextBanMessage">🚫 封印用户</button>
<button :disabled="!chatContextMenu.item?.user_id" @click="contextAddWhitelist">🛡 加入白名单</button>
<button @click="contextAddToRules">📥 写入拦截库</button> <button @click="contextAddToRules">📥 写入拦截库</button>
<button @click="contextCopyText">📋 复制内容</button> <button @click="contextCopyText">📋 复制内容</button>
<button @click="contextReplyMessage"> 回复</button> <button @click="contextReplyMessage"> 回复</button>
+6 -7
View File
@@ -219,7 +219,7 @@ async def cmd_mute(update: Update, context: ContextTypes.DEFAULT_TYPE):
asyncio.create_task(auto_delete(reply)) asyncio.create_task(auto_delete(reply))
async def require_reply_shortcut(update: Update, command: str, usage: str) -> bool: async def require_reply_shortcut(update: Update, context: ContextTypes.DEFAULT_TYPE, usage: str) -> bool:
"""引用消息快捷管理命令必须回复目标消息。""" """引用消息快捷管理命令必须回复目标消息。"""
if update.message and update.message.reply_to_message: if update.message and update.message.reply_to_message:
return True return True
@@ -233,7 +233,7 @@ async def cmd_dban(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""删除被引用消息并封禁用户。""" """删除被引用消息并封禁用户。"""
if not await require_admin(update, context): if not await require_admin(update, context):
return return
if not await require_reply_shortcut(update, "dban", "/dban"): if not await require_reply_shortcut(update, context, "/dban"):
return return
await cmd_ban(update, context) await cmd_ban(update, context)
@@ -242,7 +242,7 @@ async def cmd_dmute(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""删除被引用消息并禁言用户。""" """删除被引用消息并禁言用户。"""
if not await require_admin(update, context): if not await require_admin(update, context):
return return
if not await require_reply_shortcut(update, "dmute", "/dmute [分钟]"): if not await require_reply_shortcut(update, context, "/dmute [分钟]"):
return return
await cmd_mute(update, context) await cmd_mute(update, context)
@@ -251,7 +251,7 @@ async def cmd_dwarn(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""删除被引用消息并警告用户。""" """删除被引用消息并警告用户。"""
if not await require_admin(update, context): if not await require_admin(update, context):
return return
if not await require_reply_shortcut(update, "dwarn", "/dwarn [原因]"): if not await require_reply_shortcut(update, context, "/dwarn [原因]"):
return return
await cmd_warn(update, context) await cmd_warn(update, context)
@@ -306,11 +306,10 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
"✅ /checkin - 每日签到\n" "✅ /checkin - 每日签到\n"
"🎮 /trivia - 知识答题\n" "🎮 /trivia - 知识答题\n"
"🏆 /rank - 查看积分排行\n" "🏆 /rank - 查看积分排行\n"
"🎁 /lottery - 发起群内抽奖,进入私聊设置奖品和条件\n"
" 私聊格式:奖品 | 参与条件 | 结束条件\n"
" 示例:月卡一张 | all | people:3\n"
"🔗 /bind - 绑定账号\n\n" "🔗 /bind - 绑定账号\n\n"
"管理员操作:\n" "管理员操作:\n"
"🎁 /lottery - 发起群内抽奖,进入私聊设置奖品和条件\n"
"🎛️ /panel - 打开管理操作面板\n"
"🌙 /night on/off - 开启/关闭夜间模式\n" "🌙 /night on/off - 开启/关闭夜间模式\n"
"🔨 /ban 用户ID/@历史用户名 - 封禁用户\n" "🔨 /ban 用户ID/@历史用户名 - 封禁用户\n"
"🔇 /mute 用户ID/@历史用户名 [分钟] - 禁言用户,默认 10 分钟\n" "🔇 /mute 用户ID/@历史用户名 [分钟] - 禁言用户,默认 10 分钟\n"
+6
View File
@@ -4,6 +4,7 @@ import logging
import config import config
from services.message_utils import record_bot_message from services.message_utils import record_bot_message
from services.admin_utils import require_admin, is_admin
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ApplicationHandlerStop, CallbackQueryHandler, ContextTypes, CommandHandler, MessageHandler, filters from telegram.ext import ApplicationHandlerStop, CallbackQueryHandler, ContextTypes, CommandHandler, MessageHandler, filters
@@ -285,6 +286,8 @@ async def cmd_buy(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def cmd_panel(update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_panel(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not await require_admin(update, context):
return
reply = await update.message.reply_text( reply = await update.message.reply_text(
"🎛️ 操作面板\n请选择要执行的操作:\n\n⏱️ 60 秒无操作后自动消失。", "🎛️ 操作面板\n请选择要执行的操作:\n\n⏱️ 60 秒无操作后自动消失。",
reply_markup=panel_keyboard(), reply_markup=panel_keyboard(),
@@ -294,6 +297,9 @@ async def cmd_panel(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def handle_panel_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): async def handle_panel_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query query = update.callback_query
if not await is_admin(update, context):
await query.answer("只有管理员可以使用操作面板", show_alert=True)
return
await query.answer() await query.answer()
user = query.from_user user = query.from_user
action = query.data.split(":", 1)[1] action = query.data.split(":", 1)[1]
+8 -3
View File
@@ -7,7 +7,7 @@ from telegram import Update
from telegram.ext import ContextTypes, MessageHandler, filters from telegram.ext import ContextTypes, MessageHandler, filters
import config import config
from db.models import Message, Ban, Action from db.models import Message, Ban, Action, Whitelist
from services.spam_detector import detect from services.spam_detector import detect
from web.api import broadcast_sse, json_safe from web.api import broadcast_sse, json_safe
@@ -107,10 +107,15 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
except Exception as e: except Exception as e:
logger.error(f"❌ 删除消息失败: {e}") logger.error(f"❌ 删除消息失败: {e}")
# 管理员可用于测试规则:删消息不封禁,避免把群主/管理员踢掉 # 管理员/白名单:只删消息不封禁,避免误封可信用户
if user.id in config.ADMIN_USER_IDS: if user.id in config.ADMIN_USER_IDS:
await Action.create(action="SPAM_ADMIN_TEST", chat_id=update.effective_chat.id, user_id=user.id, username=username, details={"reason": reason}) await Action.create(action="SPAM_ADMIN_TEST", chat_id=update.effective_chat.id, user_id=user.id, username=username, details={"reason": reason})
logger.warning(f"🧪 管理员垃圾规则测试命中,不封: [{username}] {reason}") logger.warning(f"🧪 管理员垃圾规则测试命中,只删不封: [{username}] {reason}")
return
whitelist = await Whitelist.get_or_none(chat_id=update.effective_chat.id, user_id=user.id, enabled=True)
if whitelist:
await Action.create(action="SPAM_WHITELIST_DELETE_ONLY", chat_id=update.effective_chat.id, user_id=user.id, username=username, details={"reason": reason, "whitelist_id": whitelist.id})
logger.warning(f"🛡️ 白名单用户命中垃圾规则,只删不封: [{username}] {reason}")
return return
# 封禁 # 封禁
+3 -5
View File
@@ -130,14 +130,12 @@ async def start_tg_bot():
BotCommand("stats", "📊 今日统计"), BotCommand("stats", "📊 今日统计"),
BotCommand("checkin", "✅ 每日签到"), BotCommand("checkin", "✅ 每日签到"),
BotCommand("trivia", "🎮 知识答题"), BotCommand("trivia", "🎮 知识答题"),
BotCommand("lottery", "🎁 发起群内抽奖"),
BotCommand("shop", "🛒 积分商城"), BotCommand("shop", "🛒 积分商城"),
BotCommand("panel", "🎛️ 操作面板"),
BotCommand("bind", "🔗 绑定账号"), BotCommand("bind", "🔗 绑定账号"),
BotCommand("me", "👤 查看授权信息"), BotCommand("me", "👤 查看授权信息"),
BotCommand("dban", "🗑 删除并封禁"), BotCommand("dban", "🛡 管理:删封"),
BotCommand("dmute", "🔇 删除并禁言"), BotCommand("dmute", "🛡️ 管理:删禁言"),
BotCommand("dwarn", "除并警告"), BotCommand("dwarn", "🛡 管理:删警告"),
BotCommand("help", "📖 帮助"), BotCommand("help", "📖 帮助"),
] ]
await tg_app.bot.delete_my_commands() await tg_app.bot.delete_my_commands()
+40 -1
View File
@@ -21,7 +21,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from db.models import Action, Ban, GameScore, Message, LotteryEvent, LotteryParticipant, ShopItem, ShopTransaction from db.models import Action, Ban, GameScore, Message, LotteryEvent, LotteryParticipant, ShopItem, ShopTransaction, Whitelist
from config import WEB_AUTH_ENABLED, WEB_AUTH_PASSWORD, WEB_AUTH_USERNAME, WEB_SESSION_SECRET, WEB_TRUST_SAME_SUBNET from config import WEB_AUTH_ENABLED, WEB_AUTH_PASSWORD, WEB_AUTH_USERNAME, WEB_SESSION_SECRET, WEB_TRUST_SAME_SUBNET
from services.stats_service import get_daily_stats from services.stats_service import get_daily_stats
from services.time_utils import format_dt, from_timestamp, now_tz from services.time_utils import format_dt, from_timestamp, now_tz
@@ -924,6 +924,8 @@ async def api_ban_message_user(message_id: int, data: BanPayload | None = None):
return JSONResponse({"ok": False, "error": "消息不存在"}, status_code=404) return JSONResponse({"ok": False, "error": "消息不存在"}, status_code=404)
if not rec.chat_id or not rec.user_id: if not rec.chat_id or not rec.user_id:
return JSONResponse({"ok": False, "error": "缺少 chat_id 或 user_id,无法封禁"}, status_code=400) return JSONResponse({"ok": False, "error": "缺少 chat_id 或 user_id,无法封禁"}, status_code=400)
if await Whitelist.get_or_none(chat_id=rec.chat_id, user_id=rec.user_id, enabled=True):
return JSONResponse({"ok": False, "error": "该用户在白名单中,不允许封禁;如需处理请先移出白名单"}, status_code=409)
opts = build_ban_options((data.duration_minutes if data else 0)) opts = build_ban_options((data.duration_minutes if data else 0))
try: try:
if opts["until_date"]: if opts["until_date"]:
@@ -960,6 +962,39 @@ async def api_ban_message_user(message_id: int, data: BanPayload | None = None):
return JSONResponse({"ok": False, "error": f"封禁失败: {type(e).__name__}: {e}"}, status_code=502) return JSONResponse({"ok": False, "error": f"封禁失败: {type(e).__name__}: {e}"}, status_code=502)
@app.post("/api/messages/{message_id}/whitelist")
async def api_add_message_user_to_whitelist(message_id: int, request: Request):
"""把聊天记录对应用户加入白名单。白名单用户自动检测只删消息不封号。"""
rec = await Message.get_or_none(id=message_id)
if not rec:
return JSONResponse({"ok": False, "error": "消息不存在"}, status_code=404)
if not rec.chat_id or not rec.user_id:
return JSONResponse({"ok": False, "error": "缺少 chat_id 或 user_id,无法加入白名单"}, status_code=400)
data = await safe_json(request)
reason = str(data.get("reason") or "Web 右键加入白名单")[:500]
item = await Whitelist.get_or_none(chat_id=rec.chat_id, user_id=rec.user_id)
if item:
item.enabled = True
item.username = rec.username
item.first_name = rec.first_name
item.reason = reason
item.updated_at = now_tz()
await item.save()
else:
item = await Whitelist.create(chat_id=rec.chat_id, user_id=rec.user_id, username=rec.username, first_name=rec.first_name, reason=reason)
await Action.create(action="WHITELIST_ADD", chat_id=rec.chat_id, user_id=rec.user_id, username=rec.username, details={"message_id": rec.id, "reason": reason})
return JSONResponse(json_safe({"ok": True, "id": item.id, "chat_id": item.chat_id, "user_id": item.user_id, "username": item.username, "first_name": item.first_name, "reason": item.reason, "enabled": item.enabled}))
@app.get("/api/whitelist")
async def api_list_whitelist(chat_id: int | None = None, limit: int = Query(default=200, ge=1, le=1000)):
qs = Whitelist.filter(enabled=True)
if chat_id is not None:
qs = qs.filter(chat_id=chat_id)
rows = await qs.order_by("-updated_at").limit(limit).values()
return JSONResponse(json_safe(list(rows)))
@app.post("/api/chat/{message_id}/delete") @app.post("/api/chat/{message_id}/delete")
@app.post("/api/messages/{message_id}/delete") @app.post("/api/messages/{message_id}/delete")
async def api_delete_message(message_id: int, request: Request): async def api_delete_message(message_id: int, request: Request):
@@ -998,6 +1033,10 @@ async def api_delete_message(message_id: int, request: Request):
await rec.save() await rec.save()
ban = None ban = None
if ban_user and rec.user_id:
if await Whitelist.get_or_none(chat_id=rec.chat_id, user_id=rec.user_id, enabled=True):
await Action.create(action="DELETE_BAN_SKIPPED_WHITELIST", chat_id=rec.chat_id, user_id=rec.user_id, username=rec.username, details={"message_id": rec.id})
ban_user = False
if ban_user and rec.user_id: if ban_user and rec.user_id:
try: try:
if ban_opts["until_date"]: if ban_opts["until_date"]:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/static/icon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/icon.svg" />
<title>TG Spam Guard</title> <title>TG Spam Guard</title>
<script type="module" crossorigin src="/static/assets/index-CkIgY8f5.js"></script> <script type="module" crossorigin src="/static/assets/index-X2_fZIhe.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-B9Kyvn7u.css"> <link rel="stylesheet" crossorigin href="/static/assets/index-B9Kyvn7u.css">
</head> </head>
<body> <body>