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

452 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""事件处理器:进群验证主流程。
流程(与 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