新增腾讯 IM(Tencent IM)渠道扩展,支持在 Yuxi 平台中集成腾讯即时通讯 IM 渠道。 包含以下功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - pairing: 用户配对与绑定 - security: 安全校验 - signature: 请求签名验证 - usersig: UserSig 生成 - dedupe: 消息去重 - status: 会话状态管理 - group: 群组管理 - types: 类型定义
185 lines
6.3 KiB
Python
185 lines
6.3 KiB
Python
import asyncio
|
|
import logging
|
|
import random
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.tencent_im.usersig import gen_user_sig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
REGION_DOMAINS = {
|
|
"ap-beijing": "console.tim.qq.com",
|
|
"ap-guangzhou": "console.tim.qq.com",
|
|
"ap-shanghai": "console.tim.qq.com",
|
|
"ap-chengdu": "console.tim.qq.com",
|
|
"ap-singapore": "adminapisgp.im.qcloud.com",
|
|
"ap-seoul": "adminapikr.im.qcloud.com",
|
|
"eu-frankfurt": "adminapiger.im.qcloud.com",
|
|
"na-siliconvalley": "adminapiusa.im.qcloud.com",
|
|
"ap-tokyo": "adminapijpn.im.qcloud.com",
|
|
"ap-jakarta": "adminapijkt.im.qcloud.com",
|
|
}
|
|
|
|
RETRY_MAX = 3
|
|
RETRY_BASE_DELAY = 2
|
|
PROBE_INTERVAL = 60
|
|
|
|
|
|
class TencentIMError(Exception):
|
|
def __init__(self, code: int, info: str = "", raw: dict | None = None):
|
|
super().__init__(f"IM Error {code}: {info}")
|
|
self.code = code
|
|
self.info = info
|
|
self.raw = raw or {}
|
|
|
|
|
|
class TencentIMGateway:
|
|
def __init__(self):
|
|
self._account = None
|
|
self._admin_usersig: str | None = None
|
|
self._usersig_expires_at: float = 0
|
|
self._refresh_task: asyncio.Task | None = None
|
|
self._probe_task: asyncio.Task | None = None
|
|
self._http: httpx.AsyncClient | None = None
|
|
self._running = False
|
|
self._usersig_lock = asyncio.Lock()
|
|
|
|
async def start(self, ctx) -> object:
|
|
account = self._resolve_account(ctx)
|
|
if not account.is_configured():
|
|
logger.warning("TencentIM account not configured")
|
|
return {"running": False, "reason": "not-configured"}
|
|
|
|
self._account = account
|
|
domain = REGION_DOMAINS.get(account.region, "console.tim.qq.com")
|
|
self._http = httpx.AsyncClient(
|
|
base_url=f"https://{domain}",
|
|
timeout=account.http_timeout_ms / 1000,
|
|
)
|
|
|
|
try:
|
|
await self._gen_admin_usersig()
|
|
self._refresh_task = asyncio.create_task(self._usersig_refresh_loop())
|
|
self._probe_task = asyncio.create_task(self._probe_loop())
|
|
self._running = True
|
|
logger.info("TencentIM gateway started for account %s", account.account_id)
|
|
return {"running": True, "account_id": account.account_id}
|
|
except Exception:
|
|
logger.exception("TencentIM gateway failed to start")
|
|
await self.stop(ctx)
|
|
raise
|
|
|
|
async def stop(self, ctx) -> None:
|
|
self._running = False
|
|
for task in [self._refresh_task, self._probe_task]:
|
|
if task and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
if self._http:
|
|
await self._http.aclose()
|
|
self._http = None
|
|
logger.info("TencentIM gateway stopped")
|
|
|
|
async def _gen_admin_usersig(self):
|
|
async with self._usersig_lock:
|
|
self._admin_usersig = gen_user_sig(
|
|
self._account.sdk_appid,
|
|
self._account.secret_key,
|
|
self._account.admin_userid,
|
|
expire=self._account.usersig_expire_days * 86400,
|
|
)
|
|
self._usersig_expires_at = time.time() + self._account.usersig_expire_days * 86400
|
|
|
|
def _build_request_url(self, service_cmd: str) -> str:
|
|
return (
|
|
f"/v4/{service_cmd}"
|
|
f"?sdkappid={self._account.sdk_appid}"
|
|
f"&identifier={self._account.admin_userid}"
|
|
f"&usersig={self._admin_usersig}"
|
|
f"&random={random.randint(0, 0xFFFFFFFF)}"
|
|
f"&contenttype=json"
|
|
)
|
|
|
|
async def call_api(self, service_cmd: str, body: dict, retries: int = RETRY_MAX) -> dict:
|
|
url = self._build_request_url(service_cmd)
|
|
|
|
for attempt in range(retries):
|
|
try:
|
|
resp = await self._http.post(url, json=body)
|
|
result = resp.json()
|
|
error_code = result.get("ErrorCode", 0)
|
|
|
|
if error_code == 0:
|
|
return result
|
|
|
|
if error_code in (10003,):
|
|
delay = RETRY_BASE_DELAY * (2**attempt)
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
|
|
if error_code in (10004, 70001):
|
|
await self._gen_admin_usersig()
|
|
delay = RETRY_BASE_DELAY * (2**attempt)
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
|
|
raise TencentIMError(
|
|
code=error_code,
|
|
info=result.get("ErrorInfo", "unknown"),
|
|
raw=result,
|
|
)
|
|
|
|
except TencentIMError:
|
|
raise
|
|
except httpx.HTTPError as e:
|
|
if attempt == retries - 1:
|
|
raise TencentIMError(code=-1, info=str(e))
|
|
await asyncio.sleep(RETRY_BASE_DELAY * (2**attempt))
|
|
|
|
raise TencentIMError(code=-1, info="max retries exceeded")
|
|
|
|
async def _usersig_refresh_loop(self):
|
|
refresh_interval = self._account.usersig_expire_days * 86400 / 2
|
|
while self._running:
|
|
try:
|
|
await asyncio.sleep(refresh_interval)
|
|
if self._running:
|
|
await self._gen_admin_usersig()
|
|
logger.info("TencentIM admin usersig refreshed")
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception:
|
|
logger.exception("TencentIM usersig refresh failed")
|
|
|
|
async def _probe_loop(self):
|
|
while self._running:
|
|
try:
|
|
await asyncio.sleep(PROBE_INTERVAL)
|
|
if self._running:
|
|
await self.call_api(
|
|
"im_open_login_svc/account_check", {"CheckItem": [{"UserID": self._account.admin_userid}]}
|
|
)
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception:
|
|
logger.warning("TencentIM probe failed")
|
|
|
|
def get_gateway(self) -> "TencentIMGateway | None":
|
|
return self if self._running else None
|
|
|
|
@property
|
|
def account(self):
|
|
return self._account
|
|
|
|
@staticmethod
|
|
def _resolve_account(ctx):
|
|
from yuxi.channel.extensions.tencent_im.config import TencentIMConfig
|
|
|
|
config = TencentIMConfig()
|
|
return config.resolve_account()
|