Files
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

294 lines
10 KiB
Python
Raw Permalink 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.
"""
QQ 开放平台令牌缓存、消息发送和网关地址查询。
接口参考:https://bot.q.qq.com/wiki/develop/api/
"""
import time
from typing import Any, Literal, Optional
from app.adapters.network.http import RequestUtils
from app.runtime.log import logger
API_BASE = "https://api.sgroup.qq.com"
TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"
# 进程仅保留一个应用的访问令牌与毫秒级过期时间。
_cached_token: Optional[dict[str, Any]] = None
def get_access_token(app_id: str, client_secret: str) -> str:
"""
获取 QQ 访问令牌,复用尚未进入刷新窗口的缓存。
进程只保留一个应用的令牌;到期前 5 分钟重新获取,切换应用会清除旧缓存。
请求或响应解析失败时记录错误并继续抛出异常。
:param app_id: 机器人应用标识,用于区分缓存归属
:param client_secret: 机器人应用密钥
:return: 有效访问令牌字符串
:raises ValueError: 响应为空、格式无效或缺少有效令牌
"""
global _cached_token
now_ms = int(time.time() * 1000)
if _cached_token and now_ms < _cached_token["expires_at"] - 5 * 60 * 1000 and _cached_token["app_id"] == app_id:
token = _cached_token.get("token")
if isinstance(token, str):
return token
_cached_token = None
if _cached_token and _cached_token["app_id"] != app_id:
_cached_token = None
try:
resp = RequestUtils(timeout=30).post_res(
TOKEN_URL,
# QQ API 要求此处使用驼峰命名的字段。
json={"appId": app_id, "clientSecret": client_secret},
headers={"Content-Type": "application/json"},
)
if not resp or not resp.json():
raise ValueError("Failed to get access_token: empty response")
data = resp.json()
if not isinstance(data, dict):
raise ValueError(f"Failed to get access_token: invalid response {data!r}")
token = data.get("access_token")
expires_in = data.get("expires_in", 7200)
if not token:
raise ValueError(f"Failed to get access_token: {data}")
# expires_in 可能为字符串,统一转为 int
expires_in = int(expires_in) if expires_in is not None else 7200
_cached_token = {
"token": token,
"expires_at": now_ms + expires_in * 1000,
"app_id": app_id,
}
logger.debug(f"QQ API: Token cached for app_id={app_id}")
if not isinstance(token, str):
raise ValueError("Invalid access_token response")
return token
except Exception as e:
logger.error(f"QQ API: get_access_token failed: {e}")
raise
def clear_token_cache() -> None:
"""
清除 Token 缓存。
"""
global _cached_token
_cached_token = None
def _api_request(
access_token: str,
method: str,
path: str,
body: Optional[dict[str, Any]] = None,
timeout: int = 30,
) -> dict[str, Any]:
"""
携带机器人令牌请求 QQ API,并要求返回对象响应。
:param access_token: 机器人访问令牌
:param method: GET 使用查询请求,其他值均使用 POST
:param path: 相对于 QQ API 根地址的接口路径
:param body: POST JSON 载荷;未提供时发送空对象
:param timeout: 请求超时时间,单位秒
:return: 原始响应对象
:raises ValueError: 响应为空、不是对象或 HTTP 状态表示失败
"""
url = f"{API_BASE}{path}"
headers = {
"Authorization": f"QQBot {access_token}",
"Content-Type": "application/json",
}
try:
if method.upper() == "GET":
resp = RequestUtils(timeout=timeout).get_res(url, headers=headers)
else:
resp = RequestUtils(timeout=timeout).post_res(
url, json=body or {}, headers=headers
)
if not resp:
raise ValueError("Empty response")
data = resp.json()
if not isinstance(data, dict):
raise ValueError(f"API Error [{path}]: invalid response {data!r}")
status = getattr(resp, "status_code", 0)
if status and status >= 400:
raise ValueError(f"API Error [{path}]: {data.get('message', data)}")
return data
except Exception as e:
logger.error(f"QQ API: {method} {path} failed: {e}")
raise
def send_proactive_c2c_message(
access_token: str,
openid: str,
content: str,
use_markdown: bool = False,
keyboard: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""
主动发送单聊消息。
配额和可发送对象由 QQ 平台校验;本地不记录配额,接口失败时抛出异常。
:param access_token: 机器人访问令牌
:param openid: 接收用户 openid
:param content: 待发送正文;主动发送时会去掉首尾空白
:param use_markdown: 是否使用已获授权的 Markdown 消息能力
:param keyboard: 可选按钮配置
:return: QQ API 返回的原始消息对象
:raises ValueError: 内容为空或请求响应无效
"""
if not content or not content.strip():
raise ValueError("主动消息内容不能为空")
content = content.strip()
body = {"markdown": {"content": content}, "msg_type": 2} if use_markdown else {"content": content, "msg_type": 0}
if keyboard:
body["keyboard"] = {"content": keyboard}
return _api_request(
access_token, "POST", f"/v2/users/{openid}/messages", body
)
def send_proactive_group_message(
access_token: str,
group_openid: str,
content: str,
use_markdown: bool = False,
keyboard: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""
主动发送群聊消息。
配额和可发送对象由 QQ 平台校验;本地不记录配额,接口失败时抛出异常。
:param access_token: 机器人访问令牌
:param group_openid: 接收群聊 openid
:param content: 待发送正文;主动发送时会去掉首尾空白
:param use_markdown: 是否使用已获授权的 Markdown 消息能力
:param keyboard: 可选按钮配置
:return: QQ API 返回的原始消息对象
:raises ValueError: 内容为空或请求响应无效
"""
if not content or not content.strip():
raise ValueError("主动消息内容不能为空")
content = content.strip()
body = {"markdown": {"content": content}, "msg_type": 2} if use_markdown else {"content": content, "msg_type": 0}
if keyboard:
body["keyboard"] = {"content": keyboard}
return _api_request(
access_token, "POST", f"/v2/groups/{group_openid}/messages", body
)
def send_c2c_message(
access_token: str,
openid: str,
content: str,
msg_id: Optional[str] = None,
keyboard: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""
被动回复单聊消息。
配额和可发送对象由 QQ 平台校验;本地不记录配额,接口失败时抛出异常。
当前固定使用 msg_seq=1;调用方仍需遵守平台的回复时限和次数限制。
:param access_token: 机器人访问令牌
:param openid: 接收用户 openid
:param content: 待发送正文;主动发送时会去掉首尾空白
:param msg_id: 原始消息标识;提供时随请求发送以关联被动回复
:param keyboard: 可选按钮配置
:return: QQ API 返回的原始消息对象
"""
body = {"content": content, "msg_type": 0, "msg_seq": 1}
if msg_id:
body["msg_id"] = msg_id
if keyboard:
body["keyboard"] = {"content": keyboard}
return _api_request(
access_token, "POST", f"/v2/users/{openid}/messages", body
)
def send_group_message(
access_token: str,
group_openid: str,
content: str,
msg_id: Optional[str] = None,
keyboard: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""
被动回复群聊消息。
配额和可发送对象由 QQ 平台校验;本地不记录配额,接口失败时抛出异常。
当前固定使用 msg_seq=1;调用方仍需遵守平台的回复时限和次数限制。
:param access_token: 机器人访问令牌
:param group_openid: 接收群聊 openid
:param content: 待发送正文;主动发送时会去掉首尾空白
:param msg_id: 原始消息标识;提供时随请求发送以关联被动回复
:param keyboard: 可选按钮配置
:return: QQ API 返回的原始消息对象
"""
body = {"content": content, "msg_type": 0, "msg_seq": 1}
if msg_id:
body["msg_id"] = msg_id
if keyboard:
body["keyboard"] = {"content": keyboard}
return _api_request(
access_token, "POST", f"/v2/groups/{group_openid}/messages", body
)
def get_gateway_url(access_token: str) -> str:
"""
查询用于接收入站消息的 WebSocket 网关地址。
:param access_token: 机器人访问令牌
:return: 服务端返回的非空网关地址
:raises ValueError: 请求失败或响应缺少有效网关地址
"""
data = _api_request(access_token, "GET", "/gateway")
url = data.get("url")
if not isinstance(url, str) or not url:
raise ValueError("Gateway URL not found in response")
return url
def send_message(
access_token: str,
target: str,
content: str,
msg_type: Literal["c2c", "group"] = "c2c",
msg_id: Optional[str] = None,
keyboard: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""
按是否携带原消息标识选择主动发送或被动回复接口。
内容、额度、权限或请求失败时由底层接口抛出异常;本函数不进行重试。
:param access_token: 机器人访问令牌
:param target: 单聊用户 openid 或群聊 group_openid
:param content: 待发送消息正文
:param msg_type: c2c 表示单聊,group 表示群聊
:param msg_id: 原始消息标识;非空时使用被动回复接口
:param keyboard: 可选按钮配置
:return: QQ API 返回的原始消息对象
"""
if msg_id:
if msg_type == "c2c":
return send_c2c_message(access_token, target, content, msg_id, keyboard)
return send_group_message(access_token, target, content, msg_id, keyboard)
if msg_type == "c2c":
return send_proactive_c2c_message(access_token, target, content, keyboard=keyboard)
return send_proactive_group_message(access_token, target, content, keyboard=keyboard)