ForcePilot/backend/package/yuxi/channels/adapters/qqbot/setup_wizard.py
Kris ef5483dc1a refactor(qqbot): 重构QQ机器人适配器代码,优化多项功能与结构
主要变更:
1. 修复速率限流器使用setdefault替代重复创建令牌桶
2. 重构交互注册表匹配逻辑,优化精确匹配查找
3. 重构去重缓存逻辑,移到适配器实例方法
4. 重构发送URL解析,增加合法性校验并拆分公共方法
5. 优化流式消息处理逻辑,简化flush_controller调用
6. 重构群聊类型判断代码,简化语法
7. 修复重连管理器对None类型关闭分类的处理
8. 新增消息缓存、线程模拟器、发送初始化模块
9. 重构凭证备份与会话存储逻辑,支持环境变量指定状态目录
10. 新增配置提示与向导二维码绑定功能
11. 优化媒体上传逻辑,增加重试机制与缓存
12. 新增审批键盘模板构建函数
13. 重构消息格式处理,修正媒体发送字段与长度限制
14. 修复令牌过期时间计算,使用time.time替代monotonic
15. 新增群组激活缓冲区与用户追踪器增强功能
16. 修复换行符问题,统一文件结尾格式
2026-05-13 16:13:48 +08:00

263 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import json
import logging
import os
import re
import time
from dataclasses import dataclass
from enum import Enum, auto
logger = logging.getLogger(__name__)
class WizardStep(Enum):
WELCOME = auto()
SETUP_METHOD = auto()
APP_CREDENTIALS = auto()
QR_LINK = auto()
INTENTS = auto()
PERMISSIONS = auto()
WEBHOOK_URL = auto()
TEST_CONNECT = auto()
CONFIRM = auto()
FINISH = auto()
@dataclass
class WizardState:
step: WizardStep = WizardStep.WELCOME
setup_method: str = ""
app_id: str = ""
app_secret: str = ""
bot_token: str = ""
intents: list[str] = None
webhook_url: str = ""
qr_url: str = ""
qr_session_id: str = ""
verify_result: dict | None = None
started_at: float = 0.0
def __post_init__(self):
if self.intents is None:
self.intents = []
if self.started_at == 0.0:
self.started_at = time.time()
def to_dict(self) -> dict:
return {
"step": self.step.name,
"app_id": self.app_id,
"intents": self.intents,
"webhook_url": self.webhook_url,
}
@classmethod
def from_dict(cls, data: dict) -> WizardState:
return cls(
step=WizardStep[data.get("step", "WELCOME")],
app_id=data.get("app_id", ""),
app_secret="",
intents=data.get("intents", []),
webhook_url=data.get("webhook_url", ""),
)
_WIZARD_WELCOME = """
=== QQ Bot 安装向导 ===
该向导将帮助你完成 QQ Bot 的初始配置。
请按以下步骤操作:
1. 前往 QQ 开放平台 (https://q.qq.com) 创建机器人应用
2. 获取 App ID 和 App Secret
3. 配置机器人的 Intents意图
4. 配置 Webhook 地址
输入 /setup start 开始配置。
""".strip()
_SETUP_GUIDE = """
配置说明:
- App ID: 在 QQ 开放平台「应用管理」页面获取
- App Secret: 在「开发设置」中生成,请注意保管
- Intents: 机器人需要订阅的事件类型,至少需要:
- PUBLIC_GUILD_MESSAGES (群聊消息)
- DIRECT_MESSAGE (私信消息)
- GUILD_MEMBERS (频道成员)
- INTERACTION (交互事件)
- Webhook URL: 接收 QQ 推送事件的回调地址
""".strip()
def validate_app_id(app_id: str) -> bool:
return bool(re.match(r"^\d{10,20}$", app_id))
def validate_app_secret(secret: str) -> bool:
return len(secret) >= 32
def validate_webhook_url(url: str) -> bool:
return bool(re.match(r"^https?://", url))
_DEFAULT_INTENTS = [
(0, "GUILDS", "频道事件"),
(1, "GUILD_MEMBERS", "频道成员事件"),
(12, "DIRECT_MESSAGE", "私信事件"),
(25, "INTERACTION", "交互事件"),
(26, "AUDIO_ACTION", "音频事件"),
(27, "PUBLIC_GUILD_MESSAGES", "公域消息事件"),
(28, "GROUP_AND_C2C_EVENT", "群聊和私聊事件"),
]
def get_default_intents() -> list[int]:
return [intent[0] for intent in _DEFAULT_INTENTS]
def get_intent_descriptions() -> dict[int, tuple[str, str]]:
return {intent[0]: (intent[1], intent[2]) for intent in _DEFAULT_INTENTS}
async def test_connection(app_id: str, app_secret: str) -> dict:
import aiohttp
result = {"success": False, "error": "", "bot_info": {}}
try:
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.sgroup.qq.com/oauth2/token",
json={"app_id": app_id, "app_secret": app_secret},
) as resp:
if resp.status != 200:
result["error"] = f"获取 Token 失败: HTTP {resp.status}"
return result
data = await resp.json()
token = data.get("access_token", "")
if not token:
result["error"] = "Token 为空,请检查 App ID 和 App Secret"
return result
async with session.get(
"https://api.sgroup.qq.com/users/@me",
headers={"Authorization": f"QQBot {token}"},
) as resp:
if resp.status == 200:
user_data = await resp.json()
result["bot_info"] = {
"id": user_data.get("id", ""),
"username": user_data.get("username", ""),
"avatar": user_data.get("avatar", ""),
}
async with session.get(
"https://api.sgroup.qq.com/gateway/bot",
headers={"Authorization": f"QQBot {token}"},
) as resp:
if resp.status == 200:
gw_data = await resp.json()
result["gateway_url"] = gw_data.get("url", "")
result["success"] = True
except Exception as e:
result["error"] = str(e)
return result
async def setup_from_env() -> dict:
env_config = {
"app_id": os.environ.get("QQBOT_APP_ID", ""),
"app_secret": os.environ.get("QQBOT_CLIENT_SECRET", ""),
"bot_token": os.environ.get("QQBOT_BOT_TOKEN", ""),
"intents": os.environ.get("QQBOT_INTENTS", ""),
"webhook_url": os.environ.get("QQBOT_WEBHOOK_URL", ""),
}
if not env_config["app_id"] or not env_config["app_secret"]:
return {"success": False, "error": "环境变量未配置。请设置 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET"}
if not validate_app_id(env_config["app_id"]):
return {"success": False, "error": f"App ID 格式无效: {env_config['app_id']}"}
if not validate_app_secret(env_config["app_secret"]):
return {"success": False, "error": "App Secret 长度不足(需要至少 32 字符)"}
result = await test_connection(env_config["app_id"], env_config["app_secret"])
return result
async def generate_config_yaml(app_id: str, app_secret: str, intents: list[int] | None = None) -> str:
intent_values = intents or get_default_intents()
lines = [
"# QQ Bot 配置文件",
f"qqbot_app_id: {app_id}",
"# qqbot_client_secret: 请通过环境变量 QQBOT_CLIENT_SECRET 设置",
f"qqbot_intents: {json.dumps(intent_values)}",
"",
"# 推荐通过环境变量配置敏感信息",
"# export QQBOT_APP_ID={app_id}",
"# export QQBOT_CLIENT_SECRET=your_secret_here",
"",
"# Intents 说明:",
]
for intent_id, (name, desc) in get_intent_descriptions().items():
mark = "" if intent_id in intent_values else ""
lines.append(f"# {mark} {intent_id}: {name} ({desc})")
return "\n".join(lines)
async def link_via_qr_code(app_id: str) -> dict:
import uuid
session_id = uuid.uuid4().hex[:16]
api_base = "https://api.sgroup.qq.com"
qr_url = f"https://q.qq.com/qqbot/link?app_id={app_id}&session_id={session_id}&t={int(time.time())}"
return {
"success": True,
"session_id": session_id,
"qr_url": qr_url,
"message": "请使用 QQ 扫描二维码完成 Bot 绑定",
"api_base": api_base,
"poll_url": f"{api_base}/oauth2/qr_poll?session_id={session_id}",
}
async def check_qr_link_status(session_id: str, app_id: str) -> dict:
import aiohttp
api_base = "https://api.sgroup.qq.com"
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{api_base}/oauth2/qr_poll",
params={"session_id": session_id, "app_id": app_id},
) as resp:
if resp.status == 200:
data = await resp.json()
status = data.get("status", "pending")
if status == "confirmed":
return {
"success": True,
"status": "confirmed",
"app_id": app_id,
"data": data,
}
elif status == "expired":
return {"success": False, "status": "expired", "error": "QR code expired"}
return {"success": True, "status": "pending"}
return {"success": False, "error": f"HTTP {resp.status}"}
except Exception as e:
return {"success": False, "error": str(e)}