新增 QQ Bot 渠道扩展,支持在 Yuxi 平台中集成 QQ 机器人渠道。 包含以下功能模块: - api_client: QQ API 客户端封装 - api_routes: API 路由管理 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - websocket: WebSocket 实时连接 - credentials: 凭证管理 - token: Token 管理 - outbound: 外发消息管理 - outbound_media: 媒体外发 - streaming: 流式消息处理 - streaming_media: 媒体流处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session: 会话管理 - pipeline: 消息管道 - pipeline_stages: 管道阶段 - commands: 指令处理 - commands_builtin: 内置指令 - interaction: 交互处理 - approval: 审批流程 - ark: ARK 消息 - audio: 音频处理 - media: 媒体资源 - media_chunked: 分块媒体 - media_tags: 媒体标签 - message_queue: 消息队列 - delivery: 消息送达确认 - reconnect: 重连机制 - typing_keepalive: 输入状态保活 - group_activation: 群激活 - group_gating: 群门控 - group_history: 群历史 - known_users: 已知用户 - ref_index: 引用索引 - tools: Agent 工具集成 - types: 类型定义
99 lines
3.5 KiB
Python
99 lines
3.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import random
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.qqbot.errors import QQBotError, QQBotErrorCode
|
|
from yuxi.channel.extensions.qqbot.api_routes import API_BASE_URL, TOKEN_URL
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TOKEN_REFRESH_MARGIN = 300
|
|
BACKGROUND_REFRESH_INTERVAL = 3600
|
|
RANDOM_OFFSET_MAX = 600
|
|
|
|
|
|
class TokenManager:
|
|
def __init__(self, app_id: str, client_secret: str):
|
|
self._app_id = app_id
|
|
self._client_secret = client_secret
|
|
self._token: str | None = None
|
|
self._expires_at: float = 0.0
|
|
self._lock = asyncio.Lock()
|
|
self._refresh_task: asyncio.Task | None = None
|
|
self._http_client: httpx.AsyncClient | None = None
|
|
|
|
@property
|
|
def app_id(self) -> str:
|
|
return self._app_id
|
|
|
|
def _get_client(self) -> httpx.AsyncClient:
|
|
if self._http_client is None:
|
|
self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(15.0))
|
|
return self._http_client
|
|
|
|
async def get_token(self) -> str:
|
|
async with self._lock:
|
|
if self._is_expired():
|
|
await self._refresh()
|
|
if self._token is None:
|
|
raise QQBotError(QQBotErrorCode.AUTH_FAILED, "Failed to obtain access token")
|
|
return self._token
|
|
|
|
async def _refresh(self) -> None:
|
|
client = self._get_client()
|
|
try:
|
|
resp = await client.post(
|
|
TOKEN_URL,
|
|
json={"appId": self._app_id, "clientSecret": self._client_secret},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
self._token = data.get("access_token")
|
|
expires_in = data.get("expires_in", 7200)
|
|
self._expires_at = time.time() + expires_in - TOKEN_REFRESH_MARGIN
|
|
logger.debug("Token refreshed for app_id=%s, expires_in=%d", self._app_id, expires_in)
|
|
except httpx.HTTPError as e:
|
|
raise QQBotError(QQBotErrorCode.AUTH_FAILED, f"Token refresh failed: {e}", retryable=True) from e
|
|
|
|
def _is_expired(self) -> bool:
|
|
return self._token is None or time.time() >= self._expires_at
|
|
|
|
def invalidate(self) -> None:
|
|
self._token = None
|
|
self._expires_at = 0.0
|
|
logger.debug("Token invalidated for app_id=%s", self._app_id)
|
|
|
|
async def start_background_refresh(self) -> None:
|
|
if self._refresh_task and not self._refresh_task.done():
|
|
return
|
|
self._refresh_task = asyncio.create_task(self._background_refresh_loop(), name=f"token-refresh-{self._app_id}")
|
|
|
|
async def stop_background_refresh(self) -> None:
|
|
if self._refresh_task and not self._refresh_task.done():
|
|
self._refresh_task.cancel()
|
|
try:
|
|
await self._refresh_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._refresh_task = None
|
|
|
|
async def _background_refresh_loop(self) -> None:
|
|
while True:
|
|
offset = random.uniform(0, RANDOM_OFFSET_MAX)
|
|
await asyncio.sleep(BACKGROUND_REFRESH_INTERVAL + offset)
|
|
try:
|
|
async with self._lock:
|
|
await self._refresh()
|
|
except Exception:
|
|
logger.exception("Background token refresh failed for app_id=%s", self._app_id)
|
|
|
|
async def close(self) -> None:
|
|
await self.stop_background_refresh()
|
|
if self._http_client:
|
|
await self._http_client.aclose()
|
|
self._http_client = None |