Files
AuthBot/authbot/state.py
T
snowy-yang 755013b9db 初始提交:QQ 群入群验证机器人
基于 qqbotsdk-py 的入群验证:进群发按钮验证码、完成前禁言、
超时踢出(11253 时兜底长期禁言)、乱点他人按钮处罚、状态持久化
与重启恢复。38 个单元/集成测试。
2026-10-04 18:44:08 +08:00

225 lines
7.8 KiB
Python

"""验证状态:待验证/已验证双表 JSON 持久化与超时定时器。
- pending 表按 ``group_openid|member_openid`` 为键,超时定时器随记录
增删自动挂载/取消;进程重启后经 restore() 按剩余时间重新挂载,
已过期的立即触发超时回调;
- verified 表记录最近通过时间,配合 VERIFIED_TRUST_DAYS 决定退群重进
是否免验。
"""
from __future__ import annotations
import asyncio
import json
import time
from collections import OrderedDict
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable
from loguru import logger
@dataclass(slots=True)
class PendingRecord:
"""一名待验证成员的当前题目与截止时间。"""
group_openid: str
member_openid: str
deadline: float # unix 秒;超时倒计时自进群起算,换题不重置
token: str
answer: int
created_at: float
@property
def key(self) -> str:
return pending_key(self.group_openid, self.member_openid)
def pending_key(group_openid: str, member_openid: str) -> str:
return f"{group_openid}|{member_openid}"
class RecentSet:
"""有界去重集合:官方会重推同一 msg_id/事件,超出容量的旧记录淘汰。"""
def __init__(self, maxsize: int = 1024) -> None:
self._maxsize = maxsize
self._items: OrderedDict[Any, None] = OrderedDict()
def add(self, item: Any) -> bool:
"""首次出现返回 True,重复出现返回 False。"""
if item in self._items:
return False
self._items[item] = None
if len(self._items) > self._maxsize:
self._items.popitem(last=False)
return True
class ForeignClickTracker:
"""非本人点击计数:窗口内累计达到上限即应处罚,处罚后清零重新计。
仅内存计数(重启清零),用于实时反捣乱;窗口外的旧点击不计入。
"""
def __init__(self, limit: int = 3, window: float = 1800.0) -> None:
self._limit = limit
self._window = window
self._clicks: dict[str, list[float]] = {}
def register(self, key: str, now: float | None = None) -> int:
"""记录一次点击,返回时间窗口内的累计次数(含本次)。"""
now = time.time() if now is None else now
clicks = [t for t in self._clicks.get(key, []) if now - t <= self._window]
clicks.append(now)
self._clicks[key] = clicks
return len(clicks)
def reset(self, key: str) -> None:
self._clicks.pop(key, None)
class VerifyState:
def __init__(
self,
data_dir: Path,
on_timeout: Callable[[PendingRecord], Awaitable[None]],
) -> None:
self._dir = data_dir
self._on_timeout = on_timeout
self._pending: dict[str, PendingRecord] = {}
self._verified: dict[str, float] = {}
self._timers: dict[str, asyncio.Task[None]] = {}
self._load()
# ---------- 持久化 ----------
@property
def _pending_file(self) -> Path:
return self._dir / "pending.json"
@property
def _verified_file(self) -> Path:
return self._dir / "verified.json"
def _load(self) -> None:
self._pending = {
r["key"]: PendingRecord(
group_openid=r["group_openid"],
member_openid=r["member_openid"],
deadline=r["deadline"],
token=r["token"],
answer=r["answer"],
created_at=r["created_at"],
)
for r in self._read_json(self._pending_file, list)
if isinstance(r, dict) and {"group_openid", "member_openid", "deadline", "token", "answer"} <= r.keys()
}
verified = self._read_json(self._verified_file, dict)
self._verified = {k: v for k, v in verified.items() if isinstance(k, str) and isinstance(v, (int, float))}
def _read_json(self, path: Path, expect: type) -> Any:
if not path.exists():
return expect()
try:
data = json.loads(path.read_text("utf-8"))
if not isinstance(data, expect):
raise ValueError(f"应为 {expect.__name__}")
return data
except (ValueError, OSError) as e:
bad = path.with_name(path.name + ".bad")
path.replace(bad)
logger.error(f"状态文件损坏已备份到 {bad},从空状态开始: {e}")
return expect()
def _save_pending(self) -> None:
self._dir.mkdir(parents=True, exist_ok=True)
data = [
{"key": k, **asdict(r)} for k, r in self._pending.items()
]
self._pending_file.write_text(
json.dumps(data, ensure_ascii=False, indent=2), "utf-8"
)
def _save_verified(self) -> None:
self._dir.mkdir(parents=True, exist_ok=True)
self._verified_file.write_text(
json.dumps(self._verified, ensure_ascii=False, indent=2), "utf-8"
)
# ---------- verified 表 ----------
def is_verified_recently(self, key: str, trust_days: int) -> bool:
if trust_days <= 0:
return False
ts = self._verified.get(key)
return ts is not None and time.time() - ts < trust_days * 86400
def mark_verified(self, key: str) -> None:
self._verified[key] = time.time()
self._save_verified()
# ---------- pending 表与定时器 ----------
def get(self, key: str) -> PendingRecord | None:
return self._pending.get(key)
def get_by_token(self, token: str) -> PendingRecord | None:
"""按题目 token 反查所属记录,用于识别"点的是谁的题"。"""
for record in self._pending.values():
if record.token == token:
return record
return None
def all_pending(self) -> list[PendingRecord]:
return list(self._pending.values())
def summary(self) -> list[tuple[PendingRecord, float]]:
"""(记录, 剩余秒) 列表,剩余为负表示已到点待处理。"""
now = time.time()
return [(r, r.deadline - now) for r in self._pending.values()]
def put(self, record: PendingRecord) -> None:
"""新增/覆盖同键记录并(重)挂定时器。"""
self._pending[record.key] = record
self._save_pending()
self.arm(record.key)
def pop(self, key: str) -> PendingRecord | None:
"""删除记录并取消定时器(验证通过/成员退出/超时已处理)。"""
record = self._pending.pop(key, None)
if (timer := self._timers.pop(key, None)) is not None:
timer.cancel()
if record is not None:
self._save_pending()
return record
def arm(self, key: str) -> None:
"""为 pending 记录挂载/重挂超时任务;已过期的延迟取 0 立即触发。"""
record = self._pending[key]
if (old := self._timers.get(key)) is not None:
old.cancel()
delay = max(0.0, record.deadline - time.time())
self._timers[key] = asyncio.create_task(self._tick(key, delay))
async def _tick(self, key: str, delay: float) -> None:
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
raise
record = self._pending.get(key)
if record is None: # 已被 pop,正常路径
return
try:
# 超时处理由回调全权负责(含 pop 与兜底禁言/踢人)
await self._on_timeout(record)
except Exception:
logger.exception(f"超时处理异常 (key={key})")
async def restore(self) -> None:
"""重启恢复:READY 后重挂全部 pending 定时器(过期的随即触发)。"""
for key in list(self._pending):
self.arm(key)
logger.info(f"已恢复 {len(self._pending)} 条待验证记录的定时器")