新增 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: 会话状态管理
260 lines
7.7 KiB
Python
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 {}
|