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 {}
|