ForcePilot/backend/package/yuxi/channels/adapters/dingding/token.py
Kris 4347d3f937 chore: 批量优化多适配器代码与新增配置支持
本次提交包含多项改进:
1. 修复钉钉、WhatsApp、Telegram等适配器的线程动作映射名称
2. 为SynologyChat、iMessage、Urbit等多款适配器新增配置Schema
3. 优化日志输出格式,合并多行日志调用为单行
4. 修复指数退避计算中的空格问题
5. 为QQBot凭证备份模块添加弃用警告
6. 新增多款适配器的凭证持久化存储逻辑
7. 优化Matrix、Nostr、DingDing等适配器的状态存储实现
8. 完善Discord、Slack、Signal等适配器的动作注册逻辑
9. 优化WhatsApp桥接器的QR码获取逻辑
10. 修复IRC适配器的配置比对与重连逻辑
2026-05-14 02:06:59 +08:00

116 lines
4.0 KiB
Python

from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING, Any
import httpx
from yuxi.utils.logging_config import logger
TOKEN_REFRESH_MARGIN_S = 200
TOKEN_VALIDITY_S = 7200
if TYPE_CHECKING:
from yuxi.channels.services.plugin_state_store import PluginStateStore
class DingDingTokenManager:
def __init__(
self,
app_key: str,
app_secret: str,
http_client: httpx.AsyncClient | None = None,
state_store: PluginStateStore | None = None,
channel_id: str = "dingding",
):
self._app_key = app_key
self._app_secret = app_secret
self._access_token: str | None = None
self._token_expires_at: float = 0
self._lock = asyncio.Lock()
self._http_client = http_client
self._state_store = state_store
self._channel_id = channel_id
async def get_token(self) -> str:
if self._is_valid():
return self._access_token # type: ignore[return-value]
async with self._lock:
if self._is_valid():
return self._access_token # type: ignore[return-value]
if self._state_store:
cached = await self._state_store.get(self._channel_id, "access_token", namespace="auth")
if cached and isinstance(cached, dict):
token = cached.get("access_token")
expires_at = cached.get("expires_at", 0)
if token and time.time() < expires_at:
self._access_token = token
self._token_expires_at = expires_at
return self._access_token
await self._refresh()
if self._state_store and self._access_token:
expire_in = TOKEN_VALIDITY_S - TOKEN_REFRESH_MARGIN_S
await self._state_store.set(
self._channel_id,
"access_token",
{
"access_token": self._access_token,
"expires_at": time.time() + expire_in,
},
namespace="auth",
ttl_seconds=TOKEN_VALIDITY_S,
)
if not self._access_token:
raise RuntimeError("Failed to obtain DingDing access token")
return self._access_token
async def _refresh(self) -> None:
refresh_start = time.monotonic()
url = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
payload: dict[str, Any] = {
"appKey": self._app_key,
"appSecret": self._app_secret,
}
if self._http_client is not None:
resp = await self._http_client.post(url, json=payload)
else:
async with httpx.AsyncClient(timeout=httpx.Timeout(15)) as client:
resp = await client.post(url, json=payload)
if resp.status_code != 200:
logger.error(f"[DingDing] Token refresh HTTP {resp.status_code}: {resp.text[:200]}")
raise RuntimeError(f"Token refresh failed: HTTP {resp.status_code}")
data = resp.json()
token = data.get("accessToken")
if not token:
logger.error(f"[DingDing] Token refresh response missing accessToken: {data}")
raise RuntimeError("Token refresh response missing accessToken")
expire_in = data.get("expireIn", TOKEN_VALIDITY_S)
self._access_token = token
self._token_expires_at = time.time() + expire_in - TOKEN_REFRESH_MARGIN_S
elapsed = time.monotonic() - refresh_start
if elapsed > 2.0:
logger.warning(f"[DingDing] Token refresh took {elapsed:.1f}s")
logger.info(f"[DingDing] Token refreshed, expires in {expire_in - TOKEN_REFRESH_MARGIN_S}s")
def _is_valid(self) -> bool:
return self._access_token is not None and time.time() < self._token_expires_at
def invalidate(self) -> None:
self._access_token = None
self._token_expires_at = 0
async def close(self) -> None:
self.invalidate()
self._http_client = None