本次提交包含多项改进: 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适配器的配置比对与重连逻辑
116 lines
4.0 KiB
Python
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
|