ForcePilot/backend/package/yuxi/channel/extensions/wechat_ilink/outbound.py
Kris 7b5d1e5c69 feat(channel): 添加 Webex、微信 iLink 和微信小程序渠道扩展
新增 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: 会话状态管理
2026-05-21 11:59:04 +08:00

260 lines
7.7 KiB
Python

from __future__ import annotations
import base64
import logging
import os
import random
import struct
from typing import Any
import httpx
from yuxi.channel.extensions.wechat_ilink.errors import map_api_error
logger = logging.getLogger(__name__)
BASE_URL = "https://ilinkai.weixin.qq.com"
BASE_INFO = {"channel_version": "2.0.0"}
MAX_UTF8_BYTES = 2048
def split_utf8(text: str, max_bytes: int = MAX_UTF8_BYTES) -> list[str]:
chunks: list[str] = []
current = ""
current_bytes = 0
for char in text:
char_bytes = len(char.encode("utf-8"))
if current_bytes + char_bytes > max_bytes:
chunks.append(current)
current = char
current_bytes = char_bytes
else:
current += char
current_bytes += char_bytes
if current:
chunks.append(current)
return chunks
def parse_target(target_id: str) -> tuple[str, str]:
parts = target_id.split(":", 2)
if len(parts) >= 3 and parts[1] == "group":
return ("group", parts[2])
return ("private", parts[-1])
class ILinkOutbound:
def __init__(
self,
bot_token: str,
proxy: str | None = None,
context_store=None,
base_url: str = BASE_URL,
):
self._bot_token = bot_token
self._client: httpx.AsyncClient | None = None
self._proxy = proxy
self._context_store = context_store
self._base_url = base_url
async def start(self) -> None:
self._client = httpx.AsyncClient(
base_url=self._base_url,
timeout=httpx.Timeout(30.0),
proxy=self._proxy,
)
async def stop(self) -> None:
if self._client:
await self._client.aclose()
self._client = None
@property
def client(self) -> httpx.AsyncClient:
if self._client is None:
raise RuntimeError("ILinkOutbound 未启动")
return self._client
def _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._bot_token}",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": x_wechat_uin,
}
def _get_context_token(self, user_id: str) -> str:
if self._context_store:
return self._context_store.get(user_id)
return ""
def _build_payload(
self,
user_id: str,
item_list: list[dict[str, Any]],
) -> dict[str, Any]:
return {
"msg": {
"to_user_id": user_id,
"from_user_id": "",
"message_type": 2,
"message_state": 2,
"context_token": self._get_context_token(user_id),
"item_list": item_list,
},
"base_info": BASE_INFO,
}
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
) -> list[dict[str, Any]]:
chat_type, user_id = parse_target(target_id)
results: list[dict[str, Any]] = []
chunks = split_utf8(content)
for chunk in chunks:
payload = self._build_payload(
user_id,
[{"type": 1, "text_item": {"text": chunk}}],
)
if chat_type == "group":
payload["msg"]["chat_type"] = "group"
if reply_to_id:
payload["msg"]["reply_to_id"] = reply_to_id
resp = await self.client.post(
"/ilink/bot/sendmessage",
headers=self._headers(),
json=payload,
)
data = self._check_response(resp)
results.append(data)
return results
async def send_image(
self,
target_id: str,
file_path: str,
*,
reply_to_id: str | None = None,
) -> dict[str, Any]:
media_id = await self.upload_media("image", file_path)
return await self._send_media(target_id, "image", media_id, reply_to_id)
async def send_file(
self,
target_id: str,
file_path: str,
*,
reply_to_id: str | None = None,
) -> dict[str, Any]:
media_id = await self.upload_media("file", file_path)
return await self._send_media(target_id, "file", media_id, reply_to_id)
async def upload_media(self, media_type: str, file_path: str) -> str:
import aiofiles
async with aiofiles.open(file_path, "rb") as f:
raw_data = await f.read()
from yuxi.channel.extensions.wechat_ilink.aes_ecb import aes_ecb_encrypt
filesize = len(raw_data)
upload_info = await self._get_upload_url(media_type, filesize)
aes_key = upload_info.get("aeskey", "")
cdn_url = upload_info.get("cdn_url", "")
filekey = upload_info.get("filekey", "")
if aes_key and cdn_url and filekey:
key_bytes = aes_key.encode("utf-8")
encrypted_data = aes_ecb_encrypt(raw_data, key_bytes)
cdn_client = httpx.AsyncClient(timeout=httpx.Timeout(60.0))
try:
cdn_resp = await cdn_client.post(
f"{cdn_url}/upload",
files={"file": (filekey, encrypted_data)},
)
cdn_resp.raise_for_status()
finally:
await cdn_client.aclose()
return upload_info.get("encrypt_query_param", "")
filename = os.path.basename(file_path)
resp = await self.client.post(
"/ilink/bot/media/upload",
headers=self._headers(),
files={"media": (filename, raw_data)},
data={"type": media_type},
)
data = self._check_response(resp)
return data["media_id"]
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._headers(),
json=body,
)
return self._check_response(resp)
async def download_media(self, media_id: str) -> bytes:
resp = await self.client.get(
"/ilink/bot/media/download",
headers=self._headers(),
params={"media_id": media_id},
)
resp.raise_for_status()
return resp.content
async def _send_media(
self,
target_id: str,
msg_type: str,
media_id: str,
reply_to_id: str | None = None,
) -> dict[str, Any]:
chat_type, user_id = parse_target(target_id)
type_map = {"image": 3, "file": 6, "voice": 4, "video": 5}
item_type = type_map.get(msg_type, 6)
payload = self._build_payload(
user_id,
[{"type": item_type, "media_item": {"media_id": media_id}}],
)
if chat_type == "group":
payload["msg"]["chat_type"] = "group"
if reply_to_id:
payload["msg"]["reply_to_id"] = reply_to_id
resp = await self.client.post(
"/ilink/bot/sendmessage",
headers=self._headers(),
json=payload,
)
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 {}