ForcePilot/backend/package/yuxi/channel/extensions/yuanbao/accounts.py
Kris 5946478772 feat(channel): 添加小红书、XMPP、元宝和 Zalo 渠道扩展
新增小红书、XMPP、元宝、Zalo 四个渠道扩展。

小红书渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, media, status, window

XMPP 渠道扩展主要模块:plugin, config, gateway, outbound, streaming, pairing, security, dedupe, accounts, commands, muc, rate_limiter, stanza_utils, status, monitor

元宝渠道扩展主要模块:plugin, client, config_schema, gateway, outbound(chunk/queue/transport), inbound(dispatcher), streaming, pairing, security, accounts, actions, commands, codec(biz/conn), session, shared, utils

Zalo 渠道扩展主要模块:api, config, gateway, webhook, outbound, pairing, security, session, polling, monitor, status
2026-05-21 12:04:05 +08:00

144 lines
5.1 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import logging
import os
import time
from dataclasses import dataclass
import httpx
from yuxi.channel.extensions.yuanbao.types import YuanbaoAccountConfig
logger = logging.getLogger(__name__)
_TOKEN_URL = "https://{api_domain}/openapi/v1/token"
_DEFAULT_API_DOMAIN = "bot.yuanbao.tencent.com"
_DEFAULT_WS_URL = "wss://bot-wss.yuanbao.tencent.com/wss/connection"
@dataclass
class ResolvedYuanbaoAccount:
account_id: str
app_key: str
app_secret: str
name: str | None = None
api_domain: str = _DEFAULT_API_DOMAIN
ws_url: str = _DEFAULT_WS_URL
enabled: bool = True
async def resolve_yuanbao_account(account_id: str, config: dict) -> ResolvedYuanbaoAccount:
account = config.get("channels", {}).get("yuanbao", {}).get("accounts", {}).get(account_id, {})
app_key = account.get("appKey") or os.environ.get("YUANBAO_APP_KEY", "")
app_secret = account.get("appSecret") or os.environ.get("YUANBAO_APP_SECRET", "")
token = account.get("token")
if (not app_key or not app_secret) and token:
colon_idx = token.find(":")
if colon_idx > 0 and colon_idx < len(token) - 1:
parsed_key = token[:colon_idx].strip()
parsed_secret = token[colon_idx + 1:].strip()
if parsed_key and parsed_secret:
app_key = app_key or parsed_key
app_secret = app_secret or parsed_secret
return ResolvedYuanbaoAccount(
account_id=account_id,
app_key=app_key,
app_secret=app_secret,
name=account.get("name"),
api_domain=account.get("apiDomain", _DEFAULT_API_DOMAIN),
ws_url=account.get("wsUrl", _DEFAULT_WS_URL),
enabled=account.get("enabled", True),
)
def _build_hmac_signature(app_key: str, app_secret: str) -> tuple[str, str]:
timestamp = str(int(time.time()))
signature_raw = f"{app_key}{timestamp}{app_secret}"
signature = hmac.new(
app_secret.encode("utf-8"),
signature_raw.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return timestamp, signature
async def fetch_access_token(
app_key: str,
app_secret: str,
api_domain: str = _DEFAULT_API_DOMAIN,
) -> str:
timestamp, signature = _build_hmac_signature(app_key, app_secret)
url = _TOKEN_URL.format(api_domain=api_domain)
async with httpx.AsyncClient() as client:
resp = await client.post(
url,
json={"appKey": app_key, "signature": signature, "timestamp": timestamp},
headers={"Content-Type": "application/json"},
timeout=30.0,
)
resp.raise_for_status()
data = resp.json()
return data["access_token"]
class TokenCache:
TTL_SECONDS = 2 * 60 * 60 - 60
def __init__(self):
self._tokens: dict[str, tuple[str, float]] = {}
async def get(self, account: ResolvedYuanbaoAccount) -> str:
key = account.account_id
now = time.time()
if key in self._tokens:
token, expiry = self._tokens[key]
if now < expiry:
return token
token = await fetch_access_token(
account.app_key, account.app_secret, account.api_domain
)
self._tokens[key] = (token, now + self.TTL_SECONDS)
return token
def invalidate(self, account_id: str) -> None:
self._tokens.pop(account_id, None)
def build_account_config(raw_account: dict, account_id: str) -> YuanbaoAccountConfig:
return YuanbaoAccountConfig(
account_id=account_id,
app_key=raw_account.get("appKey", ""),
app_secret=raw_account.get("appSecret", ""),
name=raw_account.get("name"),
api_domain=raw_account.get("apiDomain", _DEFAULT_API_DOMAIN),
ws_url=raw_account.get("wsUrl", _DEFAULT_WS_URL),
enabled=raw_account.get("enabled", True),
max_chars=raw_account.get("maxChars", 3000),
merge_text=raw_account.get("mergeText", "merge-text"),
merge_text_min_chars=raw_account.get("mergeTextMinChars", 2800),
merge_text_idle_ms=raw_account.get("mergeTextIdleMs", 5000),
merge_text_overflow=raw_account.get("mergeTextOverflow", "split"),
disable_block_streaming=raw_account.get("disableBlockStreaming", False),
block_streaming_coalesce_min_chars=raw_account.get("blockStreamingCoalesceMinChars", 2800),
block_streaming_coalesce_max_chars=raw_account.get("blockStreamingCoalesceMaxChars", 3000),
block_streaming_coalesce_idle_ms=raw_account.get("blockStreamingCoalesceIdleMs", 1000),
reply_to_mode=raw_account.get("replyToMode", "first"),
dm_policy=raw_account.get("dmPolicy", "open"),
pairing_ttl_seconds=raw_account.get("pairingTtlSeconds", 3600),
session_key_ttl_seconds=raw_account.get("sessionKeyTtlSeconds", 300),
history_limit=raw_account.get("historyLimit", 100),
group_mention_required=raw_account.get("groupMentionRequired", True),
debug_whitelist=raw_account.get("debugWhitelist", []),
media_max_mb=raw_account.get("mediaMaxMb", 20),
default_account=raw_account.get("defaultAccount", False),
raw=raw_account,
)