"""INTERACTION_CREATE 按钮回调流程测试(FakeApi 注入,无网络)。 覆盖:本人答对通过、非本人点击计数与处罚、旧题点击不处罚。 """ import asyncio import os import time from qqbotsdk import Bot from qqbotsdk.api import BotApi from authbot import captcha from authbot.config import AuthConfig from authbot.handlers import setup from authbot.state import PendingRecord, pending_key class FakeApi(BotApi): def __init__(self): # noqa: D107 - 跳过 BaseApi.__init__(不需要其组件) self.group_messages: list[dict] = [] self.raw_posts: list[tuple[str, dict | None]] = [] self.interactions_responded: list[str] = [] self.mute_effective = True self._muted: dict[str, str] = {} async def post_group_message( self, group_openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, markdown=None, keyboard=None, **extra, ): self.group_messages.append({ "group_openid": group_openid, "content": content, "msg_id": msg_id, "event_id": event_id, "markdown": markdown, "keyboard": keyboard, }) return {"id": "fake-msg"} async def post(self, path, **kwargs): self.raw_posts.append((path, kwargs.get("json"))) return {} async def mute_group_members(self, group_openid, members): self.raw_posts.append((f"/v2/groups/{group_openid}/restrict_chat_setting", {"members": members})) for entry in members: if entry.get("op") == "del": self._muted.pop(entry.get("member_openid"), None) else: self._muted[entry.get("member_openid")] = entry.get("mute_expire_at") return {} async def get_group_mute_state(self, group_openid): members = [ {"member_openid": m, "mute_expire_at": exp} for m, exp in self._muted.items() ] if self.mute_effective else [] return {"global_rule": {"mode": "none"}, "members": members} async def get(self, path, **kwargs): return {} async def respond_interaction(self, interaction_id, code=0): self.interactions_responded.append(interaction_id) return {} def make_bot() -> Bot: os.environ.setdefault("APPID", "test-appid") os.environ.setdefault("APPSECRET", "test-secret") return Bot(intents=0) def interaction_payload(token, value, member, group="GROUP1", seq=1): return { "op": 0, "id": f"INTERACTION_CREATE:evt-{seq}", "t": "INTERACTION_CREATE", "d": { "id": f"inner-uuid-{seq}", "type": 11, "chat_type": 1, "group_openid": group, "group_member_openid": member, "data": {"resolved": {"button_data": captcha.encode_button_data(token, value)}}, }, } def test_subject_correct_answer_passes(tmp_path, monkeypatch): async def scenario(): monkeypatch.setenv("DATA_DIR", str(tmp_path)) bot = make_bot() cfg = AuthConfig.load() state = setup(bot, cfg) fake = FakeApi() bot.services[BotApi] = fake key = pending_key("GROUP1", "MEMBER_A") state.put(PendingRecord( group_openid="GROUP1", member_openid="MEMBER_A", deadline=time.time() + 600, token="subj", answer=7, created_at=time.time(), )) await bot._emitter.emit(interaction_payload("subj", 7, "MEMBER_A", seq=1)) await asyncio.sleep(0.05) # 解禁(op=del)+ 通过播报 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"] == "del" assert entry["member_openid"] == "MEMBER_A" assert "验证通过" in (fake.group_messages[-1]["content"] or "") assert state.get(key) is None # pending 已清除 assert state.is_verified_recently(key, 99) asyncio.run(scenario()) def test_foreign_clicker_muted_on_third_click(tmp_path, monkeypatch): async def scenario(): monkeypatch.setenv("DATA_DIR", str(tmp_path)) bot = make_bot() cfg = AuthConfig.load() state = setup(bot, cfg) fake = FakeApi() bot.services[BotApi] = fake # MEMBER_A 的待验证题 state.put(PendingRecord( group_openid="GROUP1", member_openid="MEMBER_A", deadline=time.time() + 600, token="subj", answer=7, created_at=time.time(), )) # 旁观者 MEMBER_B 连点 3 次别人的题 for seq in range(1, 4): await bot._emitter.emit(interaction_payload("subj", 9, "MEMBER_B", seq=seq)) await asyncio.sleep(0.03) # 每次点击都回应了互动 assert len(fake.interactions_responded) == 3 # 前两次只有警告(markdown 内联提及点击者),第三次才禁言 5 分钟 warns = [(m["markdown"] or "") for m in fake.group_messages[:2]] assert all("不是给你的" in md for md in warns) assert all(f"<@MEMBER_B>" in md for md in warns) 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_B" assert "已禁言 5 分钟" in (fake.group_messages[-1]["markdown"] or "") assert "<@MEMBER_B>" in (fake.group_messages[-1]["markdown"] or "") # 被试者的记录不受影响 assert state.get(pending_key("GROUP1", "MEMBER_A")) is not None state.pop(pending_key("GROUP1", "MEMBER_A")) asyncio.run(scenario()) def test_stale_button_click_not_punished(tmp_path, monkeypatch): async def scenario(): monkeypatch.setenv("DATA_DIR", str(tmp_path)) bot = make_bot() cfg = AuthConfig.load() state = setup(bot, cfg) fake = FakeApi() bot.services[BotApi] = fake # 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) asyncio.run(scenario())