初始提交:QQ 群入群验证机器人
基于 qqbotsdk-py 的入群验证:进群发按钮验证码、完成前禁言、 超时踢出(11253 时兜底长期禁言)、乱点他人按钮处罚、状态持久化 与重启恢复。38 个单元/集成测试。
This commit is contained in:
commit
755013b9db
17 files changed
+1864
No files matched your search
@@ -0,0 +1,224 @@
|
||||
"""验证状态:待验证/已验证双表 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)} 条待验证记录的定时器")
|
||||
Reference in new issue
Block a user