初始提交:QQ 群入群验证机器人

基于 qqbotsdk-py 的入群验证:进群发按钮验证码、完成前禁言、
超时踢出(11253 时兜底长期禁言)、乱点他人按钮处罚、状态持久化
与重启恢复。38 个单元/集成测试。
This commit is contained in:
snowy-yang committed 2026-10-04 18:44:08 +08:00
commit 755013b9db
17 files changed
+1864

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
"""AuthBot:QQ 群入群验证机器人业务包。
进群 → 按钮验证码 → 通过前禁言 → 超时踢出(失败则长期禁言兜底)。
事件接入与鉴权由 qqbotsdk 提供,本包只含验证业务逻辑。
"""
__version__ = "0.1.0"
+102
View File
@@ -0,0 +1,102 @@
"""数学选择题验证码:题目生成、选项按钮组装与按钮数据编解码。
按钮 data 格式为 ``v|<token>|<选项值>``:token 标识"当前这道题",
答错换题后旧消息上的按钮因 token 不匹配而失效,防止回放旧题。
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from uuid import uuid4
from qqbotsdk.api import button, keyboard
_BUTTON_PREFIX = "v"
@dataclass(slots=True)
class Captcha:
"""一道验证码;a/op/b 保留运算数便于测试与排查。"""
question: str
answer: int
options: list[int]
token: str
a: int
op: str
b: int
def body(self) -> str:
"""题面正文(不含欢迎/提示头),换题重发时复用。"""
return f"### {self.a} {self.op} {self.b} = ?"
def keyboard(self) -> dict:
"""选项按钮键盘,每行 2 个。"""
rows = [
[button(str(v), encode_button_data(self.token, v)) for v in self.options[i : i + 2]]
for i in range(0, len(self.options), 2)
]
return keyboard(*rows)
def encode_button_data(token: str, value: int) -> str:
return f"{_BUTTON_PREFIX}|{token}|{value}"
def parse_button_data(data: str) -> tuple[str, int] | None:
"""解析按钮 data 为 (token, 选项值);非本机器人按钮返回 None。"""
parts = data.split("|")
if len(parts) != 3 or parts[0] != _BUTTON_PREFIX:
return None
try:
return parts[1], int(parts[2])
except ValueError:
return None
def welcome_markdown(cap: Captcha, timeout_minutes: int, subject: str = "新同学") -> str:
"""进群首题/测试指令消息(markdown + 按钮一起发);subject 为称呼前缀。"""
return (
"**入群验证**\n"
f"{subject}请在 **{timeout_minutes} 分钟内** 点击下方正确答案完成验证,"
"超时未通过将被移出本群。\n\n" + cap.body()
)
def retry_markdown(cap: Captcha) -> str:
"""答错后换题重发的消息。"""
return "答错了,换一题再试试:\n\n" + cap.body()
def generate(option_count: int = 4) -> Captcha:
"""随机生成一道加减/乘法选择题;干扰项取答案邻域且互不重复。"""
op = random.choice(("+", "-", "×"))
match op:
case "+":
a, b = random.randint(1, 20), random.randint(1, 20)
case "-":
a = random.randint(5, 30)
b = random.randint(1, a) # 保证差非负
case _:
a, b = random.randint(2, 9), random.randint(2, 9)
answer = a + b if op == "+" else a - b if op == "-" else a * b
lo = max(0, answer - 12)
distractors: set[int] = set()
while len(distractors) < option_count - 1:
cand = random.randint(lo, answer + 12)
if cand != answer:
distractors.add(cand)
options = [answer, *distractors]
random.shuffle(options)
return Captcha(
question=f"{a} {op} {b}",
answer=answer,
options=options,
token=uuid4().hex[:12],
a=a,
op=op,
b=b,
)
+70
View File
@@ -0,0 +1,70 @@
"""验证业务配置:SDK 的 .env(AppID/AppSecret 等)之上再读验证参数。
全部项都有内置默认值,.env 里按需覆盖;取值越界或非法启动即报错。
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
# 幂等:qqbotsdk 导入时也会 load_dotenv(),且不会覆盖已存在的环境变量
load_dotenv()
def _int_env(name: str, default: int, lo: int, hi: int) -> int:
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
raise ValueError(f"{name} 应为整数,实际: {raw!r}") from None
if not lo <= value <= hi:
raise ValueError(f"{name} 应在 {lo}~{hi} 之间,实际: {value}")
return value
def _bool_env(name: str, default: bool) -> bool:
raw = os.getenv(name, "").strip().lower()
if not raw:
return default
if raw in ("1", "true", "yes", "on"):
return True
if raw in ("0", "false", "no", "off"):
return False
raise ValueError(f"{name} 应为 true/false,实际: {raw!r}")
@dataclass(frozen=True, slots=True)
class AuthConfig:
"""验证业务参数,含义与默认值见 .env.example。"""
verify_timeout_minutes: int
pending_mute_hours: int
timeout_kick: bool
timeout_fallback_mute_hours: int
captcha_options: int
verified_trust_days: int
foreign_click_limit: int
foreign_click_mute_minutes: int
data_dir: Path
@classmethod
def load(cls) -> "AuthConfig":
return cls(
verify_timeout_minutes=_int_env("VERIFY_TIMEOUT_MINUTES", 10, 1, 1440),
pending_mute_hours=_int_env("PENDING_MUTE_HOURS", 24, 1, 24 * 30),
timeout_kick=_bool_env("TIMEOUT_KICK", True),
timeout_fallback_mute_hours=_int_env(
"TIMEOUT_FALLBACK_MUTE_HOURS", 72, 1, 24 * 30
),
captcha_options=_int_env("CAPTCHA_OPTIONS", 4, 2, 5),
verified_trust_days=_int_env("VERIFIED_TRUST_DAYS", 0, 0, 365),
foreign_click_limit=_int_env("FOREIGN_CLICK_LIMIT", 3, 1, 100),
foreign_click_mute_minutes=_int_env("FOREIGN_CLICK_MUTE_MINUTES", 5, 1, 1440),
data_dir=Path(os.getenv("DATA_DIR", "data")),
)
+451
View File
@@ -0,0 +1,451 @@
"""事件处理器:进群验证主流程。
流程(与 README 一致):
- GROUP_MEMBER_ADD → 发按钮验证码 → 发送成功后禁言 → 挂超时定时器;
发送失败(通常是 markdown/按钮权限未开通)则不禁言,避免把成员
永久锁死,同时记日志提示排查方向;
- INTERACTION_CREATE → 回应互动 → 校验点击者/题目 → 答对解禁+记录,
答错换题(倒计时不重置),非本人或旧题点击礼貌忽略;
- 超时 → 尝试移出群(官方"内邀"能力,未开通时返回 11253)→ 失败则
延长禁言兜底并通知管理员;
- GROUP_MEMBER_REMOVE → 清理状态;@机器人 可查"验证状态"。
所有 handler 均含网络调用,注册为 background 避免 429 重试阻塞事件流。
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta
from loguru import logger
from qqbotsdk import Bot
from qqbotsdk.api import ApiError, BotApi
from qqbotsdk.api.v2 import MUTE_OP_ADD, MUTE_OP_DEL, MUTE_OP_UPDATE
from qqbotsdk.events import Event
from qqbotsdk.payloads import GroupAtMessage, GroupMemberChange, Interaction, Ready
from . import captcha
from .config import AuthConfig
from .state import (
ForeignClickTracker,
PendingRecord,
RecentSet,
VerifyState,
pending_key,
)
# 官方业务码:移出群成员能力处于内邀阶段,未开通的机器人返回 11253
KICK_NOT_ALLOWED = 11253
def _rfc3339(dt: datetime) -> str:
return dt.astimezone().isoformat(timespec="seconds")
async def set_member_mute(
api: BotApi, group_openid: str, member_openid: str, until: datetime | None
) -> bool:
"""按成员设置禁言;until=None 解除禁言。返回禁言是否确认生效。
网络层走 SDK 的 mute_group_members(qqbotsdk-py 0.2.1 修复版:官方
请求体为嵌套 {"members": [{"op": ...}]},平铺写法会被服务端静默
忽略——旧版 SDK 即踩此坑)。服务端两类"假成功"仍需自防:
- HTTP 200 但响应体带业务错误码;
- 禁言群主/管理员/机器人时静默忽略(官方限制)。
因此未来时间的禁言必须回查禁言列表,确认成员真的在列。
"""
expire = "" if until is None else _rfc3339(until)
async def _post(op: str) -> None:
result = await api.mute_group_members(
group_openid,
[{"op": op, "member_openid": member_openid, "mute_expire_at": expire}],
)
code = result.get("code") if isinstance(result, dict) else None
if isinstance(result, dict) and code not in (None, 0):
raise ApiError(200, code, result.get("message"))
if until is None:
await _post(MUTE_OP_DEL)
return True # 解禁请求被接受即可(回查"不在列表"无法区分从未禁言)
try:
await _post(MUTE_OP_ADD)
except ApiError:
await _post(MUTE_OP_UPDATE) # 已在禁言中则改为更新到期时间,仍失败向上抛
state = await api.get_group_mute_state(group_openid)
members = state.get("members") if isinstance(state, dict) else None
if not isinstance(members, list):
return False
for entry in members:
if not isinstance(entry, dict) or entry.get("member_openid") != member_openid:
continue
try:
confirmed = datetime.fromisoformat(entry.get("mute_expire_at")) >= until - timedelta(minutes=2)
except (TypeError, ValueError):
confirmed = True # 时间解析不了就只认"人在列表里"
if confirmed:
return True
logger.warning(
f"禁言未生效 (…{member_openid[-6:]}):服务端返回成功但成员不在禁言列表,"
"通常是目标为群主/管理员/机器人(QQ 不允许对其禁言)"
)
return False
async def _reply_text(api: BotApi, group_openid: str, text: str, *, event_id: str | None = None, msg_id: str | None = None) -> None:
"""被动回复文本;窗口过期等失败降级为日志,不影响主流程。"""
try:
await api.post_group_message(group_openid, content=text, event_id=event_id, msg_id=msg_id)
except ApiError as e:
logger.warning(f"回复消息失败 (group={group_openid}): {e}")
async def _reply_captcha(api: BotApi, group_openid: str, md: str, kb: dict, event_id: str) -> None:
await api.post_group_message(group_openid, markdown=md, keyboard=kb, event_id=event_id)
async def _notify(api: BotApi, group_openid: str, text: str) -> None:
"""主动通知(无被动凭据)。主动消息默认未开通,失败只记日志。"""
try:
await api.post_group_message(group_openid, content=text)
except ApiError as e:
logger.warning(f"主动通知失败(可能未开通主动消息): {e}")
async def _start_verification(
api: BotApi,
cfg: AuthConfig,
state: VerifyState,
group_openid: str,
member_openid: str,
*,
reply_event_id: str | None = None,
reply_msg_id: str | None = None,
keep_deadline: float | None = None,
subject: str = "新同学",
) -> str:
"""对一名成员发起验证:发题 → 发送成功后禁言 → 登记 pending。
进群事件与"验证我"测试指令共用。keep_deadline 用于重新出题时保留
原截止时间(限时自进群/发起起算)。返回状态:
- "send_failed":验证码发送失败(此时绝不禁言,防止成员被永久锁死);
- "mute_ineffective":题已发出,但禁言未生效(群主/管理员不可禁言);
- "ok"。
"""
cap = captcha.generate(cfg.captcha_options)
try:
await api.post_group_message(
group_openid,
markdown=captcha.welcome_markdown(cap, cfg.verify_timeout_minutes, subject),
keyboard=cap.keyboard(),
msg_id=reply_msg_id,
event_id=reply_event_id,
)
except ApiError as e:
logger.error(
f"验证码发送失败,本次不禁言 (group={group_openid}, …{member_openid[-6:]}): {e}\n"
">>> 请到 q.qq.com 开发者后台为机器人申请 markdown 消息与按钮(键盘)权限,"
"并确认机器人已添加到群、intents.toml 配置正确。"
)
return "send_failed"
mute_ok = True
try:
mute_ok = await set_member_mute(
api, group_openid, member_openid,
datetime.now() + timedelta(hours=cfg.pending_mute_hours),
)
except ApiError as e:
logger.warning(f"禁言失败(请将机器人设为群管理员)(…{member_openid[-6:]}): {e}")
state.put(PendingRecord(
group_openid=group_openid,
member_openid=member_openid,
deadline=keep_deadline if keep_deadline is not None
else time.time() + cfg.verify_timeout_minutes * 60,
token=cap.token,
answer=cap.answer,
created_at=time.time(),
))
logger.info(f"成员 …{member_openid[-6:]} 开始验证({cfg.verify_timeout_minutes} 分钟限时)")
return "ok" if mute_ok else "mute_ineffective"
async def _handle_foreign_click(
api: BotApi,
cfg: AuthConfig,
tracker: ForeignClickTracker,
group_openid: str,
member_openid: str,
*,
event_id: str | None,
) -> None:
"""非被试者点击他人的验证按钮:计数警告,达到上限禁言一段时间。"""
key = pending_key(group_openid, member_openid)
count = tracker.register(key)
if count < cfg.foreign_click_limit:
remaining = cfg.foreign_click_limit - count
await _reply_text(
api, group_openid,
f"这道题不是给你的~(再乱点 {remaining} 次将被禁言 "
f"{cfg.foreign_click_mute_minutes} 分钟)",
event_id=event_id,
)
return
tracker.reset(key) # 处罚后清零,下一轮重新计
muted = False
try:
muted = await set_member_mute(
api, group_openid, member_openid,
datetime.now() + timedelta(minutes=cfg.foreign_click_mute_minutes),
)
except ApiError as e:
logger.warning(f"处罚禁言失败(需群管理员身份): {e}")
if muted:
logger.info(
f"成员 …{member_openid[-6:]} 多次点击他人验证按钮,"
f"禁言 {cfg.foreign_click_mute_minutes} 分钟"
)
await _reply_text(
api, group_openid,
f"多次乱点他人的验证按钮,已禁言 {cfg.foreign_click_mute_minutes} 分钟。",
event_id=event_id,
)
else:
await _reply_text(
api, group_openid,
"多次乱点他人的验证按钮!禁言未能生效(群主/管理员无法被禁言),"
"请管理员处理。",
event_id=event_id,
)
async def _handle_timeout(bot: Bot, cfg: AuthConfig, state: VerifyState, record: PendingRecord) -> None:
"""超时处理:优先踢出,未开通(11253 等失败)则延长禁言兜底。"""
api = bot.api
state.pop(record.key) # 先摘除,避免重复触发
tag = record.member_openid[-6:]
if cfg.timeout_kick:
try:
result = await api.remove_group_members(record.group_openid, [record.member_openid])
# QQ 失败响应可能仍是 HTTP 200,业务码只能从响应体读
code = result.get("code") if isinstance(result, dict) else None
if isinstance(result, dict) and code not in (None, 0):
raise ApiError(200, code, result.get("message"))
except ApiError as e:
logger.warning(
f"移出群成员失败 (…{tag}, code={e.code}): {e};转入禁言兜底"
)
else:
logger.info(f"成员 …{tag} 超时未验证,已移出群")
await _notify(api, record.group_openid,
f"有成员(…{tag})超时未完成验证,已移出本群。")
return
try:
if not await set_member_mute(
api, record.group_openid, record.member_openid,
datetime.now() + timedelta(hours=cfg.timeout_fallback_mute_hours),
):
logger.warning("兜底禁言未生效(目标可能是群主/管理员)")
else:
logger.info(f"成员 …{tag} 超时未验证,已延长禁言 {cfg.timeout_fallback_mute_hours} 小时")
except ApiError as e:
logger.error(f"兜底禁言失败(请检查机器人是否为群管理员): {e}")
await _notify(
api, record.group_openid,
f"成员(…{tag})超时未完成验证。移出群能力未生效(需群管理员身份,"
f"踢人接口可能仍处官方内邀阶段),已禁言 {cfg.timeout_fallback_mute_hours} 小时,"
"请管理员酌情手动处理。",
)
def setup(bot: Bot, cfg: AuthConfig) -> VerifyState:
"""注册全部事件处理器,返回验证状态实例。"""
async def on_timeout(record: PendingRecord) -> None:
await _handle_timeout(bot, cfg, state, record)
state = VerifyState(cfg.data_dir, on_timeout=on_timeout)
seen_joins = RecentSet()
seen_at_msgs = RecentSet()
foreign_clicks = ForeignClickTracker(limit=cfg.foreign_click_limit)
# ---------- 启动恢复 ----------
@bot.on("READY")
async def on_ready(_: Ready, api: BotApi) -> None:
pending = state.all_pending()
logger.info(f"网关就绪,恢复 {len(pending)} 条待验证记录")
for record in pending:
# 停机期间禁言可能已到期/被解除,防御性重新禁言;失败不阻断
try:
await set_member_mute(
api, record.group_openid, record.member_openid,
datetime.now() + timedelta(hours=cfg.pending_mute_hours),
)
except ApiError as e:
logger.warning(f"恢复禁言失败 (…{record.member_openid[-6:]}): {e}")
await state.restore()
# ---------- 进群:发题 + 禁言 ----------
@bot.on("GROUP_MEMBER_ADD", background=True)
async def on_member_add(d: GroupMemberChange, event: Event, api: BotApi) -> None:
if not d.group_openid or not d.member_openid:
return
if not seen_joins.add((d.group_openid, d.member_openid, d.timestamp)):
return
key = pending_key(d.group_openid, d.member_openid)
if state.is_verified_recently(key, cfg.verified_trust_days):
logger.info(f"成员 …{d.member_openid[-6:]} 在信任期内,跳过验证")
return
status = await _start_verification(
api, cfg, state, d.group_openid, d.member_openid,
reply_event_id=event.event_id or None,
)
if status == "mute_ineffective":
logger.warning("新成员禁言未生效(目标为群主/管理员?),验证流程继续")
# ---------- 点按钮:判卷 ----------
@bot.on("INTERACTION_CREATE", background=True)
async def on_interaction(inter: Interaction, event: Event, api: BotApi) -> None:
# 互动必须先回应,否则点击端一直转圈;重推导致的失败忽略
try:
await api.respond_interaction(inter.id or "")
except ApiError as e:
logger.debug(f"回应互动失败(可能为重复推送): {e}")
if inter.chat_type != 1 or not inter.group_openid or not inter.group_member_openid:
return
parsed = captcha.parse_button_data(inter.button_data)
if parsed is None:
return
token, value = parsed
key = pending_key(inter.group_openid, inter.group_member_openid)
record = state.get(key)
owner = state.get_by_token(token)
is_subject = record is not None and token == record.token
# 点的是别人的当前题:无论点击者自己什么身份,计入乱点处罚
if owner is not None and not is_subject:
await _handle_foreign_click(
api, cfg, foreign_clicks, inter.group_openid, inter.group_member_openid,
event_id=event.event_id,
)
return
if record is None:
if state.is_verified_recently(key, cfg.verified_trust_days):
await _reply_text(api, inter.group_openid, "你已经通过验证啦,不用再答题~",
event_id=event.event_id)
else:
# 旧题/已失效按钮,礼貌忽略不计处罚
await _reply_text(api, inter.group_openid, "这道题不是给你的~",
event_id=event.event_id)
return
if not is_subject:
await _reply_text(api, inter.group_openid, "这题已经被换掉啦,请回答最新一题",
event_id=event.event_id)
return
if value == record.answer:
try:
await set_member_mute(api, inter.group_openid, inter.group_member_openid, None)
except ApiError as e:
logger.warning(f"解除禁言失败(已通过验证,请手动解禁): {e}")
state.pop(key)
state.mark_verified(key)
await _reply_text(api, inter.group_openid, "验证通过,已解除禁言,欢迎加入本群!",
event_id=event.event_id)
logger.info(f"成员 …{inter.group_member_openid[-6:]} 验证通过")
else:
cap = captcha.generate(cfg.captcha_options)
# 换题但保留原截止时间:限时自进群起算
state.put(PendingRecord(
group_openid=record.group_openid,
member_openid=record.member_openid,
deadline=record.deadline,
token=cap.token,
answer=cap.answer,
created_at=record.created_at,
))
try:
await _reply_captcha(api, inter.group_openid, captcha.retry_markdown(cap),
cap.keyboard(), event_id=event.event_id or "")
except ApiError as e:
logger.warning(f"换题发送失败 (…{inter.group_member_openid[-6:]}): {e}")
# ---------- 退群:清理 ----------
@bot.on("GROUP_MEMBER_REMOVE", background=True)
async def on_member_remove(d: GroupMemberChange) -> None:
if d.group_openid and d.member_openid:
state.pop(pending_key(d.group_openid, d.member_openid))
# ---------- 群消息指令:验证状态 / 验证我 / 帮助 ----------
# 平台"群消息接收方式"两种模式对应两个事件名,都注册:
# 仅@消息(GROUP_AT_MESSAGE_CREATE)与全部消息(GROUP_MESSAGE_CREATE)。
# 后者不带 @ 也能触发指令,内容匹配已对 @ 前缀免疫。
async def on_group_command(msg: GroupAtMessage, api: BotApi) -> None:
if not msg.group_openid or (msg.id and not seen_at_msgs.add(msg.id)):
return
text = (msg.content or "").strip()
if "验证状态" in text or "验证进度" in text:
items = state.summary()
if not items:
await _reply_text(api, msg.group_openid, "当前没有待验证的成员。",
msg_id=msg.id)
return
lines = [f"待验证 {len(items)} 人:"]
for record, remain in items:
minutes = max(0, int(remain // 60))
seconds = max(0, int(remain % 60))
lines.append(f"- 成员 …{record.member_openid[-6:]}:剩余 {minutes} 分 {seconds} 秒")
await _reply_text(api, msg.group_openid, "\n".join(lines), msg_id=msg.id)
elif "验证我" in text or "测试验证" in text:
# 测试指令:对发起人本人走一遍完整验证流程(发题→禁言→点按钮解禁)
member = msg.user_openid
if not member:
await _reply_text(api, msg.group_openid, "无法识别你的身份,测试发起失败。",
msg_id=msg.id)
return
existing = state.get(pending_key(msg.group_openid, member))
status = await _start_verification(
api, cfg, state, msg.group_openid, member,
reply_msg_id=msg.id,
keep_deadline=existing.deadline if existing else None,
subject="你发起了验证测试,请",
)
if status == "send_failed":
await _reply_text(api, msg.group_openid,
"验证码发送失败,请联系管理员检查机器人权限。",
msg_id=msg.id)
elif status == "mute_ineffective":
await _reply_text(
api, msg.group_openid,
"提示:你的禁言未能生效(群主/管理员/机器人无法被禁言),"
"本次测试看不到禁言效果;普通成员进群会正常禁言。",
msg_id=msg.id,
)
elif "帮助" in text or "help" in text.lower():
await _reply_text(
api, msg.group_openid,
"我是入群验证机器人:新成员点击验证码按钮即可通过。\n"
"指令:验证状态|验证我(对自己发起一次验证测试)",
msg_id=msg.id,
)
for event_name in ("GROUP_AT_MESSAGE_CREATE", "GROUP_MESSAGE_CREATE"):
bot.on(event_name, on_group_command, background=True)
return state
+224
View File
@@ -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)} 条待验证记录的定时器")