mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-05-24 07:26:52 +00:00
1139 lines
42 KiB
Python
1139 lines
42 KiB
Python
import base64
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import random
|
||
import time
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||
from urllib.parse import quote
|
||
|
||
from Crypto.Cipher import AES
|
||
from Crypto.Util.Padding import pad
|
||
|
||
from app.log import logger
|
||
from app.utils.http import RequestUtils
|
||
|
||
|
||
@dataclass
|
||
class ILinkIncomingMessage:
|
||
"""iLink 归一化入站文本消息。"""
|
||
|
||
user_id: str
|
||
text: str
|
||
username: Optional[str] = None
|
||
message_id: Optional[str] = None
|
||
chat_id: Optional[str] = None
|
||
context_token: Optional[str] = None
|
||
raw: Dict[str, Any] = field(default_factory=dict)
|
||
|
||
|
||
class ILinkClient:
|
||
"""iLink HTTP 客户端(MVP:二维码登录、文本发送、长轮询收消息)。"""
|
||
|
||
def __init__(
|
||
self,
|
||
base_url: str,
|
||
bot_token: Optional[str] = None,
|
||
account_id: Optional[str] = None,
|
||
sync_buf: Optional[str] = None,
|
||
timeout: int = 20,
|
||
log_func: Optional[Callable[[str, str], None]] = None,
|
||
):
|
||
self.base_url = (base_url or "https://ilinkai.weixin.qq.com").rstrip("/")
|
||
self.bot_token = bot_token
|
||
self.account_id = account_id
|
||
self.sync_buf = sync_buf
|
||
self.timeout = timeout
|
||
self._log_func = log_func
|
||
self.channel_version = "1.0.2"
|
||
self.cdn_base_url = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||
|
||
def _log(self, level: str, message: str):
|
||
"""输出客户端日志,优先写入插件日志缓冲。"""
|
||
if self._log_func:
|
||
try:
|
||
self._log_func(level, f"[ILinkClient] {message}")
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
lv = (level or "info").lower()
|
||
if lv == "debug":
|
||
logger.debug(f"[WechatClawBot][ILinkClient] {message}")
|
||
elif lv == "warning":
|
||
logger.warning(f"[WechatClawBot][ILinkClient] {message}")
|
||
elif lv == "error":
|
||
logger.error(f"[WechatClawBot][ILinkClient] {message}")
|
||
else:
|
||
logger.info(f"[WechatClawBot][ILinkClient] {message}")
|
||
|
||
def set_credentials(
|
||
self,
|
||
bot_token: Optional[str],
|
||
account_id: Optional[str] = None,
|
||
sync_buf: Optional[str] = None,
|
||
) -> None:
|
||
self.bot_token = bot_token
|
||
self.account_id = account_id
|
||
if sync_buf is not None:
|
||
self.sync_buf = sync_buf
|
||
|
||
def _headers(self, auth_required: bool = True) -> Dict[str, str]:
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json, text/plain, */*",
|
||
"User-Agent": "MoviePilot-WechatClawBot/0.1",
|
||
}
|
||
if auth_required and self.bot_token:
|
||
headers["AuthorizationType"] = "ilink_bot_token"
|
||
headers["Authorization"] = f"Bearer {self.bot_token}"
|
||
headers["X-WECHAT-UIN"] = self._build_wechat_uin()
|
||
return headers
|
||
|
||
@staticmethod
|
||
def _build_wechat_uin() -> str:
|
||
"""生成 iLink 要求的 X-WECHAT-UIN(base64(random_uint32_decimal_string))。"""
|
||
random_u32 = random.getrandbits(32)
|
||
return base64.b64encode(str(random_u32).encode("utf-8")).decode("ascii")
|
||
|
||
def _with_base_info(self, body: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||
payload = dict(body or {})
|
||
base_info = payload.get("base_info")
|
||
if not isinstance(base_info, dict):
|
||
base_info = {}
|
||
base_info.setdefault("channel_version", self.channel_version)
|
||
payload["base_info"] = base_info
|
||
return payload
|
||
|
||
@staticmethod
|
||
def _json(resp) -> Dict[str, Any]:
|
||
if not resp:
|
||
return {}
|
||
try:
|
||
return resp.json() or {}
|
||
except Exception:
|
||
text = (getattr(resp, "text", "") or "").strip()
|
||
if not text:
|
||
return {}
|
||
try:
|
||
return json.loads(text)
|
||
except Exception:
|
||
return {}
|
||
|
||
@staticmethod
|
||
def _ok(payload: Dict[str, Any]) -> bool:
|
||
if not payload:
|
||
return False
|
||
code = payload.get("errcode")
|
||
if code is None:
|
||
code = payload.get("code")
|
||
if code is None:
|
||
code = payload.get("ret")
|
||
if code is None:
|
||
err = payload.get("errmsg") or payload.get("error") or payload.get("error_msg")
|
||
if err and str(err).strip().lower() not in {"ok", "success", "succeed"}:
|
||
return False
|
||
state = payload.get("status") or payload.get("state")
|
||
if isinstance(state, str) and state.strip().lower() in {"error", "failed", "fail"}:
|
||
return False
|
||
return True
|
||
try:
|
||
return int(str(code)) == 0
|
||
except Exception:
|
||
return str(code).strip().lower() in {"0", "ok", "success", "succeed"}
|
||
|
||
@staticmethod
|
||
def _short_text(value: Any, max_len: int = 240) -> str:
|
||
"""将任意响应内容缩短为单行日志,避免日志过长。"""
|
||
if value is None:
|
||
return ""
|
||
if isinstance(value, (dict, list)):
|
||
try:
|
||
text = json.dumps(value, ensure_ascii=False)
|
||
except Exception:
|
||
text = str(value)
|
||
else:
|
||
text = str(value)
|
||
text = text.replace("\n", " ").replace("\r", " ").strip()
|
||
if len(text) > max_len:
|
||
return f"{text[:max_len]}..."
|
||
return text
|
||
|
||
def _is_send_success(self, payload: Dict[str, Any]) -> bool:
|
||
"""发送接口成功判定,兼容不同返回格式。"""
|
||
if not payload:
|
||
return False
|
||
|
||
# 优先使用显式状态码判断。
|
||
code = self._find_first_value(payload, ["errcode", "code", "ret", "result_code", "status_code"])
|
||
if code is not None:
|
||
try:
|
||
return int(str(code)) == 0
|
||
except Exception:
|
||
return str(code).strip().lower() in {"0", "ok", "success", "succeed"}
|
||
|
||
# 兼容布尔成功标记。
|
||
success_flag = self._find_first_value(payload, ["success", "ok", "is_success", "sent"])
|
||
if isinstance(success_flag, bool):
|
||
return success_flag
|
||
if success_flag is not None:
|
||
if str(success_flag).strip().lower() in {"1", "true", "ok", "success", "succeed", "sent"}:
|
||
return True
|
||
|
||
# 兼容状态字段。
|
||
state = self._find_first_value(payload, ["status", "state", "send_status"])
|
||
if state is not None:
|
||
if str(state).strip().lower() in {"ok", "success", "succeed", "sent", "done"}:
|
||
return True
|
||
if str(state).strip().lower() in {"failed", "error", "denied"}:
|
||
return False
|
||
|
||
# 兜底:明确错误信息判定失败。
|
||
err_text = self._find_first_value(payload, ["errmsg", "error", "error_msg", "detail"])
|
||
if err_text is not None and str(err_text).strip():
|
||
err_s = str(err_text).strip().lower()
|
||
if err_s not in {"ok", "success", "succeed", "sent"}:
|
||
return False
|
||
|
||
return False
|
||
|
||
def _is_send_explicit_failure(self, payload: Dict[str, Any]) -> bool:
|
||
"""判断返回是否明确表示失败。"""
|
||
if not payload:
|
||
return False
|
||
|
||
code = self._find_first_value(payload, ["errcode", "code", "ret", "result_code", "status_code"])
|
||
if code is not None:
|
||
try:
|
||
return int(str(code)) != 0
|
||
except Exception:
|
||
return str(code).strip().lower() not in {"0", "ok", "success", "succeed"}
|
||
|
||
success_flag = self._find_first_value(payload, ["success", "ok", "is_success", "sent"])
|
||
if isinstance(success_flag, bool):
|
||
return not success_flag
|
||
if success_flag is not None:
|
||
val = str(success_flag).strip().lower()
|
||
if val in {"0", "false", "fail", "failed", "error", "denied"}:
|
||
return True
|
||
|
||
state = self._find_first_value(payload, ["status", "state", "send_status"])
|
||
if state is not None:
|
||
val = str(state).strip().lower()
|
||
if val in {"failed", "error", "denied", "forbidden", "blocked"}:
|
||
return True
|
||
|
||
err_text = self._find_first_value(payload, ["errmsg", "error", "error_msg", "detail"])
|
||
if err_text is not None and str(err_text).strip():
|
||
val = str(err_text).strip().lower()
|
||
if val not in {"ok", "success", "succeed", "sent"}:
|
||
return True
|
||
|
||
return False
|
||
|
||
def _is_send_http_success(self, resp: Any, payload: Dict[str, Any]) -> bool:
|
||
"""官方实现以 HTTP 成功为准;仅在返回明确失败时判为失败。"""
|
||
if resp is None:
|
||
return False
|
||
status_code = getattr(resp, "status_code", None)
|
||
if status_code is None:
|
||
return False
|
||
try:
|
||
status_ok = 200 <= int(status_code) < 300
|
||
except Exception:
|
||
status_ok = False
|
||
if not status_ok:
|
||
return False
|
||
if not payload:
|
||
return True
|
||
return not self._is_send_explicit_failure(payload)
|
||
|
||
@staticmethod
|
||
def _build_user_candidates(to_user: str) -> List[str]:
|
||
"""构造多种收件人ID格式,兼容 iLink 接口差异。"""
|
||
raw = str(to_user or "").strip()
|
||
if not raw:
|
||
return []
|
||
|
||
candidates: List[str] = [raw]
|
||
if "@" in raw:
|
||
candidates.append(raw.split("@", 1)[0])
|
||
if raw.endswith("@im.wechat"):
|
||
candidates.append(raw[:-len("@im.wechat")])
|
||
else:
|
||
candidates.append(f"{raw}@im.wechat")
|
||
|
||
uniq: List[str] = []
|
||
for item in candidates:
|
||
value = str(item or "").strip()
|
||
if value and value not in uniq:
|
||
uniq.append(value)
|
||
return uniq
|
||
|
||
@staticmethod
|
||
def _build_text_payloads(user_id: str, text: str) -> List[Dict[str, Any]]:
|
||
"""构造多种发送请求体,提升 sendmessage 协议兼容性。"""
|
||
return [
|
||
{
|
||
"to_user": user_id,
|
||
"msg_type": "text",
|
||
"text": {"content": text},
|
||
},
|
||
{
|
||
"to_user": user_id,
|
||
"msg_type": "text",
|
||
"text": text,
|
||
},
|
||
{
|
||
"touser": user_id,
|
||
"msgtype": "text",
|
||
"text": {"content": text},
|
||
},
|
||
{
|
||
"touser": user_id,
|
||
"msgtype": "text",
|
||
"text": text,
|
||
},
|
||
{
|
||
"to": user_id,
|
||
"type": "text",
|
||
"content": text,
|
||
},
|
||
{
|
||
"to_user_id": user_id,
|
||
"msg_type": "text",
|
||
"content": text,
|
||
},
|
||
{
|
||
"receiver": user_id,
|
||
"msg_type": "text",
|
||
"text": {"content": text},
|
||
},
|
||
{
|
||
"to_user": user_id,
|
||
"message_type": "text",
|
||
"content": text,
|
||
},
|
||
{
|
||
"to_user": user_id,
|
||
"msg_type": 1,
|
||
"text": {"content": text},
|
||
},
|
||
]
|
||
|
||
@staticmethod
|
||
def _aes_ecb_padded_size(plaintext_size: int) -> int:
|
||
return ((int(plaintext_size) + 1 + 15) // 16) * 16
|
||
|
||
@staticmethod
|
||
def _encrypt_aes_ecb(plaintext: bytes, key: bytes) -> bytes:
|
||
cipher = AES.new(key, AES.MODE_ECB)
|
||
return cipher.encrypt(pad(plaintext, AES.block_size))
|
||
|
||
@staticmethod
|
||
def _encode_media_aes_key(aeskey: bytes) -> str:
|
||
"""与官方 openclaw-weixin 保持一致:base64(hex_string)。"""
|
||
return base64.b64encode(aeskey.hex().encode("ascii")).decode("ascii")
|
||
|
||
def _build_protocol_msg_payload(self, user_id: str, text: str, context_token: Optional[str]) -> Dict[str, Any]:
|
||
msg = {
|
||
"from_user_id": str(self.account_id or ""),
|
||
"to_user_id": user_id,
|
||
"client_id": f"mp-{uuid.uuid4()}",
|
||
"message_type": 2,
|
||
"message_state": 2,
|
||
"item_list": [
|
||
{
|
||
"type": 1,
|
||
"text_item": {
|
||
"text": text,
|
||
},
|
||
}
|
||
],
|
||
}
|
||
if context_token:
|
||
msg["context_token"] = context_token
|
||
return {"msg": msg}
|
||
|
||
def _build_protocol_image_payload(
|
||
self,
|
||
user_id: str,
|
||
context_token: Optional[str],
|
||
download_param: str,
|
||
aeskey_b64: str,
|
||
cipher_size: int,
|
||
) -> Dict[str, Any]:
|
||
msg: Dict[str, Any] = {
|
||
"from_user_id": "",
|
||
"to_user_id": user_id,
|
||
"client_id": f"mp-{uuid.uuid4()}",
|
||
"message_type": 2,
|
||
"message_state": 2,
|
||
"item_list": [
|
||
{
|
||
"type": 2,
|
||
"image_item": {
|
||
"media": {
|
||
"encrypt_query_param": download_param,
|
||
"aes_key": aeskey_b64,
|
||
"encrypt_type": 1,
|
||
},
|
||
"mid_size": int(cipher_size),
|
||
},
|
||
}
|
||
],
|
||
}
|
||
if context_token:
|
||
msg["context_token"] = context_token
|
||
return {"msg": msg}
|
||
|
||
def _request_upload_param(
|
||
self,
|
||
to_user: str,
|
||
plaintext: bytes,
|
||
) -> Tuple[Optional[str], Optional[str], Optional[bytes], Optional[int], Optional[str]]:
|
||
rawsize = len(plaintext)
|
||
rawfilemd5 = hashlib.md5(plaintext).hexdigest()
|
||
filesize = self._aes_ecb_padded_size(rawsize)
|
||
filekey = os.urandom(16).hex()
|
||
aeskey = os.urandom(16)
|
||
|
||
body = self._with_base_info(
|
||
{
|
||
"filekey": filekey,
|
||
"media_type": 1,
|
||
"to_user_id": to_user,
|
||
"rawsize": rawsize,
|
||
"rawfilemd5": rawfilemd5,
|
||
"filesize": filesize,
|
||
"no_need_thumb": True,
|
||
"aeskey": aeskey.hex(),
|
||
}
|
||
)
|
||
|
||
url = f"{self.base_url}/ilink/bot/getuploadurl"
|
||
resp = RequestUtils(headers=self._headers(auth_required=True), timeout=self.timeout).post(url, json=body)
|
||
payload = self._json(resp)
|
||
upload_param = (
|
||
self._find_first_value(payload, ["upload_param", "uploadParam"])
|
||
if payload
|
||
else None
|
||
)
|
||
upload_full_url = (
|
||
self._find_first_value(payload, ["upload_full_url", "uploadFullUrl", "full_url"])
|
||
if payload
|
||
else None
|
||
)
|
||
if not upload_param and not upload_full_url:
|
||
self._log(
|
||
"warning",
|
||
f"getuploadurl 失败: http={getattr(resp, 'status_code', None)}, resp={self._short_text(payload or getattr(resp, 'text', ''))}",
|
||
)
|
||
return None, None, None, None, None
|
||
|
||
return (
|
||
str(upload_param) if upload_param else None,
|
||
str(upload_full_url) if upload_full_url else None,
|
||
aeskey,
|
||
filesize,
|
||
filekey,
|
||
)
|
||
|
||
def _upload_encrypted_to_cdn(
|
||
self,
|
||
upload_param: Optional[str],
|
||
upload_full_url: Optional[str],
|
||
filekey: str,
|
||
plaintext: bytes,
|
||
aeskey: bytes,
|
||
) -> Tuple[Optional[str], Optional[int]]:
|
||
ciphertext = self._encrypt_aes_ecb(plaintext, aeskey)
|
||
if upload_full_url:
|
||
upload_url = str(upload_full_url).strip()
|
||
elif upload_param:
|
||
upload_url = (
|
||
f"{self.cdn_base_url}/upload?encrypted_query_param={quote(str(upload_param), safe='')}&"
|
||
f"filekey={quote(filekey, safe='')}"
|
||
)
|
||
else:
|
||
self._log("warning", "CDN 上传失败: 缺少 upload_url 参数")
|
||
return None, None
|
||
|
||
resp = RequestUtils(
|
||
headers={"Content-Type": "application/octet-stream"},
|
||
timeout=self.timeout,
|
||
).post(upload_url, data=ciphertext)
|
||
|
||
status_code = getattr(resp, "status_code", None)
|
||
if status_code != 200:
|
||
self._log(
|
||
"warning",
|
||
f"CDN 上传失败: http={status_code}, err={self._short_text(getattr(resp, 'text', ''))}",
|
||
)
|
||
return None, None
|
||
|
||
download_param = None
|
||
if resp is not None and getattr(resp, "headers", None):
|
||
download_param = resp.headers.get("x-encrypted-param")
|
||
if not download_param:
|
||
self._log("warning", "CDN 上传成功但缺少 x-encrypted-param")
|
||
return None, None
|
||
|
||
return str(download_param), len(ciphertext)
|
||
|
||
def get_qrcode(self) -> Dict[str, Any]:
|
||
url = f"{self.base_url}/ilink/bot/get_bot_qrcode?bot_type=3"
|
||
self._log("debug", f"请求二维码: {url}")
|
||
resp = RequestUtils(headers=self._headers(auth_required=False), timeout=self.timeout).get_res(url)
|
||
payload = self._json(resp)
|
||
if not payload:
|
||
self._log("warning", "二维码接口返回空响应")
|
||
return {"success": False, "message": "获取二维码失败"}
|
||
|
||
data = payload.get("data") or payload.get("result") or payload
|
||
qrcode = (
|
||
data.get("qrcode")
|
||
or data.get("qr_code")
|
||
or data.get("qrcode_id")
|
||
or data.get("ticket")
|
||
)
|
||
qrcode_url = (
|
||
data.get("qrcode_url")
|
||
or data.get("url")
|
||
or data.get("qrcodeUrl")
|
||
or data.get("qr_url")
|
||
or data.get("qrcode_img_content")
|
||
or data.get("qrcode_img_url")
|
||
or data.get("qr_img")
|
||
)
|
||
|
||
# 某些返回仅给出 qrcode id,补一个已知可扫码链接兜底。
|
||
if not qrcode_url and qrcode:
|
||
qrcode_url = f"https://liteapp.weixin.qq.com/q/7GiQu1?qrcode={qrcode}&bot_type=3"
|
||
|
||
result = {
|
||
"success": self._ok(payload) and bool(qrcode or qrcode_url),
|
||
"qrcode": qrcode,
|
||
"qrcode_url": qrcode_url,
|
||
"raw": payload,
|
||
"message": payload.get("errmsg") or payload.get("message"),
|
||
}
|
||
self._log(
|
||
"info" if result.get("success") else "warning",
|
||
f"二维码解析结果: success={result.get('success')}, qrcode={result.get('qrcode')}, has_url={bool(result.get('qrcode_url'))}",
|
||
)
|
||
return result
|
||
|
||
def get_qrcode_status(self, qrcode: str) -> Dict[str, Any]:
|
||
url = f"{self.base_url}/ilink/bot/get_qrcode_status"
|
||
self._log("debug", f"查询二维码状态: qrcode={qrcode}")
|
||
resp = RequestUtils(headers=self._headers(auth_required=False), timeout=self.timeout).get_res(
|
||
url, params={"qrcode": qrcode}
|
||
)
|
||
payload = self._json(resp)
|
||
if not payload:
|
||
# 某些代理场景下 params 透传异常,补一次显式 query 兜底。
|
||
retry_resp = RequestUtils(headers=self._headers(auth_required=False), timeout=self.timeout).get_res(
|
||
f"{url}?qrcode={qrcode}"
|
||
)
|
||
payload = self._json(retry_resp)
|
||
if not payload:
|
||
self._log("warning", "二维码状态接口返回空响应")
|
||
return {
|
||
"success": False,
|
||
"status": "waiting",
|
||
"token": None,
|
||
"account_id": None,
|
||
"raw": {},
|
||
"message": "二维码状态接口返回空响应",
|
||
}
|
||
|
||
data = payload.get("data") or payload.get("result") or payload
|
||
|
||
token = (
|
||
data.get("bot_token")
|
||
or data.get("token")
|
||
or data.get("access_token")
|
||
or self._find_first_value(data, ["bot_token", "access_token", "token", "jwt", "auth_token"])
|
||
)
|
||
account_id = (
|
||
data.get("account_id")
|
||
or data.get("ilink_bot_id")
|
||
or data.get("wxid")
|
||
or data.get("uid")
|
||
or data.get("user_id")
|
||
or self._find_first_value(data, ["account_id", "ilink_bot_id", "wxid", "uid", "user_id", "from_user", "from_uid"])
|
||
)
|
||
base_url = (
|
||
data.get("baseurl")
|
||
or data.get("base_url")
|
||
or payload.get("baseurl")
|
||
or payload.get("base_url")
|
||
)
|
||
|
||
if token:
|
||
self.bot_token = token
|
||
if account_id:
|
||
self.account_id = str(account_id)
|
||
|
||
state = (
|
||
data.get("status")
|
||
or data.get("state")
|
||
or payload.get("status")
|
||
or payload.get("state")
|
||
or self._find_first_value(data, ["status", "state", "scan_status"])
|
||
or "waiting"
|
||
)
|
||
|
||
result = {
|
||
"success": self._ok(payload),
|
||
"status": str(state).lower(),
|
||
"token": token,
|
||
"account_id": account_id,
|
||
"base_url": base_url,
|
||
"raw": payload,
|
||
"message": payload.get("errmsg") or payload.get("message"),
|
||
}
|
||
self._log(
|
||
"debug",
|
||
f"二维码状态结果: success={result.get('success')}, status={result.get('status')}, has_token={bool(result.get('token'))}",
|
||
)
|
||
return result
|
||
|
||
def send_text(self, to_user: str, text: str, context_token: Optional[str] = None) -> bool:
|
||
if not self.bot_token:
|
||
self._log("warning", "发送消息失败:bot token 未配置")
|
||
return False
|
||
if not to_user or not text:
|
||
self._log("warning", "发送消息失败:to_user 或 text 为空")
|
||
return False
|
||
|
||
url_candidates = [
|
||
f"{self.base_url}/ilink/bot/sendmessage",
|
||
f"{self.base_url}/ilink/bot/sendmessage?bot_type=3",
|
||
]
|
||
user_candidates = self._build_user_candidates(to_user)
|
||
|
||
last_error = ""
|
||
for user_id in user_candidates:
|
||
payload_candidates = [
|
||
self._build_protocol_msg_payload(user_id=user_id, text=text, context_token=context_token),
|
||
*self._build_text_payloads(user_id=user_id, text=text),
|
||
]
|
||
for url in url_candidates:
|
||
for idx, body in enumerate(payload_candidates, start=1):
|
||
request_body = self._with_base_info(body)
|
||
resp = RequestUtils(headers=self._headers(auth_required=True), timeout=self.timeout).post(
|
||
url,
|
||
json=request_body,
|
||
)
|
||
payload = self._json(resp)
|
||
if self._is_send_success(payload) or self._is_send_http_success(resp, payload):
|
||
self._log("info", f"发送消息成功: to_user={user_id}, variant={idx}")
|
||
return True
|
||
|
||
http_code = getattr(resp, "status_code", None)
|
||
err_msg = (
|
||
self._find_first_value(payload, ["errmsg", "message", "error", "detail"])
|
||
if payload
|
||
else None
|
||
)
|
||
if not err_msg and resp is not None:
|
||
err_msg = self._short_text(getattr(resp, "text", ""))
|
||
|
||
last_error = f"http={http_code}, err={self._short_text(err_msg)}"
|
||
self._log(
|
||
"debug",
|
||
f"发送候选失败: to_user={user_id}, variant={idx}, {last_error}, req={self._short_text(request_body)}, resp={self._short_text(payload)}",
|
||
)
|
||
|
||
self._log("warning", f"发送消息失败: to_user={to_user}, {last_error}")
|
||
return False
|
||
|
||
def send_image_text_png(
|
||
self,
|
||
to_user: str,
|
||
image_bytes: bytes,
|
||
text: str,
|
||
context_token: Optional[str] = None,
|
||
) -> bool:
|
||
"""发送图文消息(兼容模式:文本与图片分两条发送)。"""
|
||
if not self.bot_token:
|
||
self._log("warning", "发送图文失败:bot token 未配置")
|
||
return False
|
||
if not to_user or not image_bytes or not text:
|
||
self._log("warning", "发送图文失败:to_user 或 image_bytes 或 text 为空")
|
||
return False
|
||
|
||
url_candidates = [
|
||
f"{self.base_url}/ilink/bot/sendmessage",
|
||
f"{self.base_url}/ilink/bot/sendmessage?bot_type=3",
|
||
]
|
||
|
||
last_error = ""
|
||
for user_id in self._build_user_candidates(to_user):
|
||
upload_param, upload_full_url, aeskey, _, filekey = self._request_upload_param(user_id, image_bytes)
|
||
if (not upload_param and not upload_full_url) or not aeskey or not filekey:
|
||
continue
|
||
|
||
download_param, cipher_size = self._upload_encrypted_to_cdn(
|
||
upload_param=upload_param,
|
||
upload_full_url=upload_full_url,
|
||
filekey=filekey,
|
||
plaintext=image_bytes,
|
||
aeskey=aeskey,
|
||
)
|
||
if not download_param or not cipher_size:
|
||
continue
|
||
|
||
aeskey_b64 = self._encode_media_aes_key(aeskey)
|
||
message_items: List[Dict[str, Any]] = [
|
||
{
|
||
"type": 1,
|
||
"text_item": {
|
||
"text": text,
|
||
},
|
||
},
|
||
{
|
||
"type": 2,
|
||
"image_item": {
|
||
"media": {
|
||
"encrypt_query_param": download_param,
|
||
"aes_key": aeskey_b64,
|
||
"encrypt_type": 1,
|
||
},
|
||
"mid_size": int(cipher_size),
|
||
},
|
||
},
|
||
]
|
||
|
||
sent_all = True
|
||
for item in message_items:
|
||
item_sent = False
|
||
item_type = item.get("type")
|
||
for url in url_candidates:
|
||
msg: Dict[str, Any] = {
|
||
"from_user_id": "",
|
||
"to_user_id": user_id,
|
||
"client_id": f"mp-{uuid.uuid4()}",
|
||
"message_type": 2,
|
||
"message_state": 2,
|
||
"item_list": [item],
|
||
}
|
||
if context_token:
|
||
msg["context_token"] = context_token
|
||
|
||
body = {"msg": msg}
|
||
request_body = self._with_base_info(body)
|
||
resp = RequestUtils(headers=self._headers(auth_required=True), timeout=self.timeout).post(
|
||
url,
|
||
json=request_body,
|
||
)
|
||
payload = self._json(resp)
|
||
if self._is_send_success(payload) or self._is_send_http_success(resp, payload):
|
||
item_sent = True
|
||
break
|
||
|
||
http_code = getattr(resp, "status_code", None)
|
||
err_msg = (
|
||
self._find_first_value(payload, ["errmsg", "message", "error", "detail"])
|
||
if payload
|
||
else None
|
||
)
|
||
if not err_msg and resp is not None:
|
||
err_msg = self._short_text(getattr(resp, "text", ""))
|
||
last_error = f"http={http_code}, err={self._short_text(err_msg)}"
|
||
self._log(
|
||
"debug",
|
||
f"发送图文子消息失败: to_user={user_id}, item_type={item_type}, {last_error}, req={self._short_text(request_body)}, resp={self._short_text(payload)}",
|
||
)
|
||
|
||
if not item_sent:
|
||
sent_all = False
|
||
break
|
||
|
||
if sent_all:
|
||
self._log("info", f"发送图文成功: to_user={user_id}, mode=split_items")
|
||
return True
|
||
|
||
self._log("warning", f"发送图文失败: to_user={to_user}, {last_error}")
|
||
return False
|
||
|
||
def send_image_png(self, to_user: str, image_bytes: bytes, context_token: Optional[str] = None) -> bool:
|
||
if not self.bot_token:
|
||
self._log("warning", "发送图片失败:bot token 未配置")
|
||
return False
|
||
if not to_user or not image_bytes:
|
||
self._log("warning", "发送图片失败:to_user 或 image_bytes 为空")
|
||
return False
|
||
|
||
url_candidates = [
|
||
f"{self.base_url}/ilink/bot/sendmessage",
|
||
f"{self.base_url}/ilink/bot/sendmessage?bot_type=3",
|
||
]
|
||
|
||
last_error = ""
|
||
for user_id in self._build_user_candidates(to_user):
|
||
upload_param, upload_full_url, aeskey, _, filekey = self._request_upload_param(user_id, image_bytes)
|
||
if (not upload_param and not upload_full_url) or not aeskey or not filekey:
|
||
continue
|
||
|
||
download_param, cipher_size = self._upload_encrypted_to_cdn(
|
||
upload_param=upload_param,
|
||
upload_full_url=upload_full_url,
|
||
filekey=filekey,
|
||
plaintext=image_bytes,
|
||
aeskey=aeskey,
|
||
)
|
||
if not download_param or not cipher_size:
|
||
continue
|
||
|
||
aeskey_b64 = self._encode_media_aes_key(aeskey)
|
||
body = self._build_protocol_image_payload(
|
||
user_id=user_id,
|
||
context_token=context_token,
|
||
download_param=download_param,
|
||
aeskey_b64=aeskey_b64,
|
||
cipher_size=cipher_size,
|
||
)
|
||
|
||
for url in url_candidates:
|
||
request_body = self._with_base_info(body)
|
||
resp = RequestUtils(headers=self._headers(auth_required=True), timeout=self.timeout).post(
|
||
url,
|
||
json=request_body,
|
||
)
|
||
payload = self._json(resp)
|
||
if self._is_send_success(payload) or self._is_send_http_success(resp, payload):
|
||
self._log("info", f"发送图片成功: to_user={user_id}")
|
||
return True
|
||
|
||
http_code = getattr(resp, "status_code", None)
|
||
err_msg = (
|
||
self._find_first_value(payload, ["errmsg", "message", "error", "detail"])
|
||
if payload
|
||
else None
|
||
)
|
||
if not err_msg and resp is not None:
|
||
err_msg = self._short_text(getattr(resp, "text", ""))
|
||
last_error = f"http={http_code}, err={self._short_text(err_msg)}"
|
||
self._log(
|
||
"debug",
|
||
f"发送图片失败: to_user={user_id}, {last_error}, req={self._short_text(request_body)}, resp={self._short_text(payload)}",
|
||
)
|
||
|
||
self._log("warning", f"发送图片失败: to_user={to_user}, {last_error}")
|
||
return False
|
||
|
||
def _extract_updates(self, payload: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
||
data = payload.get("data") or payload.get("result") or payload
|
||
sync_buf = (
|
||
data.get("get_updates_buf")
|
||
or payload.get("get_updates_buf")
|
||
or data.get("sync_buf")
|
||
or data.get("syncBuf")
|
||
or payload.get("sync_buf")
|
||
or payload.get("syncBuf")
|
||
or self._find_first_value(data, ["get_updates_buf", "sync_buf", "syncBuf", "cursor", "offset", "next_sync_buf"])
|
||
)
|
||
|
||
list_keys = [
|
||
"msgs",
|
||
"updates",
|
||
"messages",
|
||
"items",
|
||
"events",
|
||
"msg_list",
|
||
"msgList",
|
||
"add_msgs",
|
||
"addMsgs",
|
||
"records",
|
||
"list",
|
||
]
|
||
|
||
for obj in [data, payload]:
|
||
for key in list_keys:
|
||
value = obj.get(key)
|
||
if isinstance(value, list):
|
||
return value, sync_buf
|
||
|
||
nested = self._find_first_list(data, prefer_keys=list_keys)
|
||
if isinstance(nested, list):
|
||
return nested, sync_buf
|
||
|
||
if isinstance(data, list):
|
||
return data, sync_buf
|
||
|
||
# 少数接口返回单条消息对象。
|
||
if isinstance(data, dict):
|
||
for key in ["message", "msg", "event", "item"]:
|
||
item = data.get(key)
|
||
if isinstance(item, dict):
|
||
return [item], sync_buf
|
||
return [], sync_buf
|
||
|
||
@staticmethod
|
||
def _pick_value(obj: Dict[str, Any], keys: List[str]) -> Optional[Any]:
|
||
for key in keys:
|
||
if key in obj and obj.get(key) not in (None, ""):
|
||
return obj.get(key)
|
||
return None
|
||
|
||
@classmethod
|
||
def _find_first_value(cls, data: Any, keys: List[str], max_depth: int = 5) -> Optional[Any]:
|
||
if max_depth < 0 or data is None:
|
||
return None
|
||
if isinstance(data, dict):
|
||
direct = cls._pick_value(data, keys)
|
||
if direct not in (None, ""):
|
||
return direct
|
||
for value in data.values():
|
||
found = cls._find_first_value(value, keys, max_depth - 1)
|
||
if found not in (None, ""):
|
||
return found
|
||
elif isinstance(data, list):
|
||
for value in data:
|
||
found = cls._find_first_value(value, keys, max_depth - 1)
|
||
if found not in (None, ""):
|
||
return found
|
||
return None
|
||
|
||
@classmethod
|
||
def _find_first_list(cls, data: Any, prefer_keys: List[str], max_depth: int = 5) -> Optional[List[Any]]:
|
||
if max_depth < 0 or data is None:
|
||
return None
|
||
if isinstance(data, dict):
|
||
for key in prefer_keys:
|
||
value = data.get(key)
|
||
if isinstance(value, list):
|
||
return value
|
||
for value in data.values():
|
||
found = cls._find_first_list(value, prefer_keys, max_depth - 1)
|
||
if found is not None:
|
||
return found
|
||
elif isinstance(data, list):
|
||
if data and all(isinstance(item, dict) for item in data):
|
||
return data
|
||
for value in data:
|
||
found = cls._find_first_list(value, prefer_keys, max_depth - 1)
|
||
if found is not None:
|
||
return found
|
||
return None
|
||
|
||
@staticmethod
|
||
def _as_scalar(value: Any) -> Optional[Any]:
|
||
if value in (None, ""):
|
||
return None
|
||
if isinstance(value, (dict, list, tuple, set)):
|
||
return None
|
||
return value
|
||
|
||
def _parse_incoming(self, item: Dict[str, Any]) -> Optional[ILinkIncomingMessage]:
|
||
if not isinstance(item, dict):
|
||
return None
|
||
|
||
message = item
|
||
for key in ["message", "msg", "event", "payload", "data"]:
|
||
child = item.get(key)
|
||
if isinstance(child, dict):
|
||
message = child
|
||
break
|
||
|
||
sender = (
|
||
message.get("from")
|
||
if isinstance(message.get("from"), dict)
|
||
else message.get("sender")
|
||
if isinstance(message.get("sender"), dict)
|
||
else message.get("user")
|
||
if isinstance(message.get("user"), dict)
|
||
else message.get("from_user")
|
||
if isinstance(message.get("from_user"), dict)
|
||
else {}
|
||
)
|
||
|
||
user_id = (
|
||
self._pick_value(sender, ["user_id", "id", "wxid", "uid"])
|
||
or self._pick_value(message, [
|
||
"from_user",
|
||
"from_user_id",
|
||
"user_id",
|
||
"uid",
|
||
"wxid",
|
||
"from_uid",
|
||
"fromUser",
|
||
"fromUserId",
|
||
"openid",
|
||
])
|
||
or self._pick_value(item, [
|
||
"from_user",
|
||
"from_user_id",
|
||
"user_id",
|
||
"uid",
|
||
"wxid",
|
||
"from_uid",
|
||
"fromUser",
|
||
"fromUserId",
|
||
"openid",
|
||
])
|
||
or self._find_first_value(message, [
|
||
"from_user",
|
||
"from_user_id",
|
||
"user_id",
|
||
"sender_id",
|
||
"uid",
|
||
"wxid",
|
||
"from_uid",
|
||
"fromUserId",
|
||
"openid",
|
||
])
|
||
)
|
||
user_id = self._as_scalar(user_id)
|
||
if not user_id:
|
||
return None
|
||
|
||
text = None
|
||
item_list = message.get("item_list") if isinstance(message.get("item_list"), list) else []
|
||
for one in item_list:
|
||
if not isinstance(one, dict):
|
||
continue
|
||
item_type = one.get("type")
|
||
if item_type == 1 and isinstance(one.get("text_item"), dict):
|
||
text = self._pick_value(one.get("text_item") or {}, ["text", "content"])
|
||
if text:
|
||
break
|
||
|
||
if isinstance(message.get("text"), dict):
|
||
text = self._pick_value(message.get("text") or {}, ["content", "text", "value", "msg"])
|
||
if not text:
|
||
text = self._pick_value(message, ["content", "message", "msg", "text", "body", "msg_content", "msgContent"])
|
||
if not text:
|
||
text = self._pick_value(item, ["content", "message", "msg", "text", "body", "msg_content", "msgContent"])
|
||
if not text:
|
||
text = self._find_first_value(message, ["content", "text", "message", "msg", "body", "cmd"])
|
||
|
||
if not text:
|
||
return None
|
||
|
||
if isinstance(text, dict):
|
||
text = self._pick_value(text, ["content", "text", "value", "message"])
|
||
if not isinstance(text, str):
|
||
text = str(text)
|
||
|
||
username = (
|
||
self._pick_value(sender, ["name", "nickname", "username", "remark"])
|
||
or self._pick_value(message, ["username", "nickname", "from_name", "fromNick", "sender_name"])
|
||
or str(user_id)
|
||
)
|
||
message_id = (
|
||
self._pick_value(message, ["message_id", "msg_id", "id", "client_msg_id", "msgId"])
|
||
or self._pick_value(item, ["message_id", "msg_id", "id", "client_msg_id", "msgId"])
|
||
)
|
||
chat_id = (
|
||
self._pick_value(message, ["chat_id", "conversation_id", "room_id", "chatId", "conversationId", "roomId"])
|
||
or self._pick_value(item, ["chat_id", "conversation_id", "room_id", "chatId", "conversationId", "roomId"])
|
||
)
|
||
context_token = (
|
||
self._pick_value(message, ["context_token", "contextToken"])
|
||
or self._pick_value(item, ["context_token", "contextToken"])
|
||
)
|
||
|
||
return ILinkIncomingMessage(
|
||
user_id=str(user_id),
|
||
text=str(text),
|
||
username=str(username) if username else None,
|
||
message_id=str(message_id) if message_id else None,
|
||
chat_id=str(chat_id) if chat_id else None,
|
||
context_token=str(context_token) if context_token else None,
|
||
raw=item,
|
||
)
|
||
|
||
def poll_updates(self, timeout_seconds: int = 25) -> Tuple[List[ILinkIncomingMessage], Optional[str], Dict[str, Any]]:
|
||
if not self.bot_token:
|
||
self._log("warning", "轮询失败:bot token 未配置")
|
||
return [], self.sync_buf, {"success": False, "message": "bot token 未配置"}
|
||
|
||
url = f"{self.base_url}/ilink/bot/getupdates"
|
||
payload = {}
|
||
body_candidates = [
|
||
{
|
||
"get_updates_buf": self.sync_buf or "",
|
||
},
|
||
{
|
||
"sync_buf": self.sync_buf,
|
||
"timeout": timeout_seconds,
|
||
},
|
||
{
|
||
"syncBuf": self.sync_buf,
|
||
"timeout": timeout_seconds,
|
||
},
|
||
{
|
||
"sync_buf": self.sync_buf,
|
||
"wait": timeout_seconds,
|
||
},
|
||
]
|
||
|
||
for body in body_candidates:
|
||
request_body = self._with_base_info(body)
|
||
resp = RequestUtils(headers=self._headers(auth_required=True), timeout=timeout_seconds + 10).post(
|
||
url,
|
||
json=request_body,
|
||
)
|
||
payload = self._json(resp)
|
||
if payload and self._ok(payload):
|
||
break
|
||
if payload and self._find_first_list(payload, prefer_keys=["updates", "messages", "items", "events", "add_msgs", "msgs"]):
|
||
break
|
||
|
||
if not payload:
|
||
self._log("warning", "轮询接口返回空响应")
|
||
return [], self.sync_buf, {"success": False, "message": "轮询返回空响应"}
|
||
|
||
items, sync_buf = self._extract_updates(payload)
|
||
parsed: List[ILinkIncomingMessage] = []
|
||
for item in items:
|
||
msg = self._parse_incoming(item)
|
||
if msg:
|
||
parsed.append(msg)
|
||
|
||
if sync_buf is not None:
|
||
self.sync_buf = str(sync_buf)
|
||
|
||
result = {
|
||
"success": self._ok(payload),
|
||
"raw": payload,
|
||
"message": payload.get("errmsg") or payload.get("message"),
|
||
"item_count": len(items),
|
||
"parsed_count": len(parsed),
|
||
}
|
||
if items and not parsed:
|
||
sample = items[0] if items else {}
|
||
sample_keys = []
|
||
if isinstance(sample, dict):
|
||
sample_keys = list(sample.keys())[:12]
|
||
self._log(
|
||
"warning",
|
||
f"轮询收到原始消息但未解析到文本: raw_items={len(items)}, sample_keys={sample_keys}",
|
||
)
|
||
elif parsed:
|
||
self._log("info", f"轮询收到文本消息: count={len(parsed)}")
|
||
else:
|
||
self._log("debug", "轮询结果: 无新消息")
|
||
return parsed, self.sync_buf, result
|
||
|
||
def test_connection(self) -> Tuple[bool, str]:
|
||
if not self.bot_token:
|
||
self._log("warning", "连接测试失败:未登录")
|
||
return False, "未登录,缺少 bot token"
|
||
|
||
url = f"{self.base_url}/ilink/bot/getconfig"
|
||
resp = RequestUtils(headers=self._headers(auth_required=True), timeout=self.timeout).post(url, json={})
|
||
payload = self._json(resp)
|
||
if self._ok(payload):
|
||
self._log("info", "连接测试通过")
|
||
return True, "连接正常"
|
||
message = payload.get("errmsg") or payload.get("message") or "连接失败"
|
||
self._log("warning", f"连接测试失败: {message}")
|
||
return False, message
|