Files
archived-MoviePilot-Plugins/plugins.v2/wechatclawbot/ilink/client.py

1139 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-UINbase64(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