初始提交:QQ 群入群验证机器人
基于 qqbotsdk-py 的入群验证:进群发按钮验证码、完成前禁言、 超时踢出(11253 时兜底长期禁言)、乱点他人按钮处罚、状态持久化 与重启恢复。38 个单元/集成测试。
This commit is contained in:
commit
755013b9db
17 files changed
+1864
No files matched your search
@@ -0,0 +1,7 @@
|
||||
"""AuthBot:QQ 群入群验证机器人业务包。
|
||||
|
||||
进群 → 按钮验证码 → 通过前禁言 → 超时踢出(失败则长期禁言兜底)。
|
||||
事件接入与鉴权由 qqbotsdk 提供,本包只含验证业务逻辑。
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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