基于 qqbotsdk-py 的入群验证:进群发按钮验证码、完成前禁言、 超时踢出(11253 时兜底长期禁言)、乱点他人按钮处罚、状态持久化 与重启恢复。38 个单元/集成测试。
452 lines
19 KiB
Python
452 lines
19 KiB
Python
"""事件处理器:进群验证主流程。
|
||
|
||
流程(与 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
|