1181 lines
49 KiB
Python
1181 lines
49 KiB
Python
"""Web 管理面板 API"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import re
|
||
import hmac
|
||
import hashlib
|
||
import ipaddress
|
||
import socket
|
||
import time
|
||
import httpx
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from fastapi import FastAPI, Query, Request, Response
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from pydantic import BaseModel, Field
|
||
from tortoise.expressions import Q
|
||
|
||
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
|
||
from services.game_service import list_shop_items, upsert_shop_item, delete_shop_item, list_shop_transactions, draw_lottery_event
|
||
from services import spam_detector
|
||
|
||
BASE_DIR = Path(__file__).parent
|
||
|
||
app = FastAPI(title="TG Spam Guard Manager")
|
||
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||
return JSONResponse({"ok": False, "error": "请求参数无效", "details": exc.errors()}, status_code=422)
|
||
|
||
|
||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||
|
||
# SSE
|
||
_sse_queues: list[asyncio.Queue] = []
|
||
_log_buffer: list[dict] = []
|
||
MAX_LOG_BUFFER = 300
|
||
MAX_LOG_MESSAGE_CHARS = 1200
|
||
_restart_bot_callback = None
|
||
_rule_write_lock = asyncio.Lock()
|
||
|
||
|
||
def set_restart_bot_callback(callback):
|
||
global _restart_bot_callback
|
||
_restart_bot_callback = callback
|
||
|
||
|
||
def compact_log_message(record: logging.LogRecord) -> str:
|
||
"""Web 日志只展示单行摘要,避免大段 traceback/长文本刷屏。"""
|
||
msg = record.getMessage().replace("\r", " ").replace("\n", " ")
|
||
msg = " ".join(msg.split())
|
||
if len(msg) > MAX_LOG_MESSAGE_CHARS:
|
||
msg = msg[:MAX_LOG_MESSAGE_CHARS] + "..."
|
||
return msg
|
||
|
||
|
||
class SSELogHandler(logging.Handler):
|
||
"""把应用日志推送到 Web 实时日志窗口。"""
|
||
|
||
def emit(self, record: logging.LogRecord):
|
||
try:
|
||
item = {
|
||
"time": format_dt(from_timestamp(record.created)),
|
||
"level": record.levelname,
|
||
"message": compact_log_message(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("%(message)s"))
|
||
logger.addHandler(handler)
|
||
|
||
|
||
install_log_handler()
|
||
|
||
AUTH_COOKIE_NAME = "tg_spam_guard_session"
|
||
AUTH_COOKIE_MAX_AGE = 7 * 24 * 3600
|
||
LOCAL_NETWORKS: list[ipaddress._BaseNetwork] = []
|
||
|
||
|
||
def _local_ipv4_addresses() -> set[str]:
|
||
ips = {"127.0.0.1"}
|
||
try:
|
||
hostname = socket.gethostname()
|
||
for item in socket.getaddrinfo(hostname, None, family=socket.AF_INET):
|
||
ips.add(item[4][0])
|
||
except Exception:
|
||
pass
|
||
try:
|
||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
sock.connect(("8.8.8.8", 80))
|
||
ips.add(sock.getsockname()[0])
|
||
sock.close()
|
||
except Exception:
|
||
pass
|
||
return ips
|
||
|
||
|
||
def _build_local_networks() -> list[ipaddress._BaseNetwork]:
|
||
nets: list[ipaddress._BaseNetwork] = [ipaddress.ip_network("127.0.0.0/8")]
|
||
for ip in _local_ipv4_addresses():
|
||
try:
|
||
addr = ipaddress.ip_address(ip)
|
||
if addr.is_loopback:
|
||
continue
|
||
nets.append(ipaddress.ip_network(f"{ip}/24", strict=False))
|
||
except Exception:
|
||
pass
|
||
return nets
|
||
|
||
|
||
LOCAL_NETWORKS = _build_local_networks()
|
||
|
||
|
||
def _sign_session(username: str, ts: int | None = None) -> str:
|
||
ts = ts or int(time.time())
|
||
payload = f"{username}:{ts}"
|
||
sig = hmac.new(WEB_SESSION_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||
return f"{payload}:{sig}"
|
||
|
||
|
||
def _verify_session(token: str | None) -> bool:
|
||
if not token:
|
||
return False
|
||
try:
|
||
username, ts_text, sig = token.split(":", 2)
|
||
ts = int(ts_text)
|
||
if username != WEB_AUTH_USERNAME or time.time() - ts > AUTH_COOKIE_MAX_AGE:
|
||
return False
|
||
expected = hmac.new(WEB_SESSION_SECRET.encode(), f"{username}:{ts}".encode(), hashlib.sha256).hexdigest()
|
||
return hmac.compare_digest(sig, expected)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _client_ip(request: Request) -> str:
|
||
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||
return forwarded or (request.client.host if request.client else "")
|
||
|
||
|
||
def _same_subnet_allowed(request: Request) -> bool:
|
||
if not WEB_TRUST_SAME_SUBNET:
|
||
return False
|
||
try:
|
||
addr = ipaddress.ip_address(_client_ip(request))
|
||
return any(addr in net for net in LOCAL_NETWORKS)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _is_auth_allowed(request: Request) -> tuple[bool, str]:
|
||
if not WEB_AUTH_ENABLED:
|
||
return True, "disabled"
|
||
if _same_subnet_allowed(request):
|
||
return True, "same_subnet"
|
||
if _verify_session(request.cookies.get(AUTH_COOKIE_NAME)):
|
||
return True, "session"
|
||
return False, "login_required"
|
||
|
||
|
||
@app.middleware("http")
|
||
async def web_auth_middleware(request: Request, call_next):
|
||
path = request.url.path
|
||
public = path == "/" or path == "/health" or path.startswith("/static/") or path in {"/api/auth/status", "/api/auth/login"}
|
||
if public:
|
||
return await call_next(request)
|
||
ok, reason = _is_auth_allowed(request)
|
||
if not ok:
|
||
return JSONResponse({"ok": False, "error": "未登录", "reason": reason}, status_code=401)
|
||
return await call_next(request)
|
||
|
||
|
||
@app.get("/api/auth/status")
|
||
async def api_auth_status(request: Request):
|
||
ok, reason = _is_auth_allowed(request)
|
||
return JSONResponse({"ok": True, "authenticated": ok, "reason": reason, "username": WEB_AUTH_USERNAME if ok else None})
|
||
|
||
|
||
@app.post("/api/auth/login")
|
||
async def api_auth_login(request: Request):
|
||
try:
|
||
data = await request.json()
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
except Exception:
|
||
return JSONResponse({"ok": False, "error": "请求体必须是合法 JSON"}, status_code=400)
|
||
username = str(data.get("username") or "")
|
||
password = str(data.get("password") or "")
|
||
if not (hmac.compare_digest(username, WEB_AUTH_USERNAME) and hmac.compare_digest(password, WEB_AUTH_PASSWORD)):
|
||
return JSONResponse({"ok": False, "error": "账号或密码错误"}, status_code=401)
|
||
resp = JSONResponse({"ok": True, "username": username})
|
||
resp.set_cookie(AUTH_COOKIE_NAME, _sign_session(username), max_age=AUTH_COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
||
return resp
|
||
|
||
|
||
@app.post("/api/auth/logout")
|
||
async def api_auth_logout():
|
||
resp = JSONResponse({"ok": True})
|
||
resp.delete_cookie(AUTH_COOKIE_NAME)
|
||
return resp
|
||
|
||
|
||
|
||
|
||
|
||
class LoginPayload(BaseModel):
|
||
username: str = Field(default='', max_length=80)
|
||
password: str = Field(default='', max_length=200)
|
||
|
||
|
||
class SendPayload(BaseModel):
|
||
text: str = Field(default='', max_length=4000)
|
||
chat_id: int | None = None
|
||
reply_to_message_id: int | None = None
|
||
|
||
|
||
class BanPayload(BaseModel):
|
||
duration_minutes: int = Field(default=0, ge=0, le=525600)
|
||
|
||
|
||
class ShopItemPayload(BaseModel):
|
||
chat_id: int = 0
|
||
item_key: str = Field(default='', max_length=64)
|
||
name: str = Field(default='', max_length=80)
|
||
cost: int = Field(default=0, ge=0, le=1_000_000)
|
||
desc: str = Field(default='', max_length=500)
|
||
enabled: bool = True
|
||
stock: int = Field(default=-1, ge=-1, le=1_000_000)
|
||
|
||
|
||
class LotteryPayload(BaseModel):
|
||
chat_id: int = 0
|
||
creator_id: int = 0
|
||
creator_name: str = Field(default='web-admin', max_length=80)
|
||
title: str = Field(default='', max_length=120)
|
||
description: str = Field(default='', max_length=500)
|
||
prize: str = Field(default='', max_length=120)
|
||
condition_type: str = Field(default='all', max_length=32)
|
||
condition_value: int = Field(default=0, ge=0, le=1_000_000)
|
||
draw_type: str = Field(default='people', max_length=32)
|
||
draw_at: str | None = None
|
||
end_type: str = Field(default='people', max_length=32)
|
||
end_value: int = Field(default=0, ge=0, le=1_000_000)
|
||
max_participants: int = Field(default=0, ge=0, le=1_000_000)
|
||
prize_count: int = Field(default=1, ge=1, le=1_000_000)
|
||
remaining_count: int | None = Field(default=None, ge=0, le=1_000_000)
|
||
status: str = Field(default='active', max_length=32)
|
||
|
||
|
||
class SpamTestPayload(BaseModel):
|
||
text: str = Field(default='', max_length=5000)
|
||
use_ai: bool = False
|
||
|
||
|
||
class SpamRulePayload(BaseModel):
|
||
type: str = Field(default='keyword', max_length=16)
|
||
pattern: str = Field(default='', max_length=500)
|
||
score: int = Field(default=60, ge=1, le=200)
|
||
async def safe_json(request: Request) -> dict:
|
||
if not request.headers.get("content-type", "").startswith("application/json"):
|
||
return {}
|
||
try:
|
||
data = await request.json()
|
||
return data if isinstance(data, dict) else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def normalize_condition_type(value: str) -> str:
|
||
value = (value or "all").strip()
|
||
return "min_points" if value == "points" else value
|
||
|
||
|
||
def json_safe(value: Any) -> Any:
|
||
"""将 ORM values() 结果转成 JSONResponse 可序列化对象。"""
|
||
if isinstance(value, datetime):
|
||
return format_dt(value)
|
||
if isinstance(value, list):
|
||
return [json_safe(v) for v in value]
|
||
if isinstance(value, dict):
|
||
return {k: json_safe(v) for k, v in value.items()}
|
||
return value
|
||
|
||
|
||
async def broadcast_sse(event: str, data: dict):
|
||
dead = []
|
||
payload = {"event": event, "data": json.dumps(data, ensure_ascii=False)}
|
||
for q in _sse_queues:
|
||
try:
|
||
await q.put(payload)
|
||
except Exception:
|
||
dead.append(q)
|
||
for q in dead:
|
||
if q in _sse_queues:
|
||
_sse_queues.remove(q)
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def index():
|
||
return HTMLResponse((BASE_DIR / "static" / "index.html").read_text(encoding="utf-8"))
|
||
|
||
|
||
@app.get("/health")
|
||
async def health():
|
||
return {"ok": True, "service": "tg-spam-guard", "time": format_dt(now_tz())}
|
||
|
||
|
||
@app.get("/api/stats")
|
||
async def api_stats(day: str = Query(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$"), chat_id: int | None = None):
|
||
return JSONResponse(await get_daily_stats(day, chat_id=chat_id))
|
||
|
||
|
||
|
||
def tg_proxy_urls(config) -> list[str | None]:
|
||
urls: list[str | None] = []
|
||
for value in getattr(config, "TG_PROXY_URLS", []) or []:
|
||
if value and value not in urls:
|
||
urls.append(value)
|
||
for value in [getattr(config, "TG_PROXY_URL", None), getattr(config, "PROXY_URL", None)]:
|
||
if value and value not in urls:
|
||
urls.append(value)
|
||
return urls or [None]
|
||
|
||
|
||
async def run_tg_request(operation, *, connect_timeout: float = 20.0, read_timeout: float = 20.0, label: str = "tg"):
|
||
import config
|
||
from telegram import Bot
|
||
from telegram.error import BadRequest, Forbidden, NetworkError, TimedOut
|
||
from telegram.request import HTTPXRequest
|
||
|
||
last_error: Exception | None = None
|
||
proxies = tg_proxy_urls(config)
|
||
for idx, proxy in enumerate(proxies, 1):
|
||
try:
|
||
bot = Bot(
|
||
token=config.TG_BOT_TOKEN,
|
||
request=HTTPXRequest(proxy=proxy, connect_timeout=connect_timeout, read_timeout=read_timeout),
|
||
)
|
||
return await operation(bot)
|
||
except (BadRequest, Forbidden):
|
||
raise
|
||
except (NetworkError, TimedOut, Exception) as e:
|
||
last_error = e
|
||
if idx < len(proxies):
|
||
logging.getLogger("spam_guard").warning(
|
||
f"🌐 Web TG 请求失败,切换代理重试: {label}: {type(e).__name__}: {e}; next={proxies[idx]}"
|
||
)
|
||
continue
|
||
raise
|
||
if last_error:
|
||
raise last_error
|
||
raise RuntimeError("TG 请求失败")
|
||
|
||
def parse_draw_at(value):
|
||
if not value:
|
||
return None
|
||
try:
|
||
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=now_tz().tzinfo)
|
||
return dt
|
||
except Exception:
|
||
return None
|
||
|
||
def normalize_message(row: dict) -> dict:
|
||
row = dict(row)
|
||
row["time"] = row.get("created_at")
|
||
row["spam"] = row.get("is_spam", False)
|
||
row["deleted"] = row.get("manually_deleted", False)
|
||
return row
|
||
|
||
|
||
def normalize_ban(row: dict) -> dict:
|
||
row = dict(row)
|
||
row["time"] = row.get("created_at")
|
||
return row
|
||
|
||
|
||
@app.get("/api/messages")
|
||
async def api_messages(limit: int = Query(default=50, ge=1, le=500), chat_id: int | None = None):
|
||
qs = Message.exclude(text="__service__:new_chat_member")
|
||
if chat_id is not None:
|
||
qs = qs.filter(Q(chat_id=chat_id) | Q(chat_id=0))
|
||
msgs = await qs.order_by("-created_at").limit(limit).values()
|
||
return JSONResponse(json_safe([normalize_message(m) for m in msgs]))
|
||
|
||
|
||
@app.get("/api/chat")
|
||
async def api_chat(limit: int = Query(default=100, ge=1, le=500), chat_id: int | None = None):
|
||
"""兼容旧前端:返回聊天记录。"""
|
||
qs = Message.exclude(text="__service__:new_chat_member")
|
||
if chat_id is not None:
|
||
qs = qs.filter(Q(chat_id=chat_id) | Q(chat_id=0))
|
||
msgs = await qs.order_by("-created_at").limit(limit).values()
|
||
return JSONResponse(json_safe([normalize_message(m) for m in msgs]))
|
||
|
||
|
||
@app.get("/api/bans")
|
||
async def api_bans(limit: int = Query(default=100, ge=1, le=500), chat_id: int | None = None):
|
||
qs = Ban.all()
|
||
if chat_id is not None:
|
||
qs = qs.filter(Q(chat_id=chat_id) | Q(chat_id=0))
|
||
bans = await qs.order_by("-created_at").limit(limit).values()
|
||
return JSONResponse(json_safe([normalize_ban(b) for b in bans]))
|
||
|
||
|
||
@app.get("/api/actions")
|
||
async def api_actions(limit: int = Query(default=100, ge=1, le=500)):
|
||
actions = await Action.all().order_by("-created_at").limit(limit).values()
|
||
return JSONResponse(json_safe(list(actions)))
|
||
|
||
|
||
@app.get("/api/groups")
|
||
async def api_groups():
|
||
import config
|
||
sources = []
|
||
for model in (Message, Ban, Action, GameScore, LotteryEvent, ShopItem):
|
||
try:
|
||
sources.extend(await model.all().distinct().values("chat_id"))
|
||
except Exception:
|
||
pass
|
||
ids = sorted({int(r["chat_id"]) for r in sources if r.get("chat_id")})
|
||
if config.TG_CHAT_ID and config.TG_CHAT_ID not in ids:
|
||
ids.insert(0, config.TG_CHAT_ID)
|
||
|
||
result = []
|
||
for chat_id in ids:
|
||
name = str(chat_id)
|
||
try:
|
||
chat = await run_tg_request(lambda bot, cid=chat_id: bot.get_chat(chat_id=cid), connect_timeout=10.0, read_timeout=10.0, label=f"get_chat:{chat_id}")
|
||
name = chat.title or chat.full_name or chat.username or str(chat_id)
|
||
except Exception:
|
||
pass
|
||
result.append({"chat_id": chat_id, "name": name, "label": f"{name} ({chat_id})"})
|
||
return JSONResponse(result)
|
||
|
||
|
||
@app.get("/api/shop-items")
|
||
async def api_shop_items(chat_id: int = 0):
|
||
return JSONResponse(json_safe(await list_shop_items(chat_id, include_disabled=True)))
|
||
|
||
|
||
@app.post("/api/shop-items")
|
||
async def api_save_shop_item(data: ShopItemPayload):
|
||
item_key = data.item_key.strip()
|
||
name = data.name.strip()
|
||
if not item_key or not name:
|
||
return JSONResponse({"ok": False, "error": "商品编号和名称不能为空"}, status_code=400)
|
||
item = await upsert_shop_item(
|
||
chat_id=data.chat_id,
|
||
item_key=item_key,
|
||
name=name,
|
||
cost=data.cost,
|
||
desc=data.desc,
|
||
enabled=data.enabled,
|
||
stock=data.stock,
|
||
)
|
||
return JSONResponse(json_safe({"ok": True, "item": {"id": item.id, "chat_id": item.chat_id, "item_key": item.item_key, "name": item.name, "cost": item.cost, "stock": item.stock, "desc": item.desc, "enabled": item.enabled}}))
|
||
|
||
|
||
@app.delete("/api/shop-items/{item_key}")
|
||
async def api_delete_shop_item(item_key: str, chat_id: int = 0):
|
||
ok = await delete_shop_item(chat_id, item_key)
|
||
return JSONResponse({"ok": ok})
|
||
|
||
|
||
@app.get("/api/shop-transactions")
|
||
async def api_shop_transactions(chat_id: int = 0, limit: int = Query(default=100, ge=1, le=500)):
|
||
return JSONResponse(json_safe(await list_shop_transactions(chat_id, limit)))
|
||
|
||
|
||
@app.get("/api/lotteries")
|
||
async def api_lotteries(chat_id: int | None = None, limit: int = Query(default=100, ge=1, le=300)):
|
||
qs = LotteryEvent.all()
|
||
if chat_id is not None:
|
||
qs = qs.filter(Q(chat_id=chat_id) | Q(chat_id=0))
|
||
rows = list(await qs.order_by("-created_at").limit(limit).values())
|
||
for row in rows:
|
||
row["participants_count"] = await LotteryParticipant.filter(event_id=row["id"]).count()
|
||
return JSONResponse(json_safe(rows))
|
||
|
||
|
||
@app.post("/api/lotteries")
|
||
async def api_create_lottery(data: LotteryPayload):
|
||
title = (data.title or data.prize or "抽奖活动").strip()
|
||
prize = (data.prize or data.title or "奖品").strip()
|
||
if not title or not prize:
|
||
return JSONResponse({"ok": False, "error": "抽奖标题和奖品不能为空"}, status_code=400)
|
||
draw_type = data.draw_type or data.end_type or "manual"
|
||
event = await LotteryEvent.create(
|
||
chat_id=data.chat_id,
|
||
creator_id=data.creator_id,
|
||
creator_name=data.creator_name or "web-admin",
|
||
title=title,
|
||
description=data.description,
|
||
prize=prize,
|
||
condition_type=normalize_condition_type(data.condition_type),
|
||
condition_value=data.condition_value,
|
||
draw_type=draw_type,
|
||
draw_at=parse_draw_at(data.draw_at),
|
||
end_type=data.end_type or draw_type,
|
||
end_value=data.end_value or data.max_participants,
|
||
max_participants=data.max_participants,
|
||
prize_count=max(1, data.prize_count),
|
||
remaining_count=max(0, data.remaining_count if data.remaining_count is not None else data.prize_count),
|
||
status=data.status or "draft",
|
||
)
|
||
return JSONResponse(json_safe({"ok": True, "event": {
|
||
"id": event.id, "chat_id": event.chat_id, "title": event.title, "description": event.description,
|
||
"prize": event.prize, "condition_type": event.condition_type, "condition_value": event.condition_value,
|
||
"draw_type": event.draw_type, "end_type": event.end_type, "end_value": event.end_value,
|
||
"max_participants": event.max_participants, "prize_count": event.prize_count, "remaining_count": event.remaining_count,
|
||
"draw_at": event.draw_at, "status": event.status, "created_at": event.created_at,
|
||
}}))
|
||
|
||
|
||
@app.put("/api/lotteries/{event_id}")
|
||
async def api_update_lottery(event_id: int, data: LotteryPayload):
|
||
event = await LotteryEvent.filter(id=event_id).first()
|
||
if not event:
|
||
return JSONResponse({"ok": False, "error": "抽奖不存在"}, status_code=404)
|
||
participant_count = await LotteryParticipant.filter(event_id=event_id).count()
|
||
if participant_count > 0:
|
||
return JSONResponse({"ok": False, "error": "已有用户参与,不能编辑抽奖;仅可强制开奖或关闭"}, status_code=409)
|
||
for key in ["title", "description", "prize", "draw_type", "end_type", "status"]:
|
||
value = getattr(data, key)
|
||
if value is not None:
|
||
setattr(event, key, str(value or ""))
|
||
event.condition_type = normalize_condition_type(data.condition_type)
|
||
event.draw_at = parse_draw_at(data.draw_at)
|
||
event.condition_value = data.condition_value
|
||
event.end_value = data.end_value
|
||
event.max_participants = data.max_participants
|
||
event.prize_count = max(1, data.prize_count)
|
||
event.remaining_count = max(0, data.remaining_count if data.remaining_count is not None else data.prize_count)
|
||
await event.save()
|
||
return JSONResponse(json_safe({"ok": True, "event": await LotteryEvent.filter(id=event_id).values().first()}))
|
||
|
||
|
||
@app.delete("/api/lotteries/{event_id}")
|
||
async def api_delete_lottery(event_id: int):
|
||
await LotteryParticipant.filter(event_id=event_id).delete()
|
||
ok = await LotteryEvent.filter(id=event_id).delete()
|
||
return JSONResponse({"ok": bool(ok)})
|
||
|
||
|
||
@app.get("/api/lotteries/{event_id}/participants")
|
||
async def api_lottery_participants(event_id: int):
|
||
rows = await LotteryParticipant.filter(event_id=event_id).order_by("created_at").values()
|
||
return JSONResponse(json_safe(list(rows)))
|
||
|
||
|
||
@app.post("/api/lotteries/{event_id}/cancel")
|
||
async def api_cancel_lottery(event_id: int):
|
||
event = await LotteryEvent.filter(id=event_id).first()
|
||
if not event:
|
||
return JSONResponse({"ok": False, "error": "抽奖不存在"}, status_code=404)
|
||
event.status = "cancelled"
|
||
event.ended_at = now_tz()
|
||
await event.save()
|
||
return JSONResponse({"ok": True})
|
||
|
||
|
||
@app.post("/api/lotteries/{event_id}/draw")
|
||
async def api_draw_lottery(event_id: int):
|
||
result = await draw_lottery_event(event_id)
|
||
return JSONResponse(json_safe(result))
|
||
|
||
|
||
@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/restart-bot")
|
||
async def api_restart_bot():
|
||
"""只重启 Telegram Bot,不重启 Web 服务。"""
|
||
if not _restart_bot_callback:
|
||
return JSONResponse({"ok": False, "error": "Bot 重启回调未就绪"}, status_code=503)
|
||
logging.getLogger("spam_guard").warning("🔄 Web 管理端请求重启 TG Bot")
|
||
asyncio.create_task(_restart_bot_callback())
|
||
return JSONResponse({"ok": True, "message": "TG Bot 正在重启"})
|
||
|
||
|
||
def sanitize_manual_rule(rule_type: str, pattern: str) -> tuple[str, str | None]:
|
||
"""清洗后台手动规则。关键词会去零宽/压缩空白,正则只做首尾清理并校验。"""
|
||
original = str(pattern or "")
|
||
cleaned = spam_detector.normalize(original)
|
||
cleaned = cleaned.replace("|", "|").strip()
|
||
if rule_type == "keyword":
|
||
# 关键词最终写紧凑版本:外部关键词会同时匹配 normalize/compact 两种目标,
|
||
# 紧凑写入能覆盖广告常用的零宽字符、断词、插空格逃逸。
|
||
cleaned = spam_detector.compact(cleaned)
|
||
cleaned = cleaned.strip("||")
|
||
if len(cleaned) < 2:
|
||
raise ValueError("关键词太短,至少 2 个字符")
|
||
if cleaned.lower() in {"qq", "tg", "ok", "yes", "http", "https", "招聘", "联系", "项目"}:
|
||
raise ValueError("关键词过于泛化,容易误杀,请改用更具体词组或正则")
|
||
else:
|
||
if not cleaned:
|
||
raise ValueError("正则不能为空")
|
||
re.compile(cleaned)
|
||
changed = cleaned != original.strip()
|
||
return cleaned, (original if changed else None)
|
||
|
||
|
||
def parse_json_object(text: str) -> dict[str, Any] | None:
|
||
body = (text or "").strip()
|
||
if not body:
|
||
return None
|
||
try:
|
||
data = json.loads(body)
|
||
return data if isinstance(data, dict) else None
|
||
except Exception:
|
||
pass
|
||
m = re.search(r"\{[\s\S]*\}", body)
|
||
if not m:
|
||
return None
|
||
try:
|
||
data = json.loads(m.group(0))
|
||
return data if isinstance(data, dict) else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def fallback_rule_from_text(text: str) -> dict[str, Any]:
|
||
norm = spam_detector.normalize(text)
|
||
domains = sorted(spam_detector.extract_domains_from_text(norm))
|
||
if domains:
|
||
return {"type": "keyword", "pattern": domains[0], "score": 80, "reason": "AI不可用,按域名生成关键词规则"}
|
||
compacted = spam_detector.compact(norm)
|
||
compacted = re.sub(r"[,。!?、,.!?;;::\s]+", "", compacted)
|
||
if len(compacted) > 80:
|
||
compacted = compacted[:80]
|
||
return {"type": "keyword", "pattern": compacted, "score": 90, "reason": "AI不可用,按紧凑文本生成关键词规则"}
|
||
|
||
|
||
async def generate_rule_from_text(text: str, use_ai: bool = True) -> dict[str, Any]:
|
||
import config
|
||
if not use_ai:
|
||
return fallback_rule_from_text(text)
|
||
prompt = f"""你是中文群聊垃圾广告规则提取器。请从下面的聊天内容中提取一条最适合写入拦截库的规则。
|
||
|
||
要求:
|
||
1. 只输出 JSON,不要解释,不要 Markdown。
|
||
2. JSON 格式:{{"type":"keyword|regex","pattern":"规则内容","score":1-200,"reason":"一句话说明"}}
|
||
3. 优先生成能覆盖变体但不容易误杀的规则。
|
||
4. 如果内容包含明确广告域名/链接,优先提取域名作为 keyword。
|
||
5. 如果有零宽字符、断词、空格插入、黑U/USDT/跑分/贷款/博彩等变体,优先生成 regex。
|
||
6. keyword 不要太泛,禁止只输出:联系、项目、赚钱、福利、微信、QQ、TG、http、https。
|
||
7. regex 必须是 Python re 可编译的正则,不要带 /.../ 包裹。
|
||
8. 分数建议:明确灰产/博彩/色情/诈骗 90-120;普通营销 60-80;域名 80。
|
||
|
||
聊天内容:
|
||
{text[:2000]}
|
||
"""
|
||
try:
|
||
async with httpx.AsyncClient(timeout=60, proxy=None) as client:
|
||
resp = await client.post(
|
||
f"{config.OLLAMA_URL}/api/generate",
|
||
json={"model": config.OLLAMA_MODEL, "prompt": prompt, "stream": False, "think": False, "options": {"temperature": 0.1, "num_predict": 220}},
|
||
)
|
||
resp.raise_for_status()
|
||
reply = resp.json().get("response", "")
|
||
data = parse_json_object(reply)
|
||
if not data:
|
||
raise ValueError("AI 未返回合法 JSON")
|
||
rule_type = str(data.get("type") or "keyword").strip().lower()
|
||
pattern = str(data.get("pattern") or "").strip()
|
||
score = int(data.get("score") or 90)
|
||
reason = str(data.get("reason") or "AI 生成规则").strip()
|
||
if rule_type not in {"keyword", "regex"}:
|
||
rule_type = "keyword"
|
||
if not pattern:
|
||
raise ValueError("AI 生成规则为空")
|
||
return {"type": rule_type, "pattern": pattern, "score": max(1, min(score, 200)), "reason": reason, "ai_used": True}
|
||
except Exception as e:
|
||
logging.getLogger("spam_guard").warning(f"⚠️ AI 生成规则失败,使用兜底规则: {e}")
|
||
data = fallback_rule_from_text(text)
|
||
data["ai_used"] = False
|
||
data["reason"] = f"{data.get('reason', '兜底生成')};AI异常: {type(e).__name__}"
|
||
return data
|
||
|
||
|
||
async def append_spam_rule(rule_type: str, pattern: str, score: int, source_note: str = "Web 添加规则") -> dict[str, Any]:
|
||
rule_type = rule_type.strip().lower()
|
||
if rule_type not in {"keyword", "regex"}:
|
||
raise ValueError("规则类型只能是 keyword 或 regex")
|
||
score = max(1, min(int(score), 200))
|
||
stored_pattern, original_pattern = sanitize_manual_rule(rule_type, pattern)
|
||
path = spam_detector.RULES_DIR / ("spam_keywords.txt" if rule_type == "keyword" else "spam_regex.txt")
|
||
line = f"{stored_pattern}|{score}"
|
||
async with _rule_write_lock:
|
||
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
||
existing_patterns = {raw.lstrip("\ufeff").rsplit("|", 1)[0].strip() for raw in existing.splitlines() if raw.strip() and not raw.strip().lstrip("\ufeff").startswith("#")}
|
||
if stored_pattern in existing_patterns:
|
||
raise FileExistsError("规则已存在")
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with path.open("a", encoding="utf-8") as f:
|
||
if existing and not existing.endswith("\n"):
|
||
f.write("\n")
|
||
note = f" 原始: {original_pattern}" if original_pattern else ""
|
||
f.write(f"\n# {source_note} {format_dt(now_tz())}{note}\n{line}\n")
|
||
counts = spam_detector.reload_external_rules()
|
||
return {"type": rule_type, "pattern": stored_pattern, "original_pattern": original_pattern, "score": score, "rule_counts": counts}
|
||
|
||
|
||
@app.get("/api/spam-rules")
|
||
async def api_list_spam_rules(limit: int = Query(default=500, ge=1, le=2000)):
|
||
def read_rules(path: Path, rule_type: str):
|
||
items = []
|
||
if not path.exists():
|
||
return items
|
||
for i, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||
line = raw.strip().lstrip("\ufeff")
|
||
if not line or line.startswith("#"):
|
||
continue
|
||
if "|" in line:
|
||
pattern, score_text = line.rsplit("|", 1)
|
||
try:
|
||
score = int(score_text.strip())
|
||
except ValueError:
|
||
pattern, score = line, None
|
||
else:
|
||
pattern, score = line, None
|
||
items.append({"type": rule_type, "line": i, "pattern": pattern.strip(), "score": score})
|
||
return items
|
||
keywords = read_rules(spam_detector.RULES_DIR / "spam_keywords.txt", "keyword")
|
||
regex = read_rules(spam_detector.RULES_DIR / "spam_regex.txt", "regex")
|
||
adblock_path = spam_detector.RULES_DIR / "adblock_domains.txt"
|
||
adblock_preview = []
|
||
adblock_count = 0
|
||
if adblock_path.exists():
|
||
for line in adblock_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||
raw = line.strip()
|
||
if not raw or raw.startswith("#"):
|
||
continue
|
||
adblock_count += 1
|
||
if len(adblock_preview) < min(limit, 200):
|
||
adblock_preview.append({"type": "adblock_domain", "line": adblock_count, "pattern": raw, "score": 80})
|
||
return JSONResponse({"ok": True, "keywords": keywords[-limit:], "regex": regex[-limit:], "adblock_domains": adblock_preview, "counts": {"keywords": len(keywords), "regex": len(regex), "adblock_domains": adblock_count}})
|
||
|
||
|
||
@app.post("/api/spam-test")
|
||
async def api_spam_test(data: SpamTestPayload):
|
||
text = data.text.strip()
|
||
use_ai = data.use_ai
|
||
if not text:
|
||
return JSONResponse({"ok": False, "error": "请输入要检测的文本"}, status_code=400)
|
||
decision, reason, score = spam_detector.local_classify(text)
|
||
result = {
|
||
"ok": True,
|
||
"text": text,
|
||
"local_decision": decision,
|
||
"local_is_spam": bool(decision) if decision is not None else None,
|
||
"reason": reason,
|
||
"score": score,
|
||
"rule_counts": {"keywords": len(spam_detector.EXTERNAL_KEYWORD_RULES), "regex": len(spam_detector.EXTERNAL_REGEX_RULES), "adblock_domains": len(spam_detector.ADBLOCK_DOMAINS)},
|
||
}
|
||
if use_ai:
|
||
if decision is True:
|
||
result.update({"ai_is_spam": None, "ai_reason": "本地强规则已判定为垃圾,未额外调用 AI", "final_is_spam": True})
|
||
else:
|
||
ai_is_spam, ai_reason = await spam_detector.ai_classify(text)
|
||
final_is_spam = True if ai_is_spam else (bool(decision) if decision is not None else False)
|
||
if ai_is_spam and score < 75:
|
||
result["score"] = 80
|
||
result["reason"] = f"{reason}; AI加权判定"
|
||
result.update({"ai_is_spam": ai_is_spam, "ai_reason": ai_reason, "final_is_spam": final_is_spam})
|
||
else:
|
||
result["final_is_spam"] = bool(decision) if decision is not None else None
|
||
return JSONResponse(result)
|
||
|
||
|
||
@app.post("/api/spam-rules/from-text")
|
||
async def api_add_spam_rule_from_text(request: Request):
|
||
try:
|
||
raw = await request.json()
|
||
if not isinstance(raw, dict):
|
||
raw = {}
|
||
except Exception:
|
||
return JSONResponse({"ok": False, "error": "请求体必须是合法 JSON"}, status_code=400)
|
||
text = str(raw.get("text") or "").strip()
|
||
use_ai = bool(raw.get("use_ai", True))
|
||
if not text:
|
||
return JSONResponse({"ok": False, "error": "请输入要入库的聊天内容"}, status_code=400)
|
||
if len(text) > 4000:
|
||
return JSONResponse({"ok": False, "error": "聊天内容不能超过 4000 字"}, status_code=400)
|
||
generated = None
|
||
try:
|
||
generated = await generate_rule_from_text(text, use_ai)
|
||
saved = await append_spam_rule(generated["type"], generated["pattern"], generated["score"], "Web AI生成规则")
|
||
except FileExistsError as e:
|
||
return JSONResponse({"ok": False, "error": str(e), "generated_rule": generated}, status_code=409)
|
||
except re.error as e:
|
||
return JSONResponse({"ok": False, "error": f"AI 生成的正则无效:{e}", "generated_rule": generated}, status_code=400)
|
||
except ValueError as e:
|
||
return JSONResponse({"ok": False, "error": str(e), "generated_rule": generated}, status_code=400)
|
||
await Action.create(action="SPAM_RULE_AI_ADD", details={"text": text[:500], "generated": generated, "saved": saved})
|
||
return JSONResponse({"ok": True, **saved, "generated_rule": generated})
|
||
|
||
|
||
@app.post("/api/spam-rules")
|
||
async def api_add_spam_rule(request: Request):
|
||
try:
|
||
raw = await request.json()
|
||
if not isinstance(raw, dict):
|
||
raw = {}
|
||
except Exception:
|
||
return JSONResponse({"ok": False, "error": "请求体必须是合法 JSON"}, status_code=400)
|
||
rule_type = str(raw.get("type") or "keyword")
|
||
pattern = str(raw.get("pattern") or "").strip()
|
||
try:
|
||
score = int(raw.get("score") or 75)
|
||
except Exception:
|
||
score = 75
|
||
if not pattern or len(pattern) > 500:
|
||
return JSONResponse({"ok": False, "error": "规则内容不能为空,且不能超过 500 字"}, status_code=400)
|
||
try:
|
||
saved = await append_spam_rule(rule_type, pattern, score, "Web 手动添加规则")
|
||
except FileExistsError as e:
|
||
return JSONResponse({"ok": False, "error": str(e)}, status_code=409)
|
||
except re.error as e:
|
||
return JSONResponse({"ok": False, "error": f"正则无效:{e}"}, status_code=400)
|
||
except ValueError as e:
|
||
return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
|
||
await Action.create(action="SPAM_RULE_ADD", details={"type": saved["type"], "pattern": saved["pattern"], "score": saved["score"], "original": saved.get("original_pattern")})
|
||
return JSONResponse({"ok": True, **saved})
|
||
|
||
|
||
@app.post("/api/restart")
|
||
async def api_restart():
|
||
"""重启服务:返回响应后退出当前进程,由 Docker restart 策略拉起。"""
|
||
import os
|
||
import signal
|
||
|
||
logging.getLogger("spam_guard").warning("🔄 Web 管理端请求重启服务")
|
||
|
||
async def delayed_exit():
|
||
await asyncio.sleep(1)
|
||
os.kill(os.getpid(), signal.SIGTERM)
|
||
|
||
asyncio.create_task(delayed_exit())
|
||
return JSONResponse({"ok": True, "message": "服务正在重启"})
|
||
|
||
|
||
def build_ban_options(duration_minutes: int):
|
||
"""duration_minutes=0 表示永久封禁;否则按分钟自动解封。"""
|
||
duration = int(duration_minutes or 0)
|
||
if duration <= 0:
|
||
return {"duration_minutes": 0, "auto_unban": False, "unban_at": None, "until_date": None}
|
||
unban_at = now_tz() + timedelta(minutes=duration)
|
||
return {"duration_minutes": duration, "auto_unban": True, "unban_at": unban_at, "until_date": int(unban_at.timestamp())}
|
||
|
||
def ban_duration_label(duration_minutes: int) -> str:
|
||
duration = int(duration_minutes or 0)
|
||
if duration <= 0:
|
||
return "永久"
|
||
if duration % 1440 == 0:
|
||
return f"{duration // 1440} 天"
|
||
if duration % 60 == 0:
|
||
return f"{duration // 60} 小时"
|
||
return f"{duration} 分钟"
|
||
|
||
@app.post("/api/send")
|
||
async def api_send(data: SendPayload):
|
||
import config
|
||
text = data.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)
|
||
|
||
chat_id = int(data.chat_id or config.TG_CHAT_ID)
|
||
reply_to_message_id = data.reply_to_message_id
|
||
msg = await run_tg_request(
|
||
lambda bot: bot.send_message(chat_id=chat_id, text=text, reply_to_message_id=reply_to_message_id),
|
||
label="send_message",
|
||
)
|
||
rec = await Message.create(
|
||
msg_id=msg.message_id,
|
||
chat_id=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": chat_id,
|
||
"user_id": 0,
|
||
"username": "web-admin",
|
||
"first_name": "Web 管理端",
|
||
"text": text,
|
||
"time": format_dt(now_tz()),
|
||
"spam": False,
|
||
})
|
||
return JSONResponse({"ok": True, "msg_id": msg.message_id})
|
||
|
||
|
||
@app.post("/api/messages/{message_id}/ban")
|
||
async def api_ban_message_user(message_id: int, data: BanPayload | None = None):
|
||
"""根据聊天记录封禁用户,不删除消息。duration_minutes=0 为永久封禁。"""
|
||
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)
|
||
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"]:
|
||
await run_tg_request(lambda bot: bot.ban_chat_member(chat_id=rec.chat_id, user_id=rec.user_id, until_date=opts["until_date"]), connect_timeout=20.0, read_timeout=20.0, label="ban_chat_member")
|
||
else:
|
||
await run_tg_request(lambda bot: bot.ban_chat_member(chat_id=rec.chat_id, user_id=rec.user_id), connect_timeout=20.0, read_timeout=20.0, label="ban_chat_member")
|
||
ban = await Ban.create(
|
||
user_id=rec.user_id,
|
||
username=rec.username,
|
||
first_name=rec.first_name,
|
||
reason=f"Web 手动封禁({ban_duration_label(opts['duration_minutes'])})",
|
||
method="web_manual_ban",
|
||
chat_id=rec.chat_id,
|
||
duration_minutes=opts["duration_minutes"],
|
||
auto_unban=opts["auto_unban"],
|
||
unban_at=opts["unban_at"],
|
||
)
|
||
await Action.create(action="BAN", user_id=rec.user_id, username=rec.username, chat_id=rec.chat_id, details={"method": "web_manual_ban", "message_id": rec.id, "duration_minutes": opts["duration_minutes"]})
|
||
await broadcast_sse("ban", json_safe({
|
||
"id": ban.id,
|
||
"chat_id": rec.chat_id,
|
||
"user_id": rec.user_id,
|
||
"username": rec.username,
|
||
"first_name": rec.first_name,
|
||
"reason": ban.reason,
|
||
"method": ban.method,
|
||
"duration_minutes": opts["duration_minutes"],
|
||
"auto_unban": opts["auto_unban"],
|
||
"unban_at": opts["unban_at"],
|
||
"time": ban.created_at,
|
||
}))
|
||
return JSONResponse(json_safe({"ok": True, "banned": True, "ban_id": ban.id, "duration_minutes": opts["duration_minutes"], "auto_unban": opts["auto_unban"]}))
|
||
except Exception as e:
|
||
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 = Query(...), limit: int = Query(default=200, ge=1, le=1000)):
|
||
qs = Whitelist.filter(enabled=True, chat_id=chat_id)
|
||
rows = await qs.order_by("-updated_at").limit(limit).values()
|
||
return JSONResponse(json_safe(list(rows)))
|
||
|
||
|
||
@app.post("/api/whitelist")
|
||
async def api_create_whitelist(request: Request):
|
||
"""手动添加当前群组白名单用户。白名单严格按群组隔离。"""
|
||
data = await safe_json(request)
|
||
try:
|
||
user_id = int(data.get("user_id") or 0)
|
||
chat_id = int(data.get("chat_id") or 0)
|
||
except (TypeError, ValueError):
|
||
return JSONResponse({"ok": False, "error": "chat_id/user_id 必须是数字"}, status_code=400)
|
||
if not chat_id:
|
||
return JSONResponse({"ok": False, "error": "chat_id 不能为空,请先选择群组"}, status_code=400)
|
||
if not user_id:
|
||
return JSONResponse({"ok": False, "error": "user_id 不能为空"}, status_code=400)
|
||
username = str(data.get("username") or "").strip().lstrip("@")[:255] or None
|
||
first_name = str(data.get("first_name") or "").strip()[:255] or None
|
||
reason = str(data.get("reason") or "Web 手动添加白名单").strip()[:500]
|
||
item = await Whitelist.get_or_none(chat_id=chat_id, user_id=user_id)
|
||
if item:
|
||
item.enabled = True
|
||
item.username = username or item.username
|
||
item.first_name = first_name or item.first_name
|
||
item.reason = reason
|
||
item.updated_at = now_tz()
|
||
await item.save()
|
||
else:
|
||
item = await Whitelist.create(chat_id=chat_id, user_id=user_id, username=username, first_name=first_name, reason=reason)
|
||
await Action.create(action="WHITELIST_ADD", chat_id=chat_id, user_id=user_id, username=username, details={"source": "web_manual", "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.delete("/api/whitelist/{item_id}")
|
||
async def api_delete_whitelist(item_id: int):
|
||
item = await Whitelist.get_or_none(id=item_id, enabled=True)
|
||
if not item:
|
||
return JSONResponse({"ok": False, "error": "白名单记录不存在"}, status_code=404)
|
||
item.enabled = False
|
||
item.updated_at = now_tz()
|
||
await item.save()
|
||
await Action.create(action="WHITELIST_DELETE", chat_id=item.chat_id, user_id=item.user_id, username=item.username, details={"whitelist_id": item.id})
|
||
return JSONResponse({"ok": True})
|
||
|
||
|
||
@app.post("/api/chat/{message_id}/delete")
|
||
@app.post("/api/messages/{message_id}/delete")
|
||
async def api_delete_message(message_id: int, request: Request):
|
||
"""手动删除聊天记录:先删 Telegram 消息,再把数据库记录标记为已删除。"""
|
||
import config
|
||
from telegram.error import BadRequest
|
||
|
||
data = await safe_json(request)
|
||
ban_user = bool(data.get("ban_user") or data.get("ban"))
|
||
ban_opts = build_ban_options(int(data.get("duration_minutes") or 0))
|
||
|
||
rec = await Message.get_or_none(id=message_id)
|
||
if not rec:
|
||
return JSONResponse({"ok": False, "error": "消息不存在"}, status_code=404)
|
||
if rec.manually_deleted:
|
||
return JSONResponse({"ok": True, "already_deleted": True})
|
||
if not rec.chat_id or not rec.msg_id:
|
||
return JSONResponse({"ok": False, "error": "缺少 TG chat_id/msg_id,无法删除 TG 消息"}, status_code=400)
|
||
|
||
tg_deleted = True
|
||
tg_error = None
|
||
try:
|
||
await run_tg_request(lambda bot: bot.delete_message(chat_id=rec.chat_id, message_id=rec.msg_id), connect_timeout=20.0, read_timeout=20.0, label="delete_message")
|
||
except BadRequest as e:
|
||
# 消息已不存在时,允许继续标记数据库;其他 TG 删除失败不标记。
|
||
if "message to delete not found" in str(e).lower():
|
||
tg_error = str(e)
|
||
else:
|
||
return JSONResponse({"ok": False, "error": f"TG 删除失败: {e}"}, status_code=400)
|
||
except Exception as e:
|
||
return JSONResponse({"ok": False, "error": f"TG 删除失败: {type(e).__name__}: {e}"}, status_code=502)
|
||
|
||
rec.manually_deleted = True
|
||
rec.deleted_at = now_tz()
|
||
rec.delete_reason = "web_manual_delete" if not tg_error else f"web_manual_delete_after_tg_error: {tg_error[:160]}"
|
||
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"]:
|
||
await run_tg_request(lambda bot: bot.ban_chat_member(chat_id=rec.chat_id, user_id=rec.user_id, until_date=ban_opts["until_date"]), connect_timeout=20.0, read_timeout=20.0, label="ban_chat_member")
|
||
else:
|
||
await run_tg_request(lambda bot: bot.ban_chat_member(chat_id=rec.chat_id, user_id=rec.user_id), connect_timeout=20.0, read_timeout=20.0, label="ban_chat_member")
|
||
ban = await Ban.create(
|
||
user_id=rec.user_id,
|
||
username=rec.username,
|
||
first_name=rec.first_name,
|
||
reason=f"Web 删除消息时手动封禁({ban_duration_label(ban_opts['duration_minutes'])})",
|
||
method="web_delete_ban",
|
||
chat_id=rec.chat_id,
|
||
duration_minutes=ban_opts["duration_minutes"],
|
||
auto_unban=ban_opts["auto_unban"],
|
||
unban_at=ban_opts["unban_at"],
|
||
)
|
||
except Exception as e:
|
||
return JSONResponse({"ok": False, "error": f"消息已删除,但封禁失败: {type(e).__name__}: {e}"}, status_code=502)
|
||
await Action.create(
|
||
action="MANUAL_DELETE_MESSAGE",
|
||
user_id=rec.user_id,
|
||
username=rec.username,
|
||
details={"message_id": rec.id, "chat_id": rec.chat_id, "msg_id": rec.msg_id, "tg_error": tg_error},
|
||
)
|
||
logging.getLogger("spam_guard").info(f"🗑️ Web 手动删除聊天消息: db_id={rec.id}, tg_msg_id={rec.msg_id}, user=@{rec.username}")
|
||
await broadcast_sse("chat_delete", json_safe({
|
||
"id": rec.id,
|
||
"msg_id": rec.msg_id,
|
||
"chat_id": rec.chat_id,
|
||
"deleted_at": rec.deleted_at,
|
||
}))
|
||
if ban:
|
||
await Action.create(action="BAN", user_id=rec.user_id, username=rec.username, details={"method": "web_delete_ban", "message_id": rec.id, "duration_minutes": ban_opts["duration_minutes"]})
|
||
await broadcast_sse("ban", json_safe({
|
||
"id": ban.id,
|
||
"user_id": rec.user_id,
|
||
"username": rec.username,
|
||
"first_name": rec.first_name,
|
||
"reason": ban.reason,
|
||
"method": ban.method,
|
||
"duration_minutes": ban_opts["duration_minutes"],
|
||
"auto_unban": ban_opts["auto_unban"],
|
||
"unban_at": ban_opts["unban_at"],
|
||
"chat_id": rec.chat_id,
|
||
"time": ban.created_at,
|
||
}))
|
||
return JSONResponse(json_safe({"ok": True, "tg_deleted": tg_deleted, "tg_error": tg_error, "deleted_at": rec.deleted_at, "banned": bool(ban)}))
|
||
|
||
|
||
@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()
|
||
return JSONResponse(json_safe(list(scores)))
|
||
|
||
|
||
@app.post("/api/unban/{user_id}")
|
||
async def api_unban(user_id: int, chat_id: int | None = None):
|
||
import config
|
||
target_chat_id = int(chat_id or config.TG_CHAT_ID)
|
||
try:
|
||
await run_tg_request(lambda bot: bot.unban_chat_member(chat_id=target_chat_id, user_id=user_id), label="unban_chat_member")
|
||
await Ban.filter(user_id=user_id, chat_id=target_chat_id).update(auto_unban=False, unban_at=now_tz().replace(tzinfo=None))
|
||
await Action.create(action="UNBAN", chat_id=target_chat_id, user_id=user_id, details={"method": "web"})
|
||
await broadcast_sse("unban", {"chat_id": target_chat_id, "user_id": user_id})
|
||
return JSONResponse({"ok": True, "chat_id": target_chat_id, "user_id": user_id})
|
||
except Exception as e:
|
||
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
||
|
||
|
||
@app.get("/api/stream")
|
||
async def api_stream(request: Request):
|
||
from sse_starlette.sse import EventSourceResponse
|
||
|
||
queue: asyncio.Queue = asyncio.Queue()
|
||
_sse_queues.append(queue)
|
||
|
||
async def event_generator():
|
||
try:
|
||
yield {"event": "connected", "data": "ok"}
|
||
while True:
|
||
if await request.is_disconnected():
|
||
break
|
||
try:
|
||
data = await asyncio.wait_for(queue.get(), timeout=30)
|
||
yield {"event": data["event"], "data": data["data"]}
|
||
except asyncio.TimeoutError:
|
||
yield {"event": "ping", "data": "ok"}
|
||
finally:
|
||
if queue in _sse_queues:
|
||
_sse_queues.remove(queue)
|
||
|
||
return EventSourceResponse(event_generator())
|