基于 qqbotsdk-py 的入群验证:进群发按钮验证码、完成前禁言、 超时踢出(11253 时兜底长期禁言)、乱点他人按钮处罚、状态持久化 与重启恢复。38 个单元/集成测试。
103 lines
3.1 KiB
Python
103 lines
3.1 KiB
Python
"""数学选择题验证码:题目生成、选项按钮组装与按钮数据编解码。
|
||
|
||
按钮 data 格式为 ``v|<token>|<选项值>``:token 标识"当前这道题",
|
||
答错换题后旧消息上的按钮因 token 不匹配而失效,防止回放旧题。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
from dataclasses import dataclass
|
||
from uuid import uuid4
|
||
|
||
from qqbotsdk.api import button, keyboard
|
||
|
||
_BUTTON_PREFIX = "v"
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class Captcha:
|
||
"""一道验证码;a/op/b 保留运算数便于测试与排查。"""
|
||
|
||
question: str
|
||
answer: int
|
||
options: list[int]
|
||
token: str
|
||
a: int
|
||
op: str
|
||
b: int
|
||
|
||
def body(self) -> str:
|
||
"""题面正文(不含欢迎/提示头),换题重发时复用。"""
|
||
return f"### {self.a} {self.op} {self.b} = ?"
|
||
|
||
def keyboard(self) -> dict:
|
||
"""选项按钮键盘,每行 2 个。"""
|
||
rows = [
|
||
[button(str(v), encode_button_data(self.token, v)) for v in self.options[i : i + 2]]
|
||
for i in range(0, len(self.options), 2)
|
||
]
|
||
return keyboard(*rows)
|
||
|
||
|
||
def encode_button_data(token: str, value: int) -> str:
|
||
return f"{_BUTTON_PREFIX}|{token}|{value}"
|
||
|
||
|
||
def parse_button_data(data: str) -> tuple[str, int] | None:
|
||
"""解析按钮 data 为 (token, 选项值);非本机器人按钮返回 None。"""
|
||
parts = data.split("|")
|
||
if len(parts) != 3 or parts[0] != _BUTTON_PREFIX:
|
||
return None
|
||
try:
|
||
return parts[1], int(parts[2])
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def welcome_markdown(cap: Captcha, timeout_minutes: int, subject: str = "新同学") -> str:
|
||
"""进群首题/测试指令消息(markdown + 按钮一起发);subject 为称呼前缀。"""
|
||
return (
|
||
"**入群验证**\n"
|
||
f"{subject}请在 **{timeout_minutes} 分钟内** 点击下方正确答案完成验证,"
|
||
"超时未通过将被移出本群。\n\n" + cap.body()
|
||
)
|
||
|
||
|
||
def retry_markdown(cap: Captcha) -> str:
|
||
"""答错后换题重发的消息。"""
|
||
return "答错了,换一题再试试:\n\n" + cap.body()
|
||
|
||
|
||
def generate(option_count: int = 4) -> Captcha:
|
||
"""随机生成一道加减/乘法选择题;干扰项取答案邻域且互不重复。"""
|
||
op = random.choice(("+", "-", "×"))
|
||
match op:
|
||
case "+":
|
||
a, b = random.randint(1, 20), random.randint(1, 20)
|
||
case "-":
|
||
a = random.randint(5, 30)
|
||
b = random.randint(1, a) # 保证差非负
|
||
case _:
|
||
a, b = random.randint(2, 9), random.randint(2, 9)
|
||
answer = a + b if op == "+" else a - b if op == "-" else a * b
|
||
|
||
lo = max(0, answer - 12)
|
||
distractors: set[int] = set()
|
||
while len(distractors) < option_count - 1:
|
||
cand = random.randint(lo, answer + 12)
|
||
if cand != answer:
|
||
distractors.add(cand)
|
||
options = [answer, *distractors]
|
||
random.shuffle(options)
|
||
|
||
return Captcha(
|
||
question=f"{a} {op} {b}",
|
||
answer=answer,
|
||
options=options,
|
||
token=uuid4().hex[:12],
|
||
a=a,
|
||
op=op,
|
||
b=b,
|
||
)
|