From 1db8f1cc0eedf131a75a3e4dc1a6b4d4f0cf1adf Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:46:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=B1=E6=95=88=E6=8C=89=E9=92=AE=E4=B9=B1?= =?UTF-8?q?=E7=82=B9=E5=90=8C=E6=A0=B7=E8=AE=A1=E6=95=B0=E5=A4=84=E7=BD=9A?= =?UTF-8?q?=EF=BC=9B=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +++-- authbot/handlers.py | 14 +++++++------ tests/test_interaction.py | 43 ++++++++++++++++++++++++++++++++++----- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5b1108f..9be4658 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ - 验证前禁言:按 `member_openid` 单独禁言(不碰全员禁言),通过后立即解禁 - 超时踢出:默认 10 分钟;踢人失败(业务码 11253)自动兜底为禁言 72 小时 + 群内提示管理员 - 旧题防重放:按钮 data 携带题目 token,换题后旧按钮点击无效 -- 反捣乱:非被试者点击他人的验证按钮,30 分钟窗口内累计 3 次即禁言 5 分钟 - (次数与时长可配:`FOREIGN_CLICK_LIMIT` / `FOREIGN_CLICK_MUTE_MINUTES`) +- 反捣乱:点击**非本人的验证按钮**(他人的当前题或已失效的残留按钮),30 分钟窗口内 + 累计 3 次即禁言 5 分钟,警告会 @ 点击者(次数与时长可配:`FOREIGN_CLICK_LIMIT` / + `FOREIGN_CLICK_MUTE_MINUTES`) - 状态持久化:`data/` 下 JSON 存储,进程重启自动恢复禁言与倒计时(已过期立即处理) - 轻量管理指令:群里 `@机器人 验证状态` 查看待验证名单与剩余时间; `@机器人 验证我`(或 `测试验证`)对发起人本人走一遍完整验证流程,方便上线前自测 diff --git a/authbot/handlers.py b/authbot/handlers.py index 2522d57..5ef6ed0 100644 --- a/authbot/handlers.py +++ b/authbot/handlers.py @@ -5,7 +5,8 @@ 发送失败(通常是 markdown/按钮权限未开通)则不禁言,避免把成员 永久锁死,同时记日志提示排查方向; - INTERACTION_CREATE → 回应互动 → 校验点击者/题目 → 答对解禁+记录, - 答错换题(倒计时不重置),非本人或旧题点击礼貌忽略; + 答错换题(倒计时不重置);非本人点击(他人的当前题或已失效的残留 + 按钮)一律计入乱点处罚,达到次数禁言; - 超时 → 尝试移出群(官方"内邀"能力,未开通时返回 11253)→ 失败则 延长禁言兜底并通知管理员; - GROUP_MEMBER_REMOVE → 清理状态;@机器人 可查"验证状态"。 @@ -379,11 +380,12 @@ def setup(bot: Bot, cfg: AuthConfig) -> VerifyState: 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) + # 失效/他人残留按钮的乱点同样计数处罚(不静默、不放过) + logger.info(f"点击已失效按钮,计入乱点 (…{clicker[-6:]})") + await _handle_foreign_click( + api, cfg, foreign_clicks, inter.group_openid, clicker, + event_id=event.event_id, + ) return if not is_subject: logger.info(f"被试者点击自己的旧题,提示换题 (…{clicker[-6:]})") diff --git a/tests/test_interaction.py b/tests/test_interaction.py index e86f49a..9ae9e98 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -160,7 +160,9 @@ def test_foreign_clicker_muted_on_third_click(tmp_path, monkeypatch): asyncio.run(scenario()) -def test_stale_button_click_not_punished(tmp_path, monkeypatch): +def test_stale_button_clicks_counted_as_foreign(tmp_path, monkeypatch): + """点已失效的残留按钮同样计入乱点处罚:3 次后禁言。""" + async def scenario(): monkeypatch.setenv("DATA_DIR", str(tmp_path)) bot = make_bot() @@ -169,13 +171,44 @@ def test_stale_button_click_not_punished(tmp_path, monkeypatch): fake = FakeApi() bot.services[BotApi] = fake - # token 不属于任何在途题目(旧题残留按钮) + # token 不属于任何在途题目(验证通过后残留的按钮) for seq in range(1, 4): await bot._emitter.emit(interaction_payload("ghost", 1, "MEMBER_C", seq=seq)) await asyncio.sleep(0.03) - assert fake.raw_posts == [] # 三次都不触发禁言 - assert all("不是给你的" in (m["markdown"] or "") for m in fake.group_messages) - assert all("<@MEMBER_C>" in (m["markdown"] or "") for m in fake.group_messages) + # 前两次带计数的警告(@点击者),第三次禁言 + warns = [(m["markdown"] or "") for m in fake.group_messages[:2]] + assert all("不是给你的" in md and "<@MEMBER_C>" in md for md in warns) + assert "再乱点" in warns[0] and "再乱点 1 次" in warns[1] + mutes = [j for p, j in fake.raw_posts if p.endswith("/restrict_chat_setting")] + assert len(mutes) == 1 + entry = mutes[0]["members"][0] + assert entry["op"] == "add" + assert entry["member_openid"] == "MEMBER_C" + + asyncio.run(scenario()) + + +def test_verified_member_clicking_stale_button_not_punished(tmp_path, monkeypatch): + """刚通过验证的成员点自己残留的旧按钮:礼貌回复,不计处罚。""" + + async def scenario(): + import time as _time + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setenv("VERIFIED_TRUST_DAYS", "3") # 开启信任期,已通过成员才免计 + bot = make_bot() + cfg = AuthConfig.load() + state = setup(bot, cfg) + fake = FakeApi() + bot.services[BotApi] = fake + + key = pending_key("GROUP1", "MEMBER_C") + state.mark_verified(key) # trust 窗口内 + await bot._emitter.emit(interaction_payload("ghost", 1, "MEMBER_C", seq=1)) + await asyncio.sleep(0.05) + + assert fake.raw_posts == [] # 不触发禁言 + assert "已经通过验证" in (fake.group_messages[-1]["content"] or "") asyncio.run(scenario())