fix: strengthen chinese spam rules and add web logs
This commit is contained in:
@@ -30,6 +30,7 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
username = user.username or ""
|
||||
first_name = user.first_name or ""
|
||||
text = msg.text
|
||||
logger.info(f"💬 聊天消息 [{username or first_name or user.id}]: {text[:120]}")
|
||||
|
||||
# === 防刷屏 ===
|
||||
now = datetime.now().timestamp()
|
||||
@@ -96,6 +97,12 @@ 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", user_id=user.id, username=username, details={"reason": reason})
|
||||
logger.warning(f"🧪 管理员垃圾规则测试命中,不封禁: [{username}] {reason}")
|
||||
return
|
||||
|
||||
# 封禁
|
||||
try:
|
||||
unban_at = datetime.now() + timedelta(minutes=config.BAN_DURATION_MINUTES)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# 代理(必须在其他 import 之前)
|
||||
PROXY_URL = os.getenv("PROXY_URL", "http://192.168.123.10:11055")
|
||||
@@ -92,8 +93,8 @@ async def start_tg_bot_safe():
|
||||
raise
|
||||
|
||||
|
||||
@web_app.on_event("startup")
|
||||
async def on_startup():
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# 初始化数据库
|
||||
await init_db()
|
||||
# 启动 TG Bot
|
||||
@@ -108,13 +109,15 @@ async def on_startup():
|
||||
|
||||
task.add_done_callback(log_task_result)
|
||||
logger.info("🚀 服务启动完成")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_tg_bot()
|
||||
await close_db()
|
||||
logger.info("🛑 服务已关闭")
|
||||
|
||||
|
||||
@web_app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
await stop_tg_bot()
|
||||
await close_db()
|
||||
logger.info("🛑 服务已关闭")
|
||||
web_app.router.lifespan_context = lifespan
|
||||
|
||||
|
||||
def run():
|
||||
|
||||
+145
-71
@@ -1,95 +1,162 @@
|
||||
"""多层垃圾检测引擎 — 缓存 → 硬性正则 → 正常特征 → AI 兜底"""
|
||||
import re
|
||||
"""中文垃圾广告检测引擎
|
||||
|
||||
策略:
|
||||
1. 先做本地规则评分(成熟中文广告/黑灰产话术体系)
|
||||
2. 高置信垃圾直接拦截
|
||||
3. 明确正常消息直接放行
|
||||
4. 低置信/模糊样本交给 AI 兜底
|
||||
|
||||
核心原则:短消息不能无条件放行;必须先识别广告意图。
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
import config
|
||||
from knowledge import OLLAMA_URL, OLLAMA_MODEL
|
||||
|
||||
logger = logging.getLogger("spam_guard")
|
||||
|
||||
# ============ 硬性垃圾正则 ============
|
||||
|
||||
HARD_SPAM_PATTERNS = [
|
||||
r"菠菜|赌博|博彩|赌场|棋牌|彩票|开奖|中奖|赔率|下注",
|
||||
r"色情|约炮|外围|楼凤|一夜情|裸聊|成人视频",
|
||||
r"刷单|传销|杀猪盘|资金盘|庞氏|传销盘",
|
||||
r"(做USDT|U ?S ?D ?T|搬砖).{0,15}(看主页|看签名|进群|加我|找我)",
|
||||
r"(看主页|看签名|加我|找我|进群).{0,15}(做USDT|U ?S ?D ?T|搬砖|赚钱|日入|月入)",
|
||||
r"(一天|日入|月入|赚).{0,10}(万|w|W).{0,8}(项目|路子|渠道|带|做)",
|
||||
r"(换|开|提).{0,6}(迈巴赫|宝马|奔驰|保时捷|法拉利|兰博基尼)",
|
||||
r"(小白可带|手把手带|带你赚|带你做)",
|
||||
r"(t\.me|telegram\.me).{0,30}(join|group|channel)",
|
||||
@dataclass(frozen=True)
|
||||
class Rule:
|
||||
name: str
|
||||
pattern: str
|
||||
score: int
|
||||
|
||||
|
||||
# 高危黑产:单项命中即可高度可疑或直接垃圾
|
||||
HARD_RULES = [
|
||||
Rule("博彩赌博", r"菠菜|赌博|博彩|赌场|棋牌|彩票|开奖|中奖|赔率|下注|百家乐|时时彩|六合彩", 100),
|
||||
Rule("色情引流", r"色情|约炮|外围|楼凤|一夜情|裸聊|成人视频|萝莉|上门服务", 100),
|
||||
Rule("诈骗/传销/资金盘", r"刷单|传销|杀猪盘|资金盘|庞氏|返利盘|跑分|洗钱|代收款", 100),
|
||||
Rule("非法交易", r"办证|假证|代开发票|买卖账号|出售账号|卖号|接码|猫池", 100),
|
||||
Rule("Telegram 引流", r"(?:t\.me|telegram\.me|tg://|飞机群).{0,40}(?:join|group|channel|\+|进群|频道)", 90),
|
||||
]
|
||||
|
||||
# 软性信号(需 AI 二次判定)
|
||||
SOFT_SPAM_SIGNALS = [
|
||||
r"(免费领|免费送|限时领|点击领取|点击链接|复制链接)",
|
||||
r"(加微信|wx|加我|私聊|私我|dm|PM)",
|
||||
r"(赚钱|赚快钱|躺赚|副业|兼职日结)",
|
||||
r"(开户|入金|出金|充值返|提现)",
|
||||
r"(代理|加盟|推广返|分润|佣金)",
|
||||
# 广告意图/黑灰产常见话术:评分组合
|
||||
SCORE_RULES = [
|
||||
# 收益承诺
|
||||
Rule("收益承诺-日赚", r"(?:日赚|日入|每天赚|一天赚|轻松赚|稳赚|躺赚|副业收入).{0,10}(?:\d{2,}|几百|上千|过千|一千|几千|上万|万元?|k|K|w|W)", 75),
|
||||
Rule("收益承诺-月入", r"(?:月入|月赚|月收益|月收入).{0,10}(?:\d{3,}|过万|上万|几万|万元?|w|W)", 70),
|
||||
Rule("收益承诺-夸张", r"(?:暴富|翻倍|稳赚不赔|保本收益|无风险收益|零风险|高回报|高收益)", 55),
|
||||
|
||||
# 兼职/刷单/项目
|
||||
Rule("兼职刷单", r"(?:兼职|副业|日结|周结|在家做|手机操作|无需经验|宝妈|学生党).{0,18}(?:赚钱|收入|结算|工资|项目|单)", 55),
|
||||
Rule("项目引流", r"(?:项目|路子|渠道|资源|教程).{0,12}(?:带你|私聊|加我|看主页|看签名|进群)", 55),
|
||||
Rule("带人赚钱", r"(?:带你赚|带你做|手把手带|小白可带|包教包会|一对一带)", 65),
|
||||
|
||||
# 联系/引流动作
|
||||
Rule("联系方式-微信", r"(?:加微信|微信号|微[信xX]|vx|v信|薇信|扣扣|QQ|q群|私信|私聊|PM|DM|联系我|找我)", 45),
|
||||
Rule("引导动作", r"(?:看主页|看签名|主页有|签名有|点我头像|点头像|进群|拉群|频道|群内|开户链接)", 45),
|
||||
Rule("链接引流", r"(?:https?://|www\.|\.com|\.cn|\.top|\.xyz|\.cc|短链接|复制链接|点击链接)", 40),
|
||||
|
||||
# 金融/币圈/贷款
|
||||
Rule("虚拟币搬砖", r"(?:USDT|U\s?S\s?D\s?T|泰达币|虚拟币|币圈|搬砖|套利).{0,18}(?:赚钱|收益|项目|加我|看主页|进群)", 65),
|
||||
Rule("贷款套现", r"(?:贷款|借款|套现|信用卡|花呗|白条|提额|下款|黑户|征信).{0,18}(?:秒批|当天|无视|包过|渠道)", 60),
|
||||
Rule("投资理财", r"(?:投资|理财|股票|期货|外汇|量化|跟单).{0,18}(?:老师|带单|收益|稳赚|回本|翻倍)", 60),
|
||||
|
||||
# 营销常见词
|
||||
Rule("免费福利", r"(?:免费领|免费送|限时领|点击领取|扫码领取|福利|红包|返现|佣金|返佣)", 40),
|
||||
Rule("代理加盟", r"(?:代理|加盟|招商|推广返|分润|招募|招代理|合作共赢)", 45),
|
||||
Rule("豪车炫富", r"(?:换|开|提).{0,6}(?:迈巴赫|宝马|奔驰|保时捷|法拉利|兰博基尼)", 55),
|
||||
]
|
||||
|
||||
# 正常消息特征
|
||||
NORMAL_SIGNALS = [
|
||||
r"^.{0,8}$",
|
||||
r"[??]",
|
||||
r"(怎么|如何|为什么|能不能|请问|求|帮忙|谢谢|好的|收到|OK|ok|嗯|是的|不是|可以|不行|对|错|嗯嗯)",
|
||||
r"(收割机|harvest|签到|站点|cookie|docker|安装|配置|更新|升级|bug|报错|失败|成功)",
|
||||
r"^\d+$",
|
||||
r"^[a-zA-Z0-9\s]{1,20}$",
|
||||
# 组合加权:成熟广告识别关键在“组合意图”,不是单词命中
|
||||
COMBO_RULES = [
|
||||
Rule("收益+金额", r"(?:赚|收入|收益|工资|佣金|返现|日结|副业).{0,12}(?:\d{3,}|几百|上千|过千|几千|上万|万元?|k|K|w|W)", 55),
|
||||
Rule("收益+引流", r"(?:赚钱|收益|收入|副业|项目|路子).{0,20}(?:加我|私聊|看主页|看签名|进群|联系我|微信|vx|QQ)", 60),
|
||||
Rule("金额+引流", r"(?:\d{3,}|几百|上千|过千|几千|上万|万元?|k|K|w|W).{0,20}(?:加我|私聊|看主页|看签名|进群|联系我|微信|vx|QQ)", 55),
|
||||
]
|
||||
|
||||
SPAM_PROMPT = """判断以下消息是否为垃圾广告。同时检查消息内容和发送者信息。
|
||||
NORMAL_RULES = [
|
||||
Rule("项目技术讨论", r"(?:收割机|harvest|签到|站点|cookie|docker|compose|安装|配置|更新|升级|bug|报错|失败|成功|数据库|postgres|sqlite|redis)", 30),
|
||||
Rule("疑问求助", r"(?:怎么|如何|为什么|能不能|请问|求|帮忙|麻烦|谢谢|这个|那个).{0,30}[??]?$", 25),
|
||||
Rule("常见短回复", r"^(?:ok|OK|yes|YES|no|NO|收到|好的|可以|不行|对|错|嗯|嗯嗯|是的|不是|谢谢|感谢)$", 60),
|
||||
Rule("纯数字", r"^\d{1,8}$", 30),
|
||||
]
|
||||
|
||||
SPAM_PROMPT = """判断以下群聊消息是否为中文垃圾广告/黑灰产推广。
|
||||
|
||||
消息内容: {text}
|
||||
发送者用户名: {username}
|
||||
发送者昵称: {first_name}
|
||||
|
||||
判断标准(满足任一即为垃圾):
|
||||
1. 包含色情、赌博、诈骗、菠菜等违法推广内容
|
||||
2. 包含明显广告推销(加群、加微信、免费领、点击链接等)
|
||||
3. 纯转发的营销内容
|
||||
垃圾广告包括:赚钱项目、刷单兼职、日赚/月入承诺、加微信/私聊引流、博彩色情、贷款套现、虚拟币搬砖、投资带单、免费福利诱导等。
|
||||
正常消息包括:技术讨论、问答、简短确认、正常聊天。
|
||||
|
||||
请先给出结论,包含"垃圾"或"正常",再简短说明原因。"""
|
||||
只输出一行:垃圾 或 正常,并简短说明原因。"""
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""去除零宽字符、规范空白"""
|
||||
t = re.sub(r'[\u200b\u200c\u200d\u2060\u200e\u200f\ufeff\u00a0]', '', text)
|
||||
t = re.sub(r'\s+', ' ', t).strip()
|
||||
"""去零宽字符、统一空白和常见混淆字符。"""
|
||||
t = re.sub(r"[\u200b\u200c\u200d\u2060\u200e\u200f\ufeff\u00a0]", "", text or "")
|
||||
t = re.sub(r"\s+", " ", t).strip()
|
||||
return t
|
||||
|
||||
|
||||
def match_patterns(text: str, patterns: list[str]) -> bool:
|
||||
for pat in patterns:
|
||||
if re.search(pat, text, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
def compact(text: str) -> str:
|
||||
"""去掉空格,处理 日赚 1000 / 日赚1000 这类绕过。"""
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
|
||||
async def detect(text: str, username: str = "", first_name: str = "") -> tuple[bool, str]:
|
||||
"""
|
||||
多层垃圾检测。
|
||||
返回 (is_spam, reason)
|
||||
"""
|
||||
def hit_rules(text: str, rules: list[Rule]) -> list[Rule]:
|
||||
return [r for r in rules if re.search(r.pattern, text, re.IGNORECASE)]
|
||||
|
||||
|
||||
def local_classify(text: str) -> tuple[bool | None, str, int]:
|
||||
"""本地规则分类。返回 (是否垃圾/None需AI, 原因, 分数)。"""
|
||||
norm = normalize(text)
|
||||
tight = compact(norm)
|
||||
targets = [norm, tight]
|
||||
|
||||
# 第一层:硬性正则
|
||||
if match_patterns(norm, HARD_SPAM_PATTERNS):
|
||||
return True, "规则命中(硬性垃圾)"
|
||||
hard_hits = []
|
||||
score_hits = []
|
||||
combo_hits = []
|
||||
normal_hits = []
|
||||
|
||||
# 短消息放行
|
||||
if len(norm) < 20 and not match_patterns(norm, SOFT_SPAM_SIGNALS):
|
||||
return False, "短消息跳过"
|
||||
for target in targets:
|
||||
hard_hits.extend(hit_rules(target, HARD_RULES))
|
||||
score_hits.extend(hit_rules(target, SCORE_RULES))
|
||||
combo_hits.extend(hit_rules(target, COMBO_RULES))
|
||||
normal_hits.extend(hit_rules(target, NORMAL_RULES))
|
||||
|
||||
# 正常特征
|
||||
if match_patterns(norm, NORMAL_SIGNALS) and not match_patterns(norm, SOFT_SPAM_SIGNALS):
|
||||
return False, "正常消息特征匹配"
|
||||
# 去重
|
||||
dedup = lambda xs: list({x.name: x for x in xs}.values())
|
||||
hard_hits, score_hits, combo_hits, normal_hits = map(dedup, [hard_hits, score_hits, combo_hits, normal_hits])
|
||||
|
||||
# AI 兜底
|
||||
if hard_hits:
|
||||
names = ",".join(r.name for r in hard_hits[:3])
|
||||
return True, f"硬规则命中: {names}", 100
|
||||
|
||||
spam_score = sum(r.score for r in score_hits) + sum(r.score for r in combo_hits)
|
||||
normal_score = sum(r.score for r in normal_hits)
|
||||
|
||||
# 广告组合是核心:即使很短也要先判垃圾
|
||||
if spam_score >= 75:
|
||||
names = ",".join(r.name for r in (score_hits + combo_hits)[:4])
|
||||
return True, f"广告规则评分 {spam_score}: {names}", spam_score
|
||||
|
||||
# 纯短消息只有在没有广告分时才放行
|
||||
if len(norm) <= 8 and spam_score == 0:
|
||||
return False, "短正常消息", -normal_score
|
||||
|
||||
# 明显正常技术/求助消息,且无明显广告分
|
||||
if normal_score >= 30 and spam_score < 45:
|
||||
names = ",".join(r.name for r in normal_hits[:3])
|
||||
return False, f"正常规则命中: {names}", spam_score - normal_score
|
||||
|
||||
# 低风险且没有广告信号
|
||||
if spam_score == 0:
|
||||
return False, "无广告信号", 0
|
||||
|
||||
# 模糊样本交给 AI
|
||||
names = ",".join(r.name for r in (score_hits + combo_hits)[:4])
|
||||
return None, f"疑似广告评分 {spam_score}: {names}", spam_score
|
||||
|
||||
|
||||
async def ai_classify(text: str, username: str = "", first_name: str = "") -> tuple[bool, str]:
|
||||
prompt = SPAM_PROMPT.format(text=text, username=username, first_name=first_name)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, proxy=None) as client:
|
||||
@@ -99,25 +166,32 @@ async def detect(text: str, username: str = "", first_name: str = "") -> tuple[b
|
||||
"model": OLLAMA_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": False,
|
||||
"options": {"num_predict": 50, "temperature": 0.1},
|
||||
"options": {"num_predict": 80, "temperature": 0.1},
|
||||
"think": False,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
reply = resp.json()["message"]["content"]
|
||||
logger.info(f"🤖 AI回复: {reply[:100]}")
|
||||
reply = resp.json()["message"]["content"].strip()
|
||||
logger.info(f"🤖 垃圾检测 AI 回复: {reply[:100]}")
|
||||
|
||||
reply_upper = reply.upper()
|
||||
spam_words = ["垃圾", "广告", "诈骗", "菠菜", "赌博", "色情", "YES"]
|
||||
normal_words = ["正常", "非垃圾", "不是垃圾", "NO"]
|
||||
if any(w in reply for w in normal_words) or "NO" in reply_upper:
|
||||
return False, f"AI判定: {reply}"
|
||||
if any(w in reply for w in spam_words) or "YES" in reply_upper:
|
||||
if re.search(r"垃圾|广告|诈骗|菠菜|赌博|色情", reply, re.IGNORECASE):
|
||||
return True, f"AI判定: {reply}"
|
||||
if re.search(r"正常|非垃圾|不是垃圾", reply, re.IGNORECASE):
|
||||
return False, f"AI判定: {reply}"
|
||||
return False, f"AI判定不明确: {reply}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ollama 调用失败: {e}")
|
||||
if match_patterns(norm, SOFT_SPAM_SIGNALS):
|
||||
return True, "关键词兜底(AI不可用)"
|
||||
return False, f"检测异常: {e}"
|
||||
return False, f"AI异常: {e}"
|
||||
|
||||
|
||||
async def detect(text: str, username: str = "", first_name: str = "") -> tuple[bool, str]:
|
||||
"""返回 (is_spam, reason)。"""
|
||||
decision, reason, score = local_classify(text)
|
||||
if decision is not None:
|
||||
return decision, reason
|
||||
|
||||
# AI 不可用时,中等以上广告分直接兜底拦截;低分放行
|
||||
ai_decision, ai_reason = await ai_classify(text, username, first_name)
|
||||
if ai_reason.startswith("AI异常") and score >= 45:
|
||||
return True, f"规则兜底(AI不可用): {reason}"
|
||||
return ai_decision, f"{reason}; {ai_reason}"
|
||||
|
||||
+82
-1
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -21,6 +22,42 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat
|
||||
|
||||
# SSE
|
||||
_sse_queues: list[asyncio.Queue] = []
|
||||
_log_buffer: list[dict] = []
|
||||
MAX_LOG_BUFFER = 300
|
||||
|
||||
|
||||
class SSELogHandler(logging.Handler):
|
||||
"""把应用日志推送到 Web 实时日志窗口。"""
|
||||
|
||||
def emit(self, record: logging.LogRecord):
|
||||
try:
|
||||
item = {
|
||||
"time": datetime.fromtimestamp(record.created).isoformat(sep=" ", timespec="seconds"),
|
||||
"level": record.levelname,
|
||||
"message": self.format(record),
|
||||
}
|
||||
_log_buffer.append(item)
|
||||
del _log_buffer[:-MAX_LOG_BUFFER]
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(broadcast_sse("log", item))
|
||||
except RuntimeError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def install_log_handler():
|
||||
logger = logging.getLogger("spam_guard")
|
||||
if any(isinstance(h, SSELogHandler) for h in logger.handlers):
|
||||
return
|
||||
handler = SSELogHandler()
|
||||
handler.setLevel(logging.INFO)
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S"))
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
install_log_handler()
|
||||
|
||||
|
||||
def json_safe(value: Any) -> Any:
|
||||
@@ -100,6 +137,49 @@ async def api_actions(limit: int = Query(default=100, ge=1, le=500)):
|
||||
return JSONResponse(json_safe(list(actions)))
|
||||
|
||||
|
||||
@app.get("/api/logs")
|
||||
async def api_logs(limit: int = Query(default=100, ge=1, le=300)):
|
||||
return JSONResponse(_log_buffer[-limit:])
|
||||
|
||||
|
||||
@app.post("/api/send")
|
||||
async def api_send(request: Request):
|
||||
import config
|
||||
from telegram import Bot
|
||||
from telegram.request import HTTPXRequest
|
||||
|
||||
data = await request.json()
|
||||
text = str(data.get("text", "")).strip()
|
||||
if not text:
|
||||
return JSONResponse({"ok": False, "error": "消息不能为空"}, status_code=400)
|
||||
if len(text) > 3500:
|
||||
return JSONResponse({"ok": False, "error": "消息太长"}, status_code=400)
|
||||
|
||||
bot = Bot(token=config.TG_BOT_TOKEN, request=HTTPXRequest(proxy=config.PROXY_URL))
|
||||
msg = await bot.send_message(chat_id=config.TG_CHAT_ID, text=text)
|
||||
rec = await Message.create(
|
||||
msg_id=msg.message_id,
|
||||
chat_id=config.TG_CHAT_ID,
|
||||
user_id=0,
|
||||
username="web-admin",
|
||||
first_name="Web 管理端",
|
||||
text=text[:500],
|
||||
)
|
||||
logging.getLogger("spam_guard").info(f"🌐 Web 发送消息: {text[:120]}")
|
||||
await broadcast_sse("chat", {
|
||||
"id": rec.id,
|
||||
"msg_id": msg.message_id,
|
||||
"chat_id": config.TG_CHAT_ID,
|
||||
"user_id": 0,
|
||||
"username": "web-admin",
|
||||
"first_name": "Web 管理端",
|
||||
"text": text,
|
||||
"time": datetime.now().isoformat(sep=" ", timespec="seconds"),
|
||||
"spam": False,
|
||||
})
|
||||
return JSONResponse({"ok": True, "msg_id": msg.message_id})
|
||||
|
||||
|
||||
@app.get("/api/scores")
|
||||
async def api_scores(limit: int = Query(default=50, ge=1, le=200)):
|
||||
scores = await GameScore.all().order_by("-score").limit(limit).values()
|
||||
@@ -111,8 +191,9 @@ async def api_unban(user_id: int):
|
||||
import config
|
||||
try:
|
||||
from telegram import Bot
|
||||
from telegram.request import HTTPXRequest
|
||||
|
||||
bot = Bot(token=config.TG_BOT_TOKEN)
|
||||
bot = Bot(token=config.TG_BOT_TOKEN, request=HTTPXRequest(proxy=config.PROXY_URL))
|
||||
await bot.unban_chat_member(chat_id=config.TG_CHAT_ID, user_id=user_id)
|
||||
await Ban.filter(user_id=user_id).update(auto_unban=False, unban_at=datetime.now())
|
||||
await Action.create(action="UNBAN", user_id=user_id, details={"method": "web"})
|
||||
|
||||
+79
-3
@@ -27,7 +27,15 @@
|
||||
.stat-card.danger .num { color: #f56c6c; }
|
||||
.stat-card.success .num { color: #67c23a; }
|
||||
.stat-card.warning .num { color: #e6a23c; }
|
||||
.chat-panel, .ban-panel { height: 520px; }
|
||||
.chat-panel, .ban-panel { height: 560px; }
|
||||
.log-panel { margin-top: 20px; height: 360px; }
|
||||
.send-box { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.log-window { background: #111827; color: #d1d5db; border-radius: 8px; padding: 12px; height: 280px; overflow-y: auto; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
|
||||
.log-line { white-space: pre-wrap; line-height: 1.6; }
|
||||
.log-line.INFO { color: #d1d5db; }
|
||||
.log-line.WARNING { color: #fbbf24; }
|
||||
.log-line.ERROR { color: #f87171; }
|
||||
.chat-item.spam { background: #fff1f0; border-left: 3px solid #f56c6c; padding-left: 8px; }
|
||||
.chat-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
@@ -116,16 +124,21 @@
|
||||
<h3>💬 实时聊天</h3>
|
||||
<el-tag size="small" type="info">{{ chatLogs.length }} 条</el-tag>
|
||||
</div>
|
||||
<div style="overflow-y:auto; height:440px;" ref="chatScroll">
|
||||
<div class="send-box">
|
||||
<el-input v-model="newMessage" placeholder="输入要发送到 TG 群的消息" size="small" @keyup.enter.native="sendMessage"></el-input>
|
||||
<el-button type="primary" size="small" :loading="sending" @click="sendMessage">发送</el-button>
|
||||
</div>
|
||||
<div style="overflow-y:auto; height:430px;" ref="chatScroll">
|
||||
<div v-if="chatLogs.length === 0" class="empty-text">
|
||||
<span class="icon">💬</span>等待消息...
|
||||
</div>
|
||||
<div v-for="(item, i) in chatLogs" :key="i" class="chat-item">
|
||||
<div v-for="(item, i) in chatLogs" :key="i" class="chat-item" :class="{spam: item.spam}">
|
||||
<div class="meta">
|
||||
<span class="user">@{{ item.username }} ({{ item.user_id }})</span>
|
||||
<span class="time">{{ formatTime(item.time) }}</span>
|
||||
</div>
|
||||
<div class="text">{{ item.text }}</div>
|
||||
<el-tag v-if="item.spam" size="mini" type="danger">垃圾:{{ item.reason || item.spam_reason }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -161,6 +174,19 @@
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="hover" class="log-panel">
|
||||
<div class="panel-header">
|
||||
<h3>📜 实时日志</h3>
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="loadLogs">刷新</el-button>
|
||||
</div>
|
||||
<div class="log-window" ref="logScroll">
|
||||
<div v-if="logs.length === 0" class="log-line">等待日志...</div>
|
||||
<div v-for="(item, i) in logs" :key="i" class="log-line" :class="item.level">
|
||||
[{{ formatTime(item.time) }}] [{{ item.level }}] {{ item.message }}
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/vue@2.7.16/dist/vue.min.js"></script>
|
||||
@@ -173,6 +199,9 @@ new Vue({
|
||||
sseConnected: false,
|
||||
chatLogs: [],
|
||||
bans: [],
|
||||
logs: [],
|
||||
newMessage: '',
|
||||
sending: false,
|
||||
stats: { today_bans: 0, total_bans: 0, active_bans: 0 },
|
||||
}
|
||||
},
|
||||
@@ -180,6 +209,7 @@ new Vue({
|
||||
this.loadChatHistory()
|
||||
this.loadBans()
|
||||
this.loadStats()
|
||||
this.loadLogs()
|
||||
this.connectSSE()
|
||||
setInterval(() => this.loadStats(), 30000)
|
||||
},
|
||||
@@ -200,6 +230,21 @@ new Vue({
|
||||
if (this.chatLogs.length > 100) this.chatLogs.pop()
|
||||
})
|
||||
|
||||
es.addEventListener('spam', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
const item = this.chatLogs.find(x => x.msg_id === data.msg_id)
|
||||
if (item) Object.assign(item, data)
|
||||
})
|
||||
|
||||
es.addEventListener('log', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
this.logs.push(data)
|
||||
if (this.logs.length > 300) this.logs.shift()
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.logScroll) this.$refs.logScroll.scrollTop = this.$refs.logScroll.scrollHeight
|
||||
})
|
||||
})
|
||||
|
||||
es.addEventListener('ban', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
data._loading = false
|
||||
@@ -245,6 +290,37 @@ new Vue({
|
||||
this.stats = await r.json()
|
||||
} catch (e) {}
|
||||
},
|
||||
async loadLogs() {
|
||||
try {
|
||||
const r = await fetch('/api/logs')
|
||||
this.logs = await r.json()
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.logScroll) this.$refs.logScroll.scrollTop = this.$refs.logScroll.scrollHeight
|
||||
})
|
||||
} catch (e) {}
|
||||
},
|
||||
async sendMessage() {
|
||||
const text = this.newMessage.trim()
|
||||
if (!text) return
|
||||
this.sending = true
|
||||
try {
|
||||
const r = await fetch('/api/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
})
|
||||
const data = await r.json()
|
||||
if (data.ok) {
|
||||
this.newMessage = ''
|
||||
this.$message.success('✅ 已发送')
|
||||
} else {
|
||||
this.$message.error(`❌ 发送失败: ${data.error}`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('❌ 网络错误')
|
||||
}
|
||||
this.sending = false
|
||||
},
|
||||
async unbanUser(item) {
|
||||
this.$set(item, '_loading', true)
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user