新增 Webex、微信 iLink、微信小程序三个渠道扩展。 Webex 渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - media: 媒体资源处理 微信 iLink 渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - context_store: 上下文存储 - aes_ecb: AES-ECB 加解密 - media: 媒体资源处理 - typing: 输入状态 微信小程序渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - crypto: 加解密处理 - dedupe: 消息去重 - message: 消息处理 - passive_reply: 被动回复 - media: 媒体资源处理 - status: 会话状态管理
334 lines
11 KiB
Python
334 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import logging
|
|
import random
|
|
import struct
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.wechat_ilink.errors import ILinkApiError, ILinkErrorCode, map_api_error
|
|
from yuxi.channel.extensions.wechat_ilink.types import ILinkAccount, ILinkMessage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BASE_URL = "https://ilinkai.weixin.qq.com"
|
|
BASE_INFO = {"channel_version": "2.0.0"}
|
|
|
|
BACKOFF_SEQUENCE = [1.0, 2.0, 5.0, 10.0, 30.0, 60.0]
|
|
MAX_RETRIES = 100
|
|
FAST_DISCONNECT_WINDOW_S = 5.0
|
|
FAST_DISCONNECT_THRESHOLD = 3
|
|
FAST_DISCONNECT_PENALTY_S = 60.0
|
|
|
|
|
|
class RetryController:
|
|
def __init__(self, max_retries: int = MAX_RETRIES):
|
|
self._max_retries = max_retries
|
|
self._attempt = 0
|
|
self._disconnect_times: list[float] = []
|
|
|
|
def reset(self) -> None:
|
|
self._attempt = 0
|
|
self._disconnect_times.clear()
|
|
|
|
def record_disconnect(self) -> None:
|
|
now = time.time()
|
|
self._disconnect_times.append(now)
|
|
cutoff = now - FAST_DISCONNECT_WINDOW_S
|
|
self._disconnect_times = [t for t in self._disconnect_times if t > cutoff]
|
|
|
|
@property
|
|
def fast_disconnect(self) -> bool:
|
|
return len(self._disconnect_times) >= FAST_DISCONNECT_THRESHOLD
|
|
|
|
@property
|
|
def exhausted(self) -> bool:
|
|
return self._attempt >= self._max_retries
|
|
|
|
def next_delay(self) -> float:
|
|
if self.fast_disconnect:
|
|
return FAST_DISCONNECT_PENALTY_S
|
|
|
|
idx = min(self._attempt, len(BACKOFF_SEQUENCE) - 1)
|
|
delay = BACKOFF_SEQUENCE[idx]
|
|
self._attempt += 1
|
|
return delay
|
|
|
|
async def wait(self) -> None:
|
|
delay = self.next_delay()
|
|
logger.info("iLink poll 重连等待 %.1fs (attempt #%d)", delay, self._attempt)
|
|
await asyncio.sleep(delay)
|
|
|
|
|
|
class ILinkApiClient:
|
|
def __init__(self, account: ILinkAccount):
|
|
self._account = account
|
|
self._client: httpx.AsyncClient | None = None
|
|
|
|
async def __aenter__(self):
|
|
self._client = httpx.AsyncClient(
|
|
base_url=self._account.base_url,
|
|
timeout=httpx.Timeout(35.0),
|
|
proxy=self._account.proxy,
|
|
)
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
if self._client:
|
|
await self._client.aclose()
|
|
|
|
def _auth_headers(self) -> dict[str, str]:
|
|
uin_bytes = struct.pack(">I", random.randint(0, 0xFFFFFFFF))
|
|
x_wechat_uin = base64.b64encode(uin_bytes).decode("ascii")
|
|
return {
|
|
"Authorization": f"Bearer {self._account.bot_token}",
|
|
"AuthorizationType": "ilink_bot_token",
|
|
"X-WECHAT-UIN": x_wechat_uin,
|
|
}
|
|
|
|
async def get_qrcode(self) -> dict[str, Any]:
|
|
resp = await self._client.get("/ilink/bot/get_bot_qrcode", params={"bot_type": 3})
|
|
return self._check_response(resp)
|
|
|
|
async def check_login_status(self, qrcode_id: str) -> dict[str, Any]:
|
|
resp = await self._client.get("/ilink/bot/get_qrcode_status", params={"qrcode": qrcode_id})
|
|
data = self._check_response(resp)
|
|
|
|
status = data.get("status", "")
|
|
if status == "confirmed":
|
|
bot_token = data.get("bot_token", "")
|
|
if bot_token:
|
|
self._account.bot_token = bot_token
|
|
ilink_bot_id = data.get("ilink_bot_id", "")
|
|
if ilink_bot_id:
|
|
self._account.ilink_bot_id = ilink_bot_id
|
|
ilink_user_id = data.get("ilink_user_id", "")
|
|
if ilink_user_id:
|
|
self._account.ilink_user_id = ilink_user_id
|
|
baseurl = data.get("baseurl", "")
|
|
if baseurl:
|
|
self._account.base_url = baseurl
|
|
if self._client:
|
|
await self._client.aclose()
|
|
self._client = httpx.AsyncClient(
|
|
base_url=self._account.base_url,
|
|
timeout=httpx.Timeout(35.0),
|
|
proxy=self._account.proxy,
|
|
)
|
|
|
|
return data
|
|
|
|
async def poll_messages(
|
|
self,
|
|
timeout: int = 25,
|
|
updates_buf: str | None = None,
|
|
) -> tuple[list[dict[str, Any]], str | None]:
|
|
body = {"timeout": timeout, "base_info": BASE_INFO}
|
|
if updates_buf:
|
|
body["get_updates_buf"] = updates_buf
|
|
resp = await self._client.post(
|
|
"/ilink/bot/getupdates",
|
|
headers=self._auth_headers(),
|
|
json=body,
|
|
)
|
|
data = self._check_response(resp)
|
|
return data.get("updates", []), data.get("get_updates_buf")
|
|
|
|
async def send_message(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
if "base_info" not in payload:
|
|
payload["base_info"] = BASE_INFO
|
|
resp = await self._client.post(
|
|
"/ilink/bot/sendmessage",
|
|
headers=self._auth_headers(),
|
|
json=payload,
|
|
)
|
|
return self._check_response(resp)
|
|
|
|
async def get_upload_url(self, media_type: str, filesize: int) -> dict[str, Any]:
|
|
body = {
|
|
"media_type": media_type,
|
|
"filesize": filesize,
|
|
"base_info": BASE_INFO,
|
|
}
|
|
resp = await self._client.post(
|
|
"/ilink/bot/getuploadurl",
|
|
headers=self._auth_headers(),
|
|
json=body,
|
|
)
|
|
return self._check_response(resp)
|
|
|
|
async def get_config(self, ilink_user_id: str, context_token: str) -> dict[str, Any]:
|
|
body = {
|
|
"ilink_user_id": ilink_user_id,
|
|
"context_token": context_token,
|
|
"base_info": BASE_INFO,
|
|
}
|
|
resp = await self._client.post(
|
|
"/ilink/bot/getconfig",
|
|
headers=self._auth_headers(),
|
|
json=body,
|
|
)
|
|
return self._check_response(resp)
|
|
|
|
async def send_typing(self, user_id: str, typing_ticket: str, status: int) -> dict[str, Any]:
|
|
body = {
|
|
"msg": {
|
|
"to_user_id": user_id,
|
|
"from_user_id": "",
|
|
"message_type": 2,
|
|
"message_state": 2,
|
|
"typing_ticket": typing_ticket,
|
|
"status": status,
|
|
},
|
|
"base_info": BASE_INFO,
|
|
}
|
|
resp = await self._client.post(
|
|
"/ilink/bot/sendtyping",
|
|
headers=self._auth_headers(),
|
|
json=body,
|
|
)
|
|
return self._check_response(resp)
|
|
|
|
@staticmethod
|
|
def _check_response(resp: httpx.Response) -> dict[str, Any]:
|
|
if resp.is_success:
|
|
data = resp.json()
|
|
errcode = data.get("errcode", 0)
|
|
if errcode != 0:
|
|
error = map_api_error(errcode, data.get("errmsg", ""))
|
|
if error:
|
|
raise error
|
|
return data
|
|
resp.raise_for_status()
|
|
return {}
|
|
|
|
|
|
class WeChatILinkGateway:
|
|
def __init__(
|
|
self,
|
|
account: ILinkAccount,
|
|
message_handler,
|
|
status_adapter=None,
|
|
context_store=None,
|
|
):
|
|
self._account = account
|
|
self._message_handler = message_handler
|
|
self._status = status_adapter
|
|
self._context_store = context_store
|
|
|
|
self._poll_task: asyncio.Task | None = None
|
|
self._running = False
|
|
self._retry = RetryController(max_retries=account.max_retries)
|
|
self._api: ILinkApiClient | None = None
|
|
self._updates_buf: str | None = None
|
|
|
|
async def start(self, ctx) -> None:
|
|
if not self._account.token_valid:
|
|
raise ILinkApiError(
|
|
ILinkErrorCode.AUTH_FAILED,
|
|
"bot_token 无效或已过期,请重新扫码登录",
|
|
)
|
|
|
|
self._running = True
|
|
self._api = ILinkApiClient(self._account)
|
|
await self._api.__aenter__()
|
|
|
|
self._poll_task = asyncio.create_task(self._poll_loop(ctx), name="ilink-poll")
|
|
|
|
if self._status:
|
|
self._status.connected = True
|
|
|
|
logger.info("iLink Gateway 已启动 (account=%s)", self._account.account_id)
|
|
|
|
async def stop(self, ctx) -> None:
|
|
self._running = False
|
|
|
|
if self._poll_task and not self._poll_task.done():
|
|
self._poll_task.cancel()
|
|
try:
|
|
await self._poll_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
if self._api:
|
|
await self._api.__aexit__(None, None, None)
|
|
self._api = None
|
|
|
|
if self._status:
|
|
self._status.connected = False
|
|
|
|
logger.info("iLink Gateway 已停止 (account=%s)", self._account.account_id)
|
|
|
|
async def _poll_loop(self, ctx) -> None:
|
|
while self._running and not ctx.cancel_event.is_set():
|
|
try:
|
|
raw_messages, self._updates_buf = await self._api.poll_messages(
|
|
timeout=self._account.poll_timeout,
|
|
updates_buf=self._updates_buf,
|
|
)
|
|
self._retry.reset()
|
|
|
|
if self._status:
|
|
self._status.record_poll_success()
|
|
|
|
for raw in raw_messages:
|
|
try:
|
|
msg = ILinkMessage.from_api_response(raw)
|
|
if self._message_handler:
|
|
await self._message_handler(msg)
|
|
except Exception:
|
|
logger.exception("iLink 消息处理异常: %s", raw.get("msg_id"))
|
|
|
|
except ILinkApiError as e:
|
|
if e.errcode == -14:
|
|
logger.error("iLink 会话过期 (errcode=-14),触发恢复流程")
|
|
if self._context_store:
|
|
self._context_store.clear_all()
|
|
self._updates_buf = None
|
|
self._account.bot_token = ""
|
|
break
|
|
|
|
if e.code in (ILinkErrorCode.INVALID_TOKEN, ILinkErrorCode.TOKEN_EXPIRED):
|
|
logger.error("iLink token 失效,等待刷新后重试")
|
|
if self._status:
|
|
self._status.record_token_error()
|
|
await asyncio.sleep(5.0)
|
|
continue
|
|
|
|
if self._status:
|
|
self._status.record_poll_error()
|
|
self._retry.record_disconnect()
|
|
await self._retry.wait()
|
|
|
|
except (httpx.TimeoutException, httpx.NetworkError):
|
|
if self._status:
|
|
self._status.record_poll_error()
|
|
self._retry.record_disconnect()
|
|
await self._retry.wait()
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
logger.error("iLink HTTP 错误: %s", e)
|
|
if self._status:
|
|
self._status.record_poll_error()
|
|
self._retry.record_disconnect()
|
|
await self._retry.wait()
|
|
|
|
except asyncio.CancelledError:
|
|
break
|
|
|
|
except Exception:
|
|
logger.exception("iLink poll 循环异常")
|
|
if self._status:
|
|
self._status.record_poll_error()
|
|
self._retry.record_disconnect()
|
|
await self._retry.wait()
|
|
|
|
finally:
|
|
await asyncio.sleep(self._account.poll_interval)
|
|
|
|
logger.info("iLink poll loop 退出 (account=%s)", self._account.account_id)
|