ForcePilot/backend/package/yuxi/channels/adapters/yuanbao/token.py
Kris eb25707668 feat(yuanbao): 新增元宝渠道适配器完整实现
新增元宝(Yuanbao)渠道的完整适配器实现,包含以下核心模块:
- 基础适配器与导出入口
- 协议编解码与WebSocket帧处理
- 会话管理与路由逻辑
- 事件队列与出站消息队列
- 消息格式转换与发送重试
- 安全审计与权限校验
- 配置映射与账户管理
- 视觉分析与工具函数
- 文档生成与设置向导
2026-05-12 00:52:20 +08:00

165 lines
5.5 KiB
Python

from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import time
import aiohttp
from yuxi.channels.exceptions import ChannelAuthenticationError
from yuxi.utils.logging_config import logger
class YuanbaoTokenManager:
REFRESH_WINDOW_SECONDS = 300
_local_cache: dict[str, str] = {}
_local_cache_lock = asyncio.Lock()
def __init__(
self,
app_key: str,
app_secret: str,
bot_app_id: str,
pre_signed_token: str | None = None,
api_base: str | None = None,
):
self._app_key = app_key
self._app_secret = app_secret
self._bot_app_id = bot_app_id
self._pre_signed_token = pre_signed_token
self._api_base = api_base or "https://open-api.yuanbao.tencent.com"
self._access_token: str | None = pre_signed_token
self._expires_at: float | None = None
self._token_lock = asyncio.Lock()
@property
def api_base(self) -> str:
return self._api_base
@property
def bot_app_id(self) -> str:
return self._bot_app_id
def _sign_request(self, timestamp: int) -> str:
message = f"{self._app_key}{timestamp}"
return hmac.new(
self._app_secret.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
async def get_token(self) -> str:
if self._pre_signed_token:
return self._pre_signed_token
async with self._token_lock:
if self._is_expired():
await self._refresh()
return self._access_token
def is_expired(self) -> bool:
if self._access_token is None or self._expires_at is None:
return True
return time.time() > self._expires_at - self.REFRESH_WINDOW_SECONDS
_is_expired = is_expired
async def refresh_token(self) -> str:
redis_key = f"yuanbao:token:{self._bot_app_id}"
try:
from yuxi.storage.redis import get_redis
redis = get_redis()
await redis.delete(redis_key)
except Exception:
logger.warning(f"[Yuanbao] Redis unavailable, skipping cache delete for {redis_key}")
return await self.get_token()
async def _refresh(self) -> None:
if self._pre_signed_token:
return
redis_key = f"yuanbao:token:{self._bot_app_id}"
cached_from_redis = False
try:
from yuxi.storage.redis import get_redis
redis = get_redis()
cached = await redis.get(redis_key)
if cached:
token_data = json.loads(cached)
expires_at = token_data["expires_at"]
if time.time() < expires_at - self.REFRESH_WINDOW_SECONDS:
self._access_token = token_data["access_token"]
self._expires_at = expires_at
cached_from_redis = True
return
except Exception:
logger.warning(f"[Yuanbao] Redis unavailable for read, checking local cache ({redis_key})")
if not cached_from_redis:
local_token = self._load_from_local_cache()
if local_token is not None:
return
timestamp = int(time.time())
signature = self._sign_request(timestamp)
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self._api_base}/api/auth/token",
json={
"app_key": self._app_key,
"timestamp": timestamp,
"signature": signature,
},
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
raise ChannelAuthenticationError(f"Token refresh failed: HTTP {resp.status} {await resp.text()}")
data = await resp.json()
self._access_token = data["access_token"]
expires_in = data.get("expires_in", 7200)
self._expires_at = time.time() + expires_in
self._save_to_local_cache(self._access_token)
try:
from yuxi.storage.redis import get_redis
redis = get_redis()
await redis.set(
redis_key,
json.dumps(
{
"access_token": self._access_token,
"expires_at": self._expires_at,
"obtained_at": time.time(),
}
),
ex=expires_in,
)
except Exception:
logger.warning(f"[Yuanbao] Redis unavailable for write, relying on local cache ({redis_key})")
logger.info(f"[Yuanbao] Token refreshed, expires in {expires_in}s (bot_app_id={self._bot_app_id})")
async def _load_from_local_cache(self) -> bool:
async with self._local_cache_lock:
entry = self._local_cache.get(self._bot_app_id)
if entry is None:
return False
access_token, expires_at = entry
if time.time() < expires_at - self.REFRESH_WINDOW_SECONDS:
self._access_token = access_token
self._expires_at = expires_at
logger.info(
f"[Yuanbao] Token loaded from local cache (Redis unavailable) (bot_app_id={self._bot_app_id})"
)
return True
return False
def _save_to_local_cache(self, token: str) -> None:
self._local_cache[self._bot_app_id] = (token, self._expires_at)