初始提交:QQ 群入群验证机器人
基于 qqbotsdk-py 的入群验证:进群发按钮验证码、完成前禁言、 超时踢出(11253 时兜底长期禁言)、乱点他人按钮处罚、状态持久化 与重启恢复。38 个单元/集成测试。
This commit is contained in:
commit
755013b9db
17 files changed
+1864
No files matched your search
@@ -0,0 +1,223 @@
|
||||
"""@机器人 指令的端到端装配测试(FakeApi 注入,不发起网络请求)。"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from qqbotsdk import Bot
|
||||
from qqbotsdk.api import BotApi
|
||||
|
||||
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.mute_effective = True # 置 False 模拟禁言被服务端静默忽略
|
||||
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 {}
|
||||
|
||||
|
||||
def make_bot() -> Bot:
|
||||
os.environ.setdefault("APPID", "test-appid")
|
||||
os.environ.setdefault("APPSECRET", "test-secret")
|
||||
return Bot(intents=0)
|
||||
|
||||
|
||||
def at_message_payload(content, member="MEMBER123456", msg_id="m1", group="GROUP1"):
|
||||
return {
|
||||
"op": 0,
|
||||
"id": msg_id,
|
||||
"t": "GROUP_AT_MESSAGE_CREATE",
|
||||
"d": {
|
||||
"id": msg_id,
|
||||
"content": content,
|
||||
"group_openid": group,
|
||||
"author": {"member_openid": member, "union_openid": ""},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def group_message_payload(content, member="MEMBER123456", msg_id="g1", group="GROUP1"):
|
||||
"""全量消息模式(GROUP_MESSAGE_CREATE),字段与 @ 消息一致。"""
|
||||
payload = at_message_payload(content, member, msg_id, group)
|
||||
payload["t"] = "GROUP_MESSAGE_CREATE"
|
||||
return payload
|
||||
|
||||
|
||||
def test_command_works_in_all_message_mode(tmp_path, monkeypatch):
|
||||
"""平台"接收全部消息"模式下,指令经 GROUP_MESSAGE_CREATE 到达也能触发。"""
|
||||
|
||||
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
|
||||
|
||||
await bot._emitter.emit(group_message_payload(" @AuthBot 验证状态"))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert len(fake.group_messages) == 1
|
||||
assert "没有待验证" in (fake.group_messages[0]["content"] or "")
|
||||
assert state.all_pending() == []
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_verify_me_command_runs_full_flow(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 # handler 按类型标注从 services 拿到替身
|
||||
|
||||
await bot._emitter.emit(at_message_payload(" 验证我"))
|
||||
await asyncio.sleep(0.05) # background handler 异步执行
|
||||
|
||||
# 发出了带按钮的验证码,且是对指令消息的被动回复(msg_id 凭据)
|
||||
assert len(fake.group_messages) == 1
|
||||
sent = fake.group_messages[0]
|
||||
assert sent["msg_id"] == "m1"
|
||||
assert sent["markdown"] and "验证测试" in sent["markdown"]
|
||||
assert sent["keyboard"]
|
||||
|
||||
# 对发起人本人按成员禁言(官方嵌套结构:members[].op=add)
|
||||
mute_calls = [j for p, j in fake.raw_posts if p.endswith("/restrict_chat_setting")]
|
||||
assert len(mute_calls) == 1
|
||||
entry = mute_calls[0]["members"][0]
|
||||
assert entry["op"] == "add"
|
||||
assert entry["member_openid"] == "MEMBER123456"
|
||||
assert "mute_expire_at" in entry
|
||||
|
||||
# 进入 pending,按钮 data 里的 token 与记录一致
|
||||
key = pending_key("GROUP1", "MEMBER123456")
|
||||
record = state.get(key)
|
||||
assert record is not None
|
||||
btn = sent["keyboard"]["content"]["rows"][0]["buttons"][0]
|
||||
assert btn["action"]["data"].split("|")[1] == record.token
|
||||
|
||||
state.pop(key) # 清理,避免定时器任务悬挂到用例外
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_verify_me_keeps_original_deadline(tmp_path, monkeypatch):
|
||||
"""已在验证中再次发起测试:换题但倒计时不重置。"""
|
||||
|
||||
async def scenario():
|
||||
import time
|
||||
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
bot = make_bot()
|
||||
cfg = AuthConfig.load()
|
||||
state = setup(bot, cfg)
|
||||
bot.services[BotApi] = FakeApi()
|
||||
|
||||
key = pending_key("GROUP1", "MEMBER123456")
|
||||
old_deadline = time.time() + 123.0
|
||||
state.put(PendingRecord(
|
||||
group_openid="GROUP1", member_openid="MEMBER123456",
|
||||
deadline=old_deadline, token="old", answer=1, created_at=time.time(),
|
||||
))
|
||||
|
||||
await bot._emitter.emit(at_message_payload(" 测试验证", msg_id="m2"))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
record = state.get(key)
|
||||
assert record is not None
|
||||
assert record.deadline == old_deadline
|
||||
assert record.token != "old" # 题目已更换
|
||||
state.pop(key)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_verify_me_reports_ineffective_mute(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()
|
||||
fake.mute_effective = False
|
||||
bot.services[BotApi] = fake
|
||||
|
||||
await bot._emitter.emit(at_message_payload(" 验证我"))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# 验证码 + 一条"禁言未生效"提示
|
||||
assert len(fake.group_messages) == 2
|
||||
assert fake.group_messages[0]["markdown"]
|
||||
assert "禁言未能生效" in (fake.group_messages[1]["content"] or "")
|
||||
key = pending_key("GROUP1", "MEMBER123456")
|
||||
assert state.get(key) is not None # 验证流程仍然继续
|
||||
state.pop(key)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_status_command_no_side_effects(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
|
||||
|
||||
await bot._emitter.emit(at_message_payload("验证状态", msg_id="m3"))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert len(fake.group_messages) == 1
|
||||
assert "没有待验证" in (fake.group_messages[0]["content"] or "")
|
||||
assert fake.raw_posts == [] # 不触发禁言等写操作
|
||||
assert state.all_pending() == []
|
||||
|
||||
asyncio.run(scenario())
|
||||
Reference in new issue
Block a user