feat: add whitelist moderation flow
This commit is contained in:
@@ -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 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;
|
||||
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 (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
@@ -62,6 +62,23 @@ class Action(TimestampMixin, Model):
|
||||
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):
|
||||
"""入群验证队列"""
|
||||
id = fields.BigIntField(pk=True, generated=True)
|
||||
|
||||
+19
-1
@@ -556,7 +556,7 @@ function openChatContextMenu(event: MouseEvent, item: ChatItem) {
|
||||
chatContextMenu.item = item;
|
||||
chatContextMenu.open = true;
|
||||
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.y = Math.min(event.clientY, window.innerHeight - menuHeight - 8);
|
||||
}
|
||||
@@ -569,6 +569,23 @@ async function contextAddToRules() {
|
||||
closeChatContextMenu();
|
||||
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) {
|
||||
const item = chatContextMenu.item;
|
||||
closeChatContextMenu();
|
||||
@@ -885,6 +902,7 @@ onBeforeUnmount(() => {
|
||||
<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="contextBanMessage">🚫 封印用户</button>
|
||||
<button :disabled="!chatContextMenu.item?.user_id" @click="contextAddWhitelist">🛡️ 加入白名单</button>
|
||||
<button @click="contextAddToRules">📥 写入拦截库</button>
|
||||
<button @click="contextCopyText">📋 复制内容</button>
|
||||
<button @click="contextReplyMessage">↩️ 回复</button>
|
||||
|
||||
+6
-7
@@ -219,7 +219,7 @@ async def cmd_mute(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
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:
|
||||
return True
|
||||
@@ -233,7 +233,7 @@ async def cmd_dban(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""删除被引用消息并封禁用户。"""
|
||||
if not await require_admin(update, context):
|
||||
return
|
||||
if not await require_reply_shortcut(update, "dban", "/dban"):
|
||||
if not await require_reply_shortcut(update, context, "/dban"):
|
||||
return
|
||||
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):
|
||||
return
|
||||
if not await require_reply_shortcut(update, "dmute", "/dmute [分钟]"):
|
||||
if not await require_reply_shortcut(update, context, "/dmute [分钟]"):
|
||||
return
|
||||
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):
|
||||
return
|
||||
if not await require_reply_shortcut(update, "dwarn", "/dwarn [原因]"):
|
||||
if not await require_reply_shortcut(update, context, "/dwarn [原因]"):
|
||||
return
|
||||
await cmd_warn(update, context)
|
||||
|
||||
@@ -306,11 +306,10 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"✅ /checkin - 每日签到\n"
|
||||
"🎮 /trivia - 知识答题\n"
|
||||
"🏆 /rank - 查看积分排行\n"
|
||||
"🎁 /lottery - 发起群内抽奖,进入私聊设置奖品和条件\n"
|
||||
" 私聊格式:奖品 | 参与条件 | 结束条件\n"
|
||||
" 示例:月卡一张 | all | people:3\n"
|
||||
"🔗 /bind - 绑定账号\n\n"
|
||||
"管理员操作:\n"
|
||||
"🎁 /lottery - 发起群内抽奖,进入私聊设置奖品和条件\n"
|
||||
"🎛️ /panel - 打开管理操作面板\n"
|
||||
"🌙 /night on/off - 开启/关闭夜间模式\n"
|
||||
"🔨 /ban 用户ID/@历史用户名 - 封禁用户\n"
|
||||
"🔇 /mute 用户ID/@历史用户名 [分钟] - 禁言用户,默认 10 分钟\n"
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
import config
|
||||
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.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):
|
||||
if not await require_admin(update, context):
|
||||
return
|
||||
reply = await update.message.reply_text(
|
||||
"🎛️ 操作面板\n请选择要执行的操作:\n\n⏱️ 60 秒无操作后自动消失。",
|
||||
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):
|
||||
query = update.callback_query
|
||||
if not await is_admin(update, context):
|
||||
await query.answer("只有管理员可以使用操作面板", show_alert=True)
|
||||
return
|
||||
await query.answer()
|
||||
user = query.from_user
|
||||
action = query.data.split(":", 1)[1]
|
||||
|
||||
+8
-3
@@ -7,7 +7,7 @@ from telegram import Update
|
||||
from telegram.ext import ContextTypes, MessageHandler, filters
|
||||
|
||||
import config
|
||||
from db.models import Message, Ban, Action
|
||||
from db.models import Message, Ban, Action, Whitelist
|
||||
from services.spam_detector import detect
|
||||
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:
|
||||
logger.error(f"❌ 删除消息失败: {e}")
|
||||
|
||||
# 管理员可用于测试规则:删消息但不封禁,避免把群主/管理员踢掉
|
||||
# 管理员/白名单:只删消息,不封禁,避免误封可信用户
|
||||
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})
|
||||
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
|
||||
|
||||
# 封禁
|
||||
|
||||
@@ -130,14 +130,12 @@ async def start_tg_bot():
|
||||
BotCommand("stats", "📊 今日统计"),
|
||||
BotCommand("checkin", "✅ 每日签到"),
|
||||
BotCommand("trivia", "🎮 知识答题"),
|
||||
BotCommand("lottery", "🎁 发起群内抽奖"),
|
||||
BotCommand("shop", "🛒 积分商城"),
|
||||
BotCommand("panel", "🎛️ 操作面板"),
|
||||
BotCommand("bind", "🔗 绑定账号"),
|
||||
BotCommand("me", "👤 查看授权信息"),
|
||||
BotCommand("dban", "🗑️ 删除并封禁"),
|
||||
BotCommand("dmute", "🔇 删除并禁言"),
|
||||
BotCommand("dwarn", "⚠️ 删除并警告"),
|
||||
BotCommand("dban", "🛡️ 管理:删封"),
|
||||
BotCommand("dmute", "🛡️ 管理:删禁言"),
|
||||
BotCommand("dwarn", "🛡️ 管理:删警告"),
|
||||
BotCommand("help", "📖 帮助"),
|
||||
]
|
||||
await tg_app.bot.delete_my_commands()
|
||||
|
||||
+40
-1
@@ -21,7 +21,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
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 services.stats_service import get_daily_stats
|
||||
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)
|
||||
if not rec.chat_id or not rec.user_id:
|
||||
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))
|
||||
try:
|
||||
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)
|
||||
|
||||
|
||||
@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/messages/{message_id}/delete")
|
||||
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()
|
||||
|
||||
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:
|
||||
try:
|
||||
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
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icon.svg" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user