"""事件处理器:进群验证主流程。 流程(与 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, mention: str | None = None, ) -> None: """被动回复文本;mention 传 openid 时改为 markdown 内联提及 @对方。 窗口过期等失败降级为日志,不影响主流程。提及必须走 markdown (纯文本 content 不解析 <@> 语法)。 """ try: if mention: await api.post_group_message( group_openid, markdown=f"<@{mention}> {text}", event_id=event_id, msg_id=msg_id, ) else: 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, mention=member_openid, ) 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, mention=member_openid, ) else: await _reply_text( api, group_openid, "多次乱点他人的验证按钮!禁言未能生效(群主/管理员无法被禁言)," "请管理员处理。", event_id=event_id, mention=member_openid, ) 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}") # 个别事件可能缺 group_member_openid 而只有 user_openid,先记日志再兜底 clicker = inter.group_member_openid or inter.user_openid logger.info( f"按钮点击: chat_type={inter.chat_type} " f"点击者=…{(clicker or '?')[-6:]} data={inter.button_data[:32]!r}" ) if inter.chat_type != 1 or not inter.group_openid or not clicker: logger.warning( f"忽略非常规互动: chat_type={inter.chat_type}, " f"group={inter.group_openid}, clicker={clicker}" ) return parsed = captcha.parse_button_data(inter.button_data) if parsed is None: logger.warning(f"按钮数据无法解析,已忽略: {inter.button_data!r}") return token, value = parsed key = pending_key(inter.group_openid, clicker) 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, clicker, event_id=event.event_id, ) return if record is None: if state.is_verified_recently(key, cfg.verified_trust_days): logger.info(f"已通过成员点击旧按钮,礼貌回复 (…{clicker[-6:]})") await _reply_text(api, inter.group_openid, "你已经通过验证啦,不用再答题~", event_id=event.event_id) else: # 旧题/已失效按钮,礼貌忽略不计处罚 logger.info(f"点击已失效按钮,礼貌回复 (…{clicker[-6:]})") await _reply_text(api, inter.group_openid, "这道题不是给你的~", event_id=event.event_id, mention=clicker) return if not is_subject: logger.info(f"被试者点击自己的旧题,提示换题 (…{clicker[-6:]})") 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, clicker, 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"成员 …{clicker[-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"换题发送失败 (…{clicker[-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