Files
MoviePilot/app/modules/wechatclawbot/wechatclawbot.py
Aqr-K fc0eed08cc fix: preserve directed messaging and channel interaction contracts (#7148)
* fix: retain plugin lifecycle ownership and recover installation state

* fix: preserve directed messaging and channel interaction contracts

* test: cover plugin rollback and uninstall lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 07:49:53 +08:00

2633 lines
104 KiB
Python
Raw Permalink 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.
"""
微信 ClawBot 的 iLink 协议、扫码登录、持久化状态和消息收发。
"""
import base64
import hashlib
import json
import mimetypes
import os
import random
import re
import threading
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
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.adapters.network.http import RequestUtils
from app.application.messaging.ingress import forward_message_to_host
from app.domain.context import Context, MediaInfo
from app.domain.metainfo import MetaInfo
from app.foundation import size as size_tools
from app.runtime.cache import FileCache
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
@dataclass
class ILinkIncomingMessage:
"""
保存经过 iLink 字段兼容处理的入站消息。
:param user_id: 必需的发送者标识
:param text: 提取后的文字;无文字时为 None
:param username: 发送者显示名;未知时为 None,转发时回退到用户标识
:param message_id: 可选消息标识,供模块去重
:param chat_id: 可选来源会话标识
:param context_token: 可选回复上下文令牌,后续发送时复用
:param images: 图片引用及格式元数据列表;无图片时为空列表
:param audio_refs: 语音内部引用列表;无语音时为空列表
:param files: 文件引用、名称及格式等元数据列表;无附件时为空列表
:param raw: 原始入站字典,保留用于内部排查,不加入转发报文
"""
user_id: str
text: Optional[str] = None
username: Optional[str] = None
message_id: Optional[str] = None
chat_id: Optional[str] = None
context_token: Optional[str] = None
images: List[Dict[str, Any]] = field(default_factory=list)
audio_refs: List[str] = field(default_factory=list)
files: List[Dict[str, Any]] = field(default_factory=list)
raw: Dict[str, Any] = field(default_factory=dict)
def to_message_payload(self) -> Dict[str, Any]:
"""
构造统一消息入口需要的渠道报文。
:return: 含渠道、用户和内容的字典;空附件及其他 None 字段省略,文本缺失时使用空字符串
"""
payload = {
"__channel__": "wechatclawbot",
"userid": self.user_id,
"username": self.username or self.user_id,
"text": self.text or "",
"message_id": self.message_id,
"chat_id": self.chat_id,
"context_token": self.context_token,
"images": self.images or None,
"audio_refs": self.audio_refs or None,
"files": self.files or None,
}
return {key: value for key, value in payload.items() if value is not None}
class ILinkClient:
"""
iLink HTTP 客户端,负责二维码登录、消息发送与长轮询。
"""
# 发送报文携带的 iLink 渠道协议版本
channel_version = "1.0.2"
cdn_base_url = "https://novac2c.cdn.weixin.qq.com/c2c"
def __init__(
self,
base_url: str,
bot_token: Optional[str] = None,
account_id: Optional[str] = None,
sync_buf: Optional[str] = None,
timeout: int = 20,
):
"""
保存一次 iLink 会话的请求地址、凭据和轮询游标。
:param base_url: iLink API 基址;空值时使用官方服务地址
:param bot_token: 扫码登录取得的机器人令牌;未登录时为 None
:param account_id: 可选登录账号标识,用于构建发送报文
:param sync_buf: 可选消息轮询游标
:param timeout: 普通请求超时,单位为秒,默认 20
"""
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
def set_credentials(
self,
bot_token: Optional[str],
account_id: Optional[str] = None,
sync_buf: Optional[str] = None,
) -> None:
"""
更新登录凭据,并按需替换轮询游标。
:param bot_token: 新的机器人令牌;None 会清空旧值
:param account_id: 新的账号标识;None 会清空旧值
:param sync_buf: 新轮询游标;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/1.0",
}
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:
"""
生成一个随机的微信 UIN,用于标识请求来源。
"""
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]:
"""
浅复制请求体并补充缺失的渠道版本信息。
已有 base_info 字典会复用并可能被原地补充字段。
:param body: 原始请求体;为空时使用空字典
:return: 含 base_info.channel_version 的请求体
"""
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: Any) -> Dict[str, Any]:
"""
尝试将响应解析为 JSON,处理异常情况。
"""
if not resp:
return {}
try:
payload = resp.json()
return payload if isinstance(payload, dict) else {}
except Exception:
text = (getattr(resp, "text", "") or "").strip()
if not text:
return {}
try:
payload = json.loads(text)
return payload if isinstance(payload, dict) else {}
except Exception:
return {}
@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
@staticmethod
def _normalize_qrcode_url(value: Any) -> Optional[str]:
"""
规范化二维码展示字段,兼容图片 URL、data URL 与裸 base64。
"""
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
lowered = raw.lower()
if lowered.startswith("data:image/"):
return raw
if lowered.startswith("//"):
return f"https:{raw}"
# 某些实现会直接返回二维码图片的 base64 内容,这里自动补齐 data URL 前缀
if len(raw) >= 128 and re.fullmatch(r"[A-Za-z0-9+/=_-]+", raw):
return f"data:image/png;base64,{raw}"
return raw
@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
@staticmethod
def _pick_present_value(obj: Dict[str, Any], keys: List[str]) -> Optional[Any]:
"""
获取已存在的字段值,允许空字符串和 0 作为有效值。
sync_buf 这类游标字段可能会被服务端明确置为空串,表示游标已经回到初始状态。
这里不能复用默认的“忽略空字符串”逻辑,否则会错误沿用上一轮旧游标。
"""
for key in keys:
if key in obj and obj.get(key) is not 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 _ok(payload: Dict[str, Any]) -> bool:
"""
检查 API 响应是否表示成功。
"""
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"}
def _is_send_success(self, payload: Dict[str, Any]) -> bool:
"""
检查 API 响应中是否包含明确的发送成功信号。
"""
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_value = str(err_text).strip().lower()
if err_value not in {"ok", "success", "succeed", "sent"}:
return False
return False
def _is_send_explicit_failure(self, payload: Dict[str, Any]) -> bool:
"""
检查 API 响应中是否包含明确的发送失败信号。
"""
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:
if str(success_flag).strip().lower() in {
"0",
"false",
"fail",
"failed",
"error",
"denied",
}:
return True
state = self._find_first_value(payload, ["status", "state", "send_status"])
if state is not None:
if str(state).strip().lower() 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():
if str(err_text).strip().lower() not in {"ok", "success", "succeed", "sent"}:
return True
return False
def _is_send_http_success(self, resp: Any, payload: Dict[str, Any]) -> bool:
"""
兼容仅以 HTTP 状态确认发送结果的 iLink 服务。
:param resp: 原始 HTTP 响应
:param payload: 已解析的响应内容
:return: HTTP 为 2xx 且内容没有显式失败标记时返回 True;空响应体也可判定成功
"""
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 构建多种格式的候选列表以提高匹配成功率。
"""
raw = str(to_user or "").strip()
if not raw:
return []
candidates = [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]]:
"""
构建多种格式的纯文本消息报文以兼容不同接口。
"""
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},
]
@staticmethod
def _aes_ecb_padded_size(plaintext_size: int) -> int:
"""
计算 AES ECB 加密后的填充大小。
"""
return ((int(plaintext_size) + 1 + 15) // 16) * 16
@staticmethod
def _encrypt_aes_ecb(plaintext: bytes, key: bytes) -> bytes:
"""
使用 AES ECB 模式对明文进行加密。
"""
cipher = AES.new(key, AES.MODE_ECB)
return cipher.encrypt(pad(plaintext, AES.block_size))
@staticmethod
def _encode_media_aes_key(aeskey: bytes) -> str:
"""
将媒体 AES 密钥进行 Base64 编码。
"""
return base64.b64encode(aeskey.hex().encode("ascii")).decode("ascii")
def _build_protocol_text_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": 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": 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 _build_protocol_file_payloads(
self,
user_id: str,
context_token: Optional[str],
download_param: str,
aeskey_b64: str,
cipher_size: int,
raw_size: int,
file_name: str,
mime_type: str,
file_md5: str,
) -> List[Dict[str, Any]]:
"""
构建协议层标准文件消息报文(含多种格式候选)。
"""
media = {
"encrypt_query_param": download_param,
"aes_key": aeskey_b64,
"encrypt_type": 1,
}
file_item = {
"name": file_name,
"file_name": file_name,
"filename": file_name,
"title": file_name,
"size": int(raw_size),
"file_size": int(raw_size),
"raw_size": int(raw_size),
"mid_size": int(cipher_size),
"mime_type": mime_type,
"content_type": mime_type,
"md5": file_md5,
"media": media,
}
msg_base: Dict[str, Any] = {
"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,
}
if context_token:
msg_base["context_token"] = context_token
protocol_candidates = []
for item_type in (6, 5, 3):
msg = dict(msg_base)
msg["item_list"] = [{"type": item_type, "file_item": dict(file_item)}]
protocol_candidates.append({"msg": msg})
simple_candidates = [
{
"to_user": user_id,
"msg_type": "file",
"file": dict(file_item),
},
{
"touser": user_id,
"msgtype": "file",
"file": dict(file_item),
},
{"to": user_id, "type": "file", "file": dict(file_item)},
]
return [*protocol_candidates, *simple_candidates]
def _request_upload_param(
self,
to_user: str,
plaintext: bytes,
media_types: Optional[List[int]] = None,
) -> Tuple[Optional[str], Optional[str], Optional[bytes], Optional[int], Optional[str]]:
"""
按候选媒体类型申请 CDN 上传地址与一次性加密材料。
:param to_user: iLink 接收用户标识,兼容候选格式由发送流程处理
:param plaintext: 待上传的原始字节,用于计算大小和摘要
:param media_types: 按顺序尝试的媒体类型编号;为空时默认图片类型 1
:return: 上传查询参数、完整上传 URL、AES 密钥、填充后字节数及文件标识的五元组;失败时均为 None
"""
rawsize = len(plaintext)
rawfilemd5 = hashlib.md5(plaintext).hexdigest()
filesize = self._aes_ecb_padded_size(rawsize)
filekey = os.urandom(16).hex()
aeskey = os.urandom(16)
last_payload = None
media_type_candidates = media_types or [1]
for media_type in media_type_candidates:
body = self._with_base_info(
{
"filekey": filekey,
"media_type": media_type,
"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)
last_payload = payload or getattr(resp, "text", None)
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 upload_param or upload_full_url:
return (
str(upload_param) if upload_param else None,
str(upload_full_url) if upload_full_url else None,
aeskey,
filesize,
filekey,
)
logger.warning(f"getuploadurl 失败: resp={self._short_text(last_payload)}")
return None, None, None, None, None
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]]:
"""
按 AES ECB 与 PKCS#7 填充上传加密资源。
:param upload_param: 可选 CDN 上传查询参数
:param upload_full_url: 可选完整上传地址,优先于查询参数
:param filekey: 申请上传时生成的文件标识
:param plaintext: 待加密的原始字节
:param aeskey: 申请上传时生成的 AES 密钥
:return: 下载查询参数和密文字节数;HTTP 非 200 或缺少下载参数时返回两个 None
"""
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:
logger.warning("CDN 上传失败: 缺少 upload_url 参数")
return None, None
resp = RequestUtils(
headers={"Content-Type": "application/octet-stream"},
timeout=self.timeout,
).post(upload_url, data=ciphertext)
if getattr(resp, "status_code", None) != 200:
logger.warning(
f"CDN 上传失败: http={getattr(resp, 'status_code', None)}, "
f"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:
logger.warning("CDN 上传成功但缺少 x-encrypted-param")
return None, None
return str(download_param), len(ciphertext)
def _send_payload_candidates(
self,
to_user: str,
payload_candidates: List[Dict[str, Any]],
url_candidates: Optional[List[str]] = None,
) -> bool:
"""
按候选接口与报文逐次尝试发送,首个成功结果即结束。
外层按用户候选重复尝试同一组报文,兼容调用方传入的不同版本报文结构。
:param to_user: iLink 接收用户标识,兼容候选格式由发送流程处理
:param payload_candidates: 完整候选报文;本方法不会根据循环中的用户候选改写其接收字段
:param url_candidates: 候选发送地址;为空时尝试默认地址及带 bot_type 的地址
:return: 明确业务成功或 HTTP 2xx 且无显式失败时返回 True;全部尝试失败返回 False
"""
url_candidates = url_candidates or [
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):
for url in url_candidates:
for index, 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):
logger.info(f"发送消息成功: to_user={user_id}, variant={index}")
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)}"
logger.debug(
f"发送候选失败: to_user={user_id}, variant={index}, "
f"{last_error}, req={self._short_text(request_body)}, "
f"resp={self._short_text(payload)}"
)
logger.warning(f"发送消息失败: to_user={to_user}, {last_error}")
return False
def get_qrcode(self) -> Dict[str, Any]:
"""
请求登录二维码并兼容不同响应字段名称。
:return: 含 success、二维码标识、图片或登录地址、原始响应及可选错误说明的字典
"""
url = f"{self.base_url}/ilink/bot/get_bot_qrcode?bot_type=3"
logger.debug(f"请求二维码: {url}")
resp = RequestUtils(
headers=self._headers(auth_required=False), timeout=self.timeout
).get_res(url)
payload = self._json(resp)
if not payload:
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_url = self._normalize_qrcode_url(qrcode_url)
if not qrcode_url and qrcode:
qrcode_url = f"https://liteapp.weixin.qq.com/q/7GiQu1?qrcode={qrcode}&bot_type=3"
return {
"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"),
}
def get_qrcode_status(self, qrcode: str) -> Dict[str, Any]:
"""
查询二维码状态,并把返回的登录凭据写入当前客户端。
:param qrcode: 待查询的二维码标识或票据
:return: 含 success、状态、令牌、账号和原始响应的字典;空响应使用 waiting 状态并标记失败
"""
url = f"{self.base_url}/ilink/bot/get_qrcode_status"
resp = RequestUtils(
headers=self._headers(auth_required=False), timeout=self.timeout
).get_res(url, params={"qrcode": qrcode})
payload = self._json(resp)
if not payload:
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:
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"
)
return {
"success": self._ok(payload),
"status": str(state).lower(),
"token": token,
"account_id": account_id,
"base_url": base_url,
"qrcode_url": self._normalize_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")
),
"raw": payload,
"message": payload.get("errmsg") or payload.get("message"),
}
def send_text(self, to_user: str, text: str, context_token: Optional[str] = None) -> bool:
"""
按标准与兼容报文格式发送纯文本。
:param to_user: iLink 接收用户标识,兼容候选格式由发送流程处理
:param text: 必需的非空正文
:param context_token: 最近一次入站消息携带的可选会话上下文令牌
:return: 首个候选发送成功返回 True;凭据或内容缺失及全部尝试失败返回 False
"""
if not self.bot_token:
logger.warning("发送消息失败:bot token 未配置")
return False
if not to_user or not text:
logger.warning("发送消息失败:to_user 或 text 为空")
return False
payload_candidates = [
self._build_protocol_text_payload(
user_id=str(to_user), text=text, context_token=context_token
),
*self._build_text_payloads(str(to_user), text),
]
return self._send_payload_candidates(to_user=to_user, payload_candidates=payload_candidates)
def send_image_text_png(
self,
to_user: str,
image_bytes: bytes,
text: str,
context_token: Optional[str] = None,
) -> bool:
"""
加密上传图片,再分别发送文字和图片两条消息。
失败时不撤回已发送的文字,继续尝试其他用户候选可能重复发送前一部分。
:param to_user: iLink 接收用户标识,兼容候选格式由发送流程处理
:param image_bytes: 必需的图片原始字节,不在此方法转换格式
:param text: 必需的非空正文
:param context_token: 最近一次入站消息携带的可选会话上下文令牌
:return: 某个用户候选的文字和图片均发送成功返回 True,否则返回 False
"""
if not self.bot_token:
logger.warning("发送图文失败:bot token 未配置")
return False
if not to_user or not image_bytes or not text:
logger.warning("发送图文失败:to_user 或 image_bytes 或 text 为空")
return False
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, media_types=[1]
)
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 = [
{"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:
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": [item],
}
if context_token:
msg["context_token"] = context_token
if not self._send_payload_candidates(
to_user=user_id,
payload_candidates=[{"msg": msg}],
):
sent_all = False
break
if sent_all:
return True
return False
def send_image_png(
self, to_user: str, image_bytes: bytes, context_token: Optional[str] = None
) -> bool:
"""
加密上传并发送图片引用。
:param to_user: iLink 接收用户标识,兼容候选格式由发送流程处理
:param image_bytes: 图片原始字节;方法名称不代表会转换为 PNG
:param context_token: 最近一次入站消息携带的可选会话上下文令牌
:return: 首个用户候选发送成功返回 True;参数缺失、上传或发送失败返回 False
"""
if not self.bot_token:
logger.warning("发送图片失败:bot token 未配置")
return False
if not to_user or not image_bytes:
logger.warning("发送图片失败:to_user 或 image_bytes 为空")
return False
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, media_types=[1]
)
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,
)
if self._send_payload_candidates(
to_user=user_id,
payload_candidates=[body],
):
return True
return False
def send_file_bytes(
self,
to_user: str,
file_bytes: bytes,
file_name: str,
mime_type: str,
context_token: Optional[str] = None,
) -> bool:
"""
申请上传、加密文件并尝试兼容附件报文。
:param to_user: iLink 接收用户标识,兼容候选格式由发送流程处理
:param file_bytes: 非空文件原始字节
:param file_name: 显示文件名;空值时使用 attachment
:param mime_type: 文件 MIME 类型;空值时使用 application/octet-stream
:param context_token: 最近一次入站消息携带的可选会话上下文令牌
:return: 首个用户候选发送成功返回 True;参数缺失、上传或发送失败返回 False
"""
if not self.bot_token:
logger.warning("发送文件失败:bot token 未配置")
return False
if not to_user or not file_bytes:
logger.warning("发送文件失败:to_user 或 file_bytes 为空")
return False
file_name = file_name or "attachment"
mime_type = mime_type or "application/octet-stream"
file_md5 = hashlib.md5(file_bytes).hexdigest()
for user_id in self._build_user_candidates(to_user):
upload_param, upload_full_url, aeskey, _, filekey = self._request_upload_param(
user_id, file_bytes, media_types=[2, 6, 1]
)
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=file_bytes,
aeskey=aeskey,
)
if not download_param or not cipher_size:
continue
aeskey_b64 = self._encode_media_aes_key(aeskey)
payload_candidates = self._build_protocol_file_payloads(
user_id=user_id,
context_token=context_token,
download_param=download_param,
aeskey_b64=aeskey_b64,
cipher_size=cipher_size,
raw_size=len(file_bytes),
file_name=file_name,
mime_type=mime_type,
file_md5=file_md5,
)
if self._send_payload_candidates(
to_user=user_id,
payload_candidates=payload_candidates,
):
return True
return False
@classmethod
def _encode_ref_payload(cls, kind: str, payload: Dict[str, Any]) -> str:
"""
将附件元信息编码为 wxclaw:// 协议链接。
"""
encoded = base64.urlsafe_b64encode(
json.dumps(payload, ensure_ascii=False).encode("utf-8")
).decode("ascii").rstrip("=")
return f"wxclaw://{kind}/{encoded}"
def _build_attachment_ref(
self,
kind: str,
attachment: Dict[str, Any],
default_name: Optional[str] = None,
) -> Optional[Tuple[str, Dict[str, Any]]]:
"""
从附件信息构建 wxclaw 协议引用。
下行报文可能把下载地址与密钥放在附件的 `media` 子对象中
(如 `image_item.media.full_url` / `media.aes_key`),也可能直接放在附件顶层,
这里统一按“附件顶层优先、media 子对象兜底”的顺序提取字段。
"""
if not isinstance(attachment, dict):
return None
media = attachment.get("media")
media = media if isinstance(media, dict) else {}
url_keys = ["full_url", "download_url", "url", "cdnurl", "cdn_url", "file_url"]
download_url = self._pick_value(attachment, url_keys) or self._pick_value(
media, url_keys
)
if not download_url:
return None
aeskey_keys = ["aeskey", "aes_key", "encoding_aes_key", "encrypt_key"]
payload = {
"url": download_url,
"aeskey": self._pick_value(attachment, aeskey_keys)
or self._pick_value(media, aeskey_keys),
"encrypt_type": self._pick_value(attachment, ["encrypt_type"])
or self._pick_value(media, ["encrypt_type"]),
"mime_type": self._pick_value(attachment, ["mime_type", "content_type"])
or self._pick_value(media, ["mime_type", "content_type"]),
"name": self._pick_value(attachment, ["name", "filename", "file_name"])
or default_name,
"size": self._pick_value(attachment, ["size", "file_size"])
or self._pick_value(media, ["size", "file_size"]),
}
ref = self._encode_ref_payload(kind=kind, payload=payload)
return ref, payload
@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 _append_item_list_entry(
self,
one: Dict[str, Any],
append_text: Callable[[Any], None],
images: List[Dict[str, Any]],
audio_refs: List[str],
files: List[Dict[str, Any]],
) -> None:
"""
把 item_list 的单个子项分派为文本或媒体附件。
iLink 下行报文的 item_list 存在两套类型约定:数字 `type` 与子项键名。
这里按实际出现的子项键名分派,兼容公开资料的 `2 图片/3 语音/4 文件/5 视频`
以及历史实现里 `file_item` 使用 `type=5/6` 的报文,避免媒体消息被静默丢弃。
"""
candidates = (
("text_item", "text", None),
("image_item", "image", None),
("voice_item", "voice", "voice.amr"),
("file_item", "file", None),
("video_item", "file", "video.mp4"),
)
for key, kind, default_name in candidates:
child = one.get(key)
if not isinstance(child, dict):
continue
if kind == "text":
append_text(child.get("text"))
return
if kind == "voice":
append_text(child.get("content"))
attachment_ref = self._build_attachment_ref(
kind, child, default_name=default_name
)
if not attachment_ref:
return
ref, payload = attachment_ref
if kind == "voice":
audio_refs.append(ref)
return
entry = {
"ref": ref,
"name": payload.get("name"),
"mime_type": payload.get("mime_type"),
"size": payload.get("size"),
}
if kind == "image":
images.append(entry)
else:
files.append(entry)
return
def _build_poll_result(
self,
success: bool,
payload: Optional[Dict[str, Any]] = None,
message: Optional[str] = None,
item_count: int = 0,
parsed_count: int = 0,
) -> Dict[str, Any]:
"""
构建轮询结果摘要。
"""
payload = payload or {}
resolved_message = message or self._find_first_value(
payload, ["errmsg", "message", "error", "error_msg", "detail"]
)
return {
"success": success,
"raw": payload,
"message": self._short_text(resolved_message) if resolved_message else None,
"item_count": item_count,
"parsed_count": parsed_count,
}
def _parse_incoming(self, item: Dict[str, Any]) -> Optional[ILinkIncomingMessage]:
"""
将 getupdates 返回的原始事件归一化为 MoviePilot 可消费的入站消息。
iLink 返回结构存在多种嵌套和字段别名,这里集中做兼容处理,统一提取
用户、消息ID、文本、图片、语音和文件附件。
"""
if not isinstance(item, dict):
return None
message = item
for key in ["message", "msg", "event", "payload", "data", "body"]:
child = item.get(key)
if isinstance(child, dict):
message = child
break
sender_value = (
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 {}
)
sender: Dict[str, Any] = sender_value if isinstance(sender_value, 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
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", "seq"]
) or self._pick_value(
item, ["message_id", "msg_id", "id", "client_msg_id", "msgId", "seq"]
)
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"]
)
text_parts: List[str] = []
images: List[Dict[str, Any]] = []
audio_refs: List[str] = []
files: List[Dict[str, Any]] = []
def append_text(value: Any) -> None:
"""
收集非空消息文本并处理嵌套载荷,避免把空字段加入正文。
"""
if value in (None, ""):
return
if isinstance(value, dict):
value = self._pick_value(value, ["content", "text", "value", "message"])
if value in (None, ""):
return
text_value = str(value).strip()
if text_value and text_value not in text_parts:
text_parts.append(text_value)
msgtype = str(
message.get("msgtype") or message.get("msg_type") or message.get("type") or ""
).lower()
if msgtype == "text":
append_text((message.get("text") or {}).get("content") if isinstance(message.get("text"), dict) else None)
append_text(message.get("content"))
elif msgtype == "image":
image_data = message.get("image") or {}
image_ref = self._build_attachment_ref("image", image_data)
if image_ref:
ref, payload = image_ref
images.append(
{
"ref": ref,
"name": payload.get("name"),
"mime_type": payload.get("mime_type"),
"size": payload.get("size"),
}
)
elif msgtype == "file":
file_data = message.get("file") or {}
file_ref = self._build_attachment_ref("file", file_data)
if file_ref:
ref, payload = file_ref
files.append(
{
"ref": ref,
"name": payload.get("name"),
"mime_type": payload.get("mime_type"),
"size": payload.get("size"),
}
)
elif msgtype == "voice":
voice_data = message.get("voice") or {}
append_text((voice_data or {}).get("content"))
voice_ref = self._build_attachment_ref("voice", voice_data, default_name="voice.amr")
if voice_ref:
ref, _ = voice_ref
audio_refs.append(ref)
elif msgtype == "mixed":
for msg_item in ((message.get("mixed") or {}).get("msg_item") or []):
item_type = str(msg_item.get("msgtype") or msg_item.get("type") or "").lower()
if item_type == "text":
append_text((msg_item.get("text") or {}).get("content"))
elif item_type == "image":
image_ref = self._build_attachment_ref("image", msg_item.get("image") or {})
if image_ref:
ref, payload = image_ref
images.append(
{
"ref": ref,
"name": payload.get("name"),
"mime_type": payload.get("mime_type"),
"size": payload.get("size"),
}
)
elif item_type == "file":
file_ref = self._build_attachment_ref("file", msg_item.get("file") or {})
if file_ref:
ref, payload = file_ref
files.append(
{
"ref": ref,
"name": payload.get("name"),
"mime_type": payload.get("mime_type"),
"size": payload.get("size"),
}
)
elif item_type == "voice":
append_text(((msg_item.get("voice") or {}).get("content") or "").strip())
voice_ref = self._build_attachment_ref(
"voice",
msg_item.get("voice") or {},
default_name="voice.amr",
)
if voice_ref:
ref, _ = voice_ref
audio_refs.append(ref)
raw_item_list = message.get("item_list")
item_list: List[Any] = raw_item_list if isinstance(raw_item_list, list) else []
for one in item_list:
if not isinstance(one, dict):
continue
self._append_item_list_entry(one, append_text, images, audio_refs, files)
if not text_parts:
append_text(
self._pick_value(message, ["content", "message", "msg", "text", "body", "msg_content", "msgContent"])
)
append_text(
self._pick_value(item, ["content", "message", "msg", "text", "body", "msg_content", "msgContent"])
)
append_text(self._find_first_value(message, ["content", "text", "message", "msg", "body", "cmd"]))
text = "\n".join(part for part in text_parts if part).strip() or None
return ILinkIncomingMessage(
user_id=str(user_id),
text=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,
images=images,
audio_refs=audio_refs,
files=files,
raw=item,
)
def _extract_updates(
self, payload: Dict[str, Any]
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""
提取轮询结果中的消息列表与游标。
线上存在两种等价字段命名:较新的实现返回 `get_updates_buf`,
部分实例仍然返回 `sync_buf`。两者都表示下一轮轮询应携带的游标。
"""
sync_buf = self._pick_present_value(
payload, ["get_updates_buf", "sync_buf", "syncBuf"]
)
items = payload.get("msgs")
if isinstance(items, list):
return items, sync_buf
return [], sync_buf
@staticmethod
def _has_canonical_poll_shape(payload: Dict[str, Any]) -> bool:
"""
官方响应至少应包含顶层 msgs 列表。
"""
return isinstance(payload.get("msgs"), list)
def _resolve_poll_success(self, payload: Dict[str, Any]) -> Optional[bool]:
"""
判断 getupdates 是否给出了明确的成功/失败信号。
轮询接口不能沿用“只要没有明显报错就算成功”的宽松策略,否则服务端返回旧消息列表、
但状态码其实失败时,会被误判为可消费响应,导致旧消息再次进入业务链路。
返回 `None` 表示响应里没有显式状态,需要交给协议结构继续判断。
"""
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"]
)
if isinstance(success_flag, bool):
return success_flag
if success_flag is not None:
return str(success_flag).strip().lower() in {
"1",
"true",
"ok",
"success",
"succeed",
}
state = self._find_first_value(payload, ["status", "state"])
if state is not None:
lowered = str(state).strip().lower()
if lowered in {"ok", "success", "succeed", "done"}:
return True
if lowered in {"failed", "fail", "error", "denied", "blocked"}:
return False
return None
def poll_updates(
self, timeout_seconds: int = 25
) -> Tuple[List[ILinkIncomingMessage], Optional[str], Dict[str, Any]]:
"""
执行一次长轮询,仅消费包含顶层 msgs 列表的成功响应。
有效响应中的新游标会写入当前客户端;缺少显式状态时可依据官方结构接受响应。
:param timeout_seconds: 长轮询等待预算,单位为秒;HTTP 超时在此基础上增加 10 秒
:return: 规范化消息列表、当前游标和轮询诊断结果的三元组;失败时消息列表为空且保留旧游标
"""
if not self.bot_token:
return [], self.sync_buf, {"success": False, "message": "bot token 未配置"}
url = f"{self.base_url}/ilink/bot/getupdates"
request_body = self._with_base_info({"get_updates_buf": self.sync_buf or ""})
resp = RequestUtils(
headers=self._headers(auth_required=True),
timeout=timeout_seconds + 10,
).post(url, json=request_body)
payload = self._json(resp)
explicit_success = self._resolve_poll_success(payload)
has_canonical_shape = self._has_canonical_poll_shape(payload)
# 某些 iLink 部署不会返回 ret/success,但顶层 msgs + sync_buf 已经足够表明
# 这是一次有效的轮询响应;只有出现显式失败信号时才应拒绝消费。
success = bool(payload) and (
explicit_success is True
or (explicit_success is None and has_canonical_shape)
)
last_message = None
if payload and explicit_success is False:
last_message = self._find_first_value(
payload, ["errmsg", "message", "error", "error_msg", "detail"]
) or self._short_text(payload)
if not payload:
return [], self.sync_buf, self._build_poll_result(
success=False,
message="轮询返回空响应",
)
if not success:
return [], self.sync_buf, self._build_poll_result(
success=False,
payload=payload,
message=last_message or "轮询响应未明确成功",
)
if not has_canonical_shape:
logger.warning(
"getupdates 返回非官方结构,已拒绝消费: %s",
self._short_text(payload),
)
return [], self.sync_buf, self._build_poll_result(
success=False,
payload=payload,
message="轮询响应结构非官方,缺少顶层 msgs 字段",
)
items, sync_buf = self._extract_updates(payload)
parsed: List[ILinkIncomingMessage] = []
for item in items:
message = self._parse_incoming(item)
if message:
parsed.append(message)
if sync_buf is not None:
self.sync_buf = str(sync_buf)
return parsed, self.sync_buf, self._build_poll_result(
success=True,
payload=payload,
item_count=len(items),
parsed_count=len(parsed),
)
def test_connection(self) -> Tuple[bool, str]:
"""
请求账号配置接口以探测当前凭据的连通性。
:return: 成功标记与说明的二元组;缺少令牌返回失败,缺少 ilink_user_id 的特定响应按连通处理
"""
if not self.bot_token:
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):
return True, "连接正常"
err_message = payload.get("errmsg") or payload.get("message") or "连接失败"
if "ilink_user_id required" in str(err_message).strip().lower():
return True, "连接正常"
return False, err_message
class WechatClawBot:
"""
管理微信 ClawBot 登录缓存、轮询线程、互动目标和通知发送。
"""
_default_base_url = "https://ilinkai.weixin.qq.com"
# 本地二维码有效期,按最后更新的 Unix 秒级时间判断
_qrcode_ttl_seconds = 240
# 无显式目标时优先选择的近期互动窗口,单位为秒
_active_target_ttl_seconds = 24 * 60 * 60
# 停止轮询时等待线程退出的上限,单位为秒
_poll_join_timeout_seconds = 5
@classmethod
def _build_cache_key(cls, identity: str) -> str:
"""
根据稳定渠道身份构建缓存键。
"""
safe_name = hashlib.md5(str(identity or "wechatclawbot").encode("utf-8")).hexdigest()[:12]
return f"__wechatclawbot_state_{safe_name}__"
def __init__(
self,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
name: Optional[str] = None,
identity: Optional[str] = None,
auto_start_polling: bool = True,
**kwargs: object,
) -> None:
"""
恢复持久化登录状态,并按配置启动消息轮询。
:param WECHATCLAWBOT_BASE_URL: iLink 服务基址,默认使用官方地址
:param WECHATCLAWBOT_DEFAULT_TARGET: 可选默认通知目标
:param WECHATCLAWBOT_ADMINS: 逗号分隔的管理员标识,保存在实例中
:param WECHATCLAWBOT_POLL_TIMEOUT: 轮询等待秒数;默认 25,最小 10,无效输入回退到 25
:param name: 显示实例名;为空时使用 wechatclawbot
:param identity: 稳定缓存身份;为空时回退到实例名
:param auto_start_polling: 已有令牌或扫码登录成功时是否自动启动轮询;预览实例可禁用
:param kwargs: 通知实例附加配置,当前不使用
"""
self._config_name = name or "wechatclawbot"
self._identity = str(identity or self._config_name)
self._base_url = (WECHATCLAWBOT_BASE_URL or self._default_base_url).rstrip("/")
self._default_target = (WECHATCLAWBOT_DEFAULT_TARGET or "").strip() or None
self._auto_start_polling = bool(auto_start_polling)
self._admins = [
admin.strip()
for admin in str(WECHATCLAWBOT_ADMINS or "").split(",")
if admin.strip()
]
try:
self._poll_timeout = max(10, int(WECHATCLAWBOT_POLL_TIMEOUT or 25))
except Exception:
self._poll_timeout = 25
self._cache_key = self._build_cache_key(self._identity)
self._legacy_cache_key = self._build_cache_key(self._config_name)
self._filecache = FileCache()
self._lock = threading.Lock()
self._stop_event = threading.Event()
self._poll_thread: Optional[threading.Thread] = None
self._state = self._load_state()
if self._state.get("bot_token") and self._auto_start_polling:
self._start_polling()
def _load_state(self) -> Dict[str, Any]:
"""
恢复稳定身份下的登录状态,必要时迁移旧名称缓存。
稳定身份下无缓存时复制旧名称缓存并删除旧键;损坏内容只在内存中重置,后续保存时覆盖。
:return: 包含凭据、二维码、游标和互动目标的状态字典;缓存缺失或损坏时使用初始状态
"""
content = self._filecache.get(self._cache_key)
if not content and self._legacy_cache_key != self._cache_key:
content = self._filecache.get(self._legacy_cache_key)
if content:
self._filecache.set(self._cache_key, content)
self._filecache.delete(self._legacy_cache_key)
if not content:
return {
"bot_token": None,
"account_id": None,
"sync_buf": None,
"qrcode": {},
"known_targets": {},
"user_context_tokens": {},
"base_url": self._base_url,
}
try:
data = json.loads(content.decode("utf-8"))
if not isinstance(data, dict):
raise ValueError("invalid state")
data.setdefault("qrcode", {})
data.setdefault("known_targets", {})
data.setdefault("user_context_tokens", {})
data.setdefault("base_url", self._base_url)
return data
except Exception as err:
logger.warning(f"加载登录状态失败,已重置缓存:{err}")
return {
"bot_token": None,
"account_id": None,
"sync_buf": None,
"qrcode": {},
"known_targets": {},
"user_context_tokens": {},
"base_url": self._base_url,
}
def _save_state(self) -> None:
"""
将当前状态持久化到文件缓存。
"""
self._state["base_url"] = self._base_url
self._filecache.set(
self._cache_key,
json.dumps(self._state, ensure_ascii=False).encode("utf-8"),
)
def _build_client(self) -> ILinkClient:
"""
根据当前持久化状态构建一次性的 iLink 客户端。
"""
return ILinkClient(
base_url=self._state.get("base_url") or self._base_url,
bot_token=self._state.get("bot_token"),
account_id=self._state.get("account_id"),
sync_buf=self._state.get("sync_buf"),
timeout=max(self._poll_timeout, 20),
)
def _update_state(self, **kwargs: object) -> None:
"""
更新并保存持久化状态。
"""
with self._lock:
self._state.update(kwargs)
self._save_state()
def _clear_login_state(self) -> None:
"""
清除令牌、账号、轮询游标和用户上下文令牌并持久化。
保留二维码和已知互动目标;轮询循环会因令牌缺失而退出。
"""
with self._lock:
self._state["bot_token"] = None
self._state["account_id"] = None
self._state["sync_buf"] = None
self._state["user_context_tokens"] = {}
self._save_state()
def _qrcode_expired(self, updated_at: Optional[int]) -> bool:
"""
检查二维码是否过期。
"""
if not updated_at:
return True
return int(time.time()) - int(updated_at) > self._qrcode_ttl_seconds
def _remember_target(
self, user_id: str, username: Optional[str], context_token: Optional[str]
) -> None:
"""
保存发送者最近活动时间及可选回复上下文令牌。
:param user_id: 发送者标识;为空时忽略
:param username: 显示名称;为空时回退到用户标识
:param context_token: 可选新上下文令牌;为空时保留此前令牌
"""
if not user_id:
return
now_ts = int(time.time())
with self._lock:
known_targets = self._state.setdefault("known_targets", {})
known_targets[str(user_id)] = {
"username": username or str(user_id),
"last_active": now_ts,
}
if context_token:
tokens = self._state.setdefault("user_context_tokens", {})
tokens[str(user_id)] = str(context_token)
self._save_state()
def _get_context_token(self, user_id: str) -> Optional[str]:
"""
获取特定用户的上下文 Token。
"""
tokens = self._state.get("user_context_tokens") or {}
token = tokens.get(str(user_id))
return str(token) if token else None
def _get_targets(self, userid: Optional[str] = None) -> List[str]:
"""
按显式用户、默认目标和互动记录选择通知接收者。
:param userid: 显式接收用户;为空时使用默认目标,再回退到近期活跃或全部已知用户
:return: 目标标识列表;优先选择 24 小时内活跃用户,无活跃用户时回退到全部已知用户并排序
"""
if userid:
return [str(userid)]
if self._default_target:
return [self._default_target]
now_ts = int(time.time())
known_targets = self._state.get("known_targets") or {}
active_targets = [
target
for target, data in known_targets.items()
if isinstance(data, dict)
and now_ts - int(data.get("last_active") or 0) <= self._active_target_ttl_seconds
]
if active_targets:
return sorted(active_targets)
return sorted(known_targets.keys())
@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
@staticmethod
def _split_content(content: str, max_bytes: int = 3000) -> List[str]:
"""
按 UTF-8 字节数拆分文本并保留完整字符。
:param content: 完整正文;每块首尾空白会移除
:param max_bytes: 每块目标字节上限,默认 3000;必须足以容纳单个字符
:return: 按原顺序排列的非空文本块
"""
if not content:
return []
chunks: List[str] = []
current = bytearray()
for line in content.splitlines():
encoded = (line + "\n").encode("utf-8")
if len(encoded) > max_bytes:
if current:
chunks.append(current.decode("utf-8", errors="replace").strip())
current = bytearray()
start = 0
while start < len(encoded):
end = min(start + max_bytes, len(encoded))
while start < end < len(encoded) and (encoded[end] & 0xC0) == 0x80:
end -= 1
chunks.append(encoded[start:end].decode("utf-8", errors="replace").strip())
start = end
continue
if len(current) + len(encoded) > max_bytes:
chunks.append(current.decode("utf-8", errors="replace").strip())
current = bytearray()
current += encoded
if current:
chunks.append(current.decode("utf-8", errors="replace").strip())
return [chunk for chunk in chunks if chunk]
@staticmethod
def _compose_text(
title: Optional[str] = None,
text: Optional[str] = None,
link: Optional[str] = None,
) -> str:
"""
组合标题、文本和链接,生成消息内容。
"""
parts = []
if title:
parts.append(str(title).strip())
if text:
parts.append(str(text).replace("\n\n", "\n"))
if link:
parts.append(f"查看详情:{link}")
return "\n\n".join(part for part in parts if part).strip()
@staticmethod
def _guess_mime_type(file_path: Path, file_bytes: bytes) -> str:
"""
猜测文件 MIME 类型。
"""
guessed = mimetypes.guess_type(file_path.name)[0]
if guessed:
return guessed
if file_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if file_bytes.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if file_bytes.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
return "application/octet-stream"
@staticmethod
def _load_remote_image(image: str) -> Optional[bytes]:
"""
加载远程图片并返回二进制数据。
"""
image_url = str(image or "").strip()
if not image_url:
return None
if image_url.startswith("data:image/"):
try:
_, raw = image_url.split(",", 1)
return base64.b64decode(raw)
except Exception:
return None
if image_url.startswith("/"):
image_url = get_runtime_setting('MP_DOMAIN')(image_url)
if not image_url.lower().startswith("http"):
return None
try:
resp = RequestUtils(
timeout=20,
proxies=get_runtime_setting('PROXY'),
ua=get_runtime_setting('USER_AGENT'),
).get_res(image_url)
if resp and resp.status_code == 200 and resp.content:
content_type = (resp.headers.get("Content-Type") or "").lower()
if not content_type or "image" in content_type:
content = resp.content
return content if isinstance(content, bytes) else None
except Exception as err:
logger.warning(f"加载图片失败:{err}")
return None
def get_state(self) -> bool:
"""
检查本地缓存是否保存了登录令牌。
:return: 有令牌时返回 True;不代表服务端已验证令牌或轮询线程正在运行
"""
return bool(self._state.get("bot_token"))
def stop(self) -> bool:
"""
请求停止轮询并最多等待 5 秒,保留未退出线程以便重试。
:return: 轮询线程不存在或已退出返回 True,仍存活返回 False
"""
self._stop_event.set()
poll_thread = self._poll_thread
if (
poll_thread
and poll_thread.is_alive()
and poll_thread is not threading.current_thread()
):
poll_thread.join(timeout=self._poll_join_timeout_seconds)
if poll_thread and poll_thread.is_alive():
logger.error("微信 ClawBot 消息轮询线程未在关闭预算内退出")
return False
self._poll_thread = None
return True
def _start_polling(self) -> None:
"""
启动消息轮询线程。
"""
if not self._state.get("bot_token"):
return
if self._poll_thread and self._poll_thread.is_alive():
return
self._stop_event.clear()
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
self._poll_thread.start()
logger.info("消息轮询线程已启动")
def _poll_loop(self) -> None:
"""
轮询并先保存游标,再同步转发每条入站消息。
入口转发失败只记录日志,不回滚游标,因此失败消息不会由本循环保证重投。
轮询异常依次等待 1、2、5、10、30 秒,连续失败 10 次会清除登录状态并结束轮询。
"""
consecutive_failures = 0
backoff = [1, 2, 5, 10, 30]
while not self._stop_event.is_set() and self._state.get("bot_token"):
try:
client = self._build_client()
messages, sync_buf, result = client.poll_updates(
timeout_seconds=self._poll_timeout
)
if sync_buf is not None and sync_buf != self._state.get("sync_buf"):
self._update_state(sync_buf=sync_buf)
if not result.get("success"):
raise RuntimeError(result.get("message") or "poll failed")
for message in messages:
self._remember_target(
user_id=message.user_id,
username=message.username,
context_token=message.context_token,
)
try:
if not self._forward_to_message_chain(
message.to_message_payload()
):
logger.error(
"转发微信 ClawBot 消息失败:"
f"message_id={message.message_id}"
)
except Exception as err:
logger.error(
f"转发微信 ClawBot 消息失败:message_id={message.message_id}, error={err}"
)
consecutive_failures = 0
except Exception as err:
consecutive_failures += 1
delay = backoff[min(consecutive_failures - 1, len(backoff) - 1)]
logger.warning(f"轮询异常,{delay}s 后重试:{err}")
if consecutive_failures >= 10:
logger.error("轮询连续失败,已清理登录状态")
self._clear_login_state()
break
self._stop_event.wait(delay)
def _forward_to_message_chain(self, payload: Dict[str, Any]) -> bool:
"""
把 WeChatClawBot 轮询消息同步转交统一消息入口。
"""
return forward_message_to_host(payload, self._config_name)
def _build_known_targets(self) -> List[Dict[str, Any]]:
"""
把已记录的微信接收方状态转换为可查询列表。
"""
known_targets = self._state.get("known_targets") or {}
items = []
for userid, data in known_targets.items():
if not isinstance(data, dict):
continue
items.append(
{
"userid": userid,
"username": data.get("username") or userid,
"last_active": data.get("last_active"),
}
)
return sorted(items, key=lambda item: item.get("last_active") or 0, reverse=True)
def refresh_qrcode(self) -> Dict[str, Any]:
"""
未登录时请求并持久化新二维码;已登录时只返回本地状态。
:return: 状态字典,二维码更新时含 Unix 秒级时间戳;获取失败时返回接口的失败结果
"""
if self._state.get("bot_token"):
return self.get_status(refresh_remote=False)
client = ILinkClient(
base_url=self._base_url,
timeout=max(self._poll_timeout, 20),
)
result = client.get_qrcode()
if not result.get("success"):
return result
qrcode = {
"qrcode": result.get("qrcode"),
"qrcode_url": result.get("qrcode_url"),
"status": "waiting",
"updated_at": int(time.time()),
}
self._update_state(qrcode=qrcode)
return self.get_status(refresh_remote=False)
def get_status(
self,
refresh_remote: bool = True,
auto_generate_qrcode: bool = False,
) -> Dict[str, Any]:
"""
查询登录与二维码状态,必要时更新持久化凭据并启动轮询。
状态结果的 success 固定为 True,不代表远端登录校验成功;轮询自动启动受实例配置控制。
:param refresh_remote: 未登录且已有二维码时是否请求远端扫码状态
:param auto_generate_qrcode: 二维码缺失或超过本地 240 秒有效期时是否自动生成
:return: 包含 success、connected、二维码、账号及已知目标的状态字典;connected 只依据本地令牌
"""
qrcode = self._state.get("qrcode") or {}
if (
auto_generate_qrcode
and not self._state.get("bot_token")
and (not qrcode.get("qrcode") or self._qrcode_expired(qrcode.get("updated_at")))
):
self.refresh_qrcode()
qrcode = self._state.get("qrcode") or {}
if refresh_remote and not self._state.get("bot_token") and qrcode.get("qrcode"):
client = ILinkClient(
base_url=self._base_url,
timeout=max(self._poll_timeout, 20),
)
result = client.get_qrcode_status(str(qrcode.get("qrcode")))
updated_qrcode = dict(qrcode)
updated_qrcode["status"] = result.get("status") or updated_qrcode.get("status") or "waiting"
updated_qrcode["updated_at"] = int(time.time())
if result.get("qrcode_url"):
updated_qrcode["qrcode_url"] = result.get("qrcode_url")
update_payload: Dict[str, Any] = {"qrcode": updated_qrcode}
if result.get("token"):
update_payload.update(
{
"bot_token": result.get("token"),
"account_id": str(result.get("account_id") or "") or None,
"sync_buf": None,
"base_url": (result.get("base_url") or self._base_url).rstrip("/"),
}
)
self._base_url = (result.get("base_url") or self._base_url).rstrip("/")
self._update_state(**update_payload)
if self._auto_start_polling:
self._start_polling()
else:
self._update_state(**update_payload)
qrcode = self._state.get("qrcode") or {}
return {
"success": True,
"connected": bool(self._state.get("bot_token")),
"account_id": self._state.get("account_id"),
"qrcode": qrcode.get("qrcode"),
"qrcode_url": qrcode.get("qrcode_url"),
"qrcode_status": qrcode.get("status"),
"qrcode_updated_at": qrcode.get("updated_at"),
"known_targets": self._build_known_targets(),
"default_target": self._default_target,
"base_url": self._base_url,
}
def logout(self) -> Dict[str, Any]:
"""
请求停止轮询并清空本地登录、二维码及上下文令牌。
保留已知互动目标;不向远端发送账号注销请求。
:return: 本地状态清理成功的结果字典;不保证停止请求返回时轮询线程已退出
"""
self.stop()
with self._lock:
self._state["bot_token"] = None
self._state["account_id"] = None
self._state["sync_buf"] = None
self._state["qrcode"] = {}
self._state["user_context_tokens"] = {}
self._save_state()
return {"success": True, "message": "已退出微信 ClawBot 登录"}
def test_connection(self) -> Tuple[bool, str]:
"""
使用当前缓存令牌探测 iLink 服务连接。
:return: 状态与说明的二元组;未登录直接返回失败
"""
if not self._state.get("bot_token"):
return False, "未登录,请先扫码完成绑定"
return self._build_client().test_connection()
@classmethod
def migrate_cached_state(
cls,
old_name: Optional[str],
new_name: Optional[str],
cleanup_old: bool = False,
overwrite: bool = False,
) -> Tuple[bool, str]:
"""
按配置名称迁移登录缓存。
:param old_name: 来源配置名,不能为空
:param new_name: 目标配置名,不能为空
:param cleanup_old: 复制成功后是否删除旧缓存,默认 False
:param overwrite: 目标已有缓存时是否覆盖,默认 False
:return: 是否完成或可安全跳过与说明的二元组;无来源缓存或不覆盖已有目标也视为成功
"""
source_name = str(old_name or "").strip()
target_name = str(new_name or "").strip()
if not source_name or not target_name:
return False, "旧名称或新名称不能为空"
if source_name == target_name:
return True, "通知名称未变化,无需迁移登录缓存"
cache = FileCache()
source_key = cls._build_cache_key(source_name)
target_key = cls._build_cache_key(target_name)
source_state = cache.get(source_key)
if not source_state:
return True, "未找到可迁移的微信 ClawBot 登录缓存"
if cache.exists(target_key) and not overwrite:
return True, "新名称下已存在登录缓存,跳过迁移"
cache.set(target_key, source_state)
if cleanup_old:
cache.delete(source_key)
return True, f"已将微信 ClawBot 登录缓存从 {source_name} 迁移到 {target_name}"
@classmethod
def reconcile_cached_states(
cls, previous: List[Dict[str, Any]], current: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
按稳定身份迁移改名缓存,并删除已移除渠道的缓存。
:param previous: 修改前完整通知配置列表,使用 id、name 和 type 字段
:param current: 修改后完整通知配置列表,用稳定 id 判断保留与删除
:return: 含 success 与说明的同步结果;缓存操作异常向上传播
"""
cache = FileCache()
old_by_id = {str(item.get("id")): item for item in previous if item.get("id")}
current_ids = {str(item.get("id")) for item in current if item.get("id")}
for item in current:
if item.get("type") != "wechatclawbot":
continue
identity = str(item.get("id") or item.get("name") or "").strip()
old = old_by_id.get(identity)
if old and old.get("name") and old.get("name") != item.get("name"):
legacy_key = cls._build_cache_key(str(old["name"]))
target_key = cls._build_cache_key(identity)
if not cache.exists(target_key) and cache.exists(legacy_key):
content = cache.get(legacy_key)
if content:
cache.set(target_key, content)
cache.delete(legacy_key)
for item in previous:
if item.get("type") != "wechatclawbot" or str(item.get("id")) in current_ids:
continue
for legacy_identity in (item.get("id"), item.get("name")):
if legacy_identity:
cache.delete(cls._build_cache_key(str(legacy_identity)))
return {"success": True, "message": "通知渠道缓存已同步"}
@staticmethod
def _decode_ref_payload(ref: str, kind: str) -> Optional[Dict[str, Any]]:
"""
校验本地资源引用类别并解码载荷,非法引用不进入资源读取流程。
"""
prefix = f"wxclaw://{kind}/"
if not ref or not ref.startswith(prefix):
return None
encoded = ref.replace(prefix, "", 1)
padding = "=" * (-len(encoded) % 4)
try:
payload = json.loads(
base64.urlsafe_b64decode((encoded + padding).encode("ascii")).decode(
"utf-8"
)
)
return payload if isinstance(payload, dict) else None
except Exception:
return None
@staticmethod
def _decode_aes_key(aeskey: Optional[str]) -> Optional[bytes]:
"""
解析微信资源使用的 AES 密钥,无法识别时返回 None。
"""
if not aeskey:
return None
value = str(aeskey).strip()
if not value:
return None
if re.fullmatch(r"[0-9a-fA-F]{32}", value):
try:
return bytes.fromhex(value)
except Exception:
return None
for candidate in (value, value + "=" * (-len(value) % 4)):
try:
decoded = base64.b64decode(candidate)
if len(decoded) == 16:
return decoded
if len(decoded) == 32 and re.fullmatch(rb"[0-9a-fA-F]{32}", decoded):
return bytes.fromhex(decoded.decode("ascii"))
except Exception:
continue
return None
@staticmethod
def _unpad_bytes(content: bytes) -> Optional[bytes]:
"""
校验并移除资源明文的块填充,非法填充返回 None。
"""
if not content:
return None
padding_len = content[-1]
if padding_len <= 0 or padding_len > 16:
return None
if content[-padding_len:] != bytes([padding_len]) * padding_len:
return None
return content[:-padding_len]
@classmethod
def _decrypt_if_needed(
cls,
content: bytes,
aeskey: Optional[str],
mime_type: Optional[str] = None,
) -> bytes:
"""
按资源协议使用AES-ECB解密并校验PKCS7填充,不根据文件内容猜测加密方式。
:param content: 下载到的原始字节
:param aeskey: 可选编码后的 AES 密钥
:param mime_type: 保留调用兼容的文件MIME类型,不参与密码协议判断
:return: 完整明文;未声明密钥时原样返回下载内容
:raises ValueError: 已声明密钥但密钥、密文或填充无效
"""
del mime_type
if not aeskey:
return content
aes_bytes = cls._decode_aes_key(aeskey)
if not aes_bytes or not content:
raise ValueError("附件密钥或密文无效")
plaintext = AES.new(aes_bytes, AES.MODE_ECB).decrypt(content)
unpadded = cls._unpad_bytes(plaintext)
if unpadded is None:
raise ValueError("附件密文填充校验失败")
return unpadded
@staticmethod
def _guess_binary_mime(content: bytes, default: str = "application/octet-stream") -> str:
"""
根据资源内容的文件签名识别 MIME,未知内容沿用调用方默认值。
"""
if not content:
return default
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if content.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if content.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
if content.startswith(b"#!AMR"):
return "audio/amr"
if content.startswith(b"OggS"):
return "audio/ogg"
if content.startswith(b"ID3"):
return "audio/mpeg"
if content.startswith(b"RIFF") and b"WAVE" in content[:16]:
return "audio/wav"
if content.startswith(b"PK\x03\x04"):
return "application/zip"
return default
def _download_ref_bytes(self, ref: str, kind: str) -> Optional[Tuple[bytes, str, Optional[str]]]:
"""
下载内部资源引用并尝试兼容解密。
:param ref: wxclaw:// 引用,包含下载地址及可选密钥、名称和格式
:param kind: 引用类型,如 image、file 或 voice
:return: 内容字节、MIME 类型和可选名称的三元组;引用无效或下载失败返回 None
"""
payload = self._decode_ref_payload(ref=ref, kind=kind)
if not payload:
return None
encryption_type = payload.get("encrypt_type")
if encryption_type not in (None, 1, "1") or (encryption_type is not None and not payload.get("aeskey")):
logger.warning("附件使用不支持的加密方式或缺少密钥,已拒绝下载内容")
return None
download_url = payload.get("url")
if not download_url:
return None
try:
resp = RequestUtils(timeout=30).get_res(download_url)
except Exception as err:
logger.error(f"下载 {kind} 失败:{err}")
return None
if not resp or not resp.content:
return None
try:
content = self._decrypt_if_needed(
content=resp.content,
aeskey=payload.get("aeskey"),
mime_type=payload.get("mime_type"),
)
except ValueError:
logger.warning("附件密文或密钥无效,已拒绝返回未解密内容")
return None
mime_type = payload.get("mime_type") or self._guess_binary_mime(
content,
(resp.headers.get("Content-Type") or "application/octet-stream").split(
";", 1
)[0],
)
return content, mime_type, payload.get("name")
def download_image_to_data_url(self, image_ref: str) -> Optional[str]:
"""
下载内部图片引用并编码为数据地址。
:param image_ref: wxclaw://image/ 内部图片引用
:return: 包含 MIME 类型的 Base64 数据地址;引用无效或下载失败返回 None
"""
result = self._download_ref_bytes(ref=image_ref, kind="image")
if not result:
return None
content, mime_type, _ = result
return f"data:{mime_type};base64,{base64.b64encode(content).decode()}"
def download_media_bytes(self, media_ref: str) -> Optional[bytes]:
"""
下载内部文件或语音引用的内容。
:param media_ref: wxclaw://file/ 或 wxclaw://voice/ 引用
:return: 下载及兼容解密后的字节;类型不支持或下载失败返回 None
"""
kind = None
if media_ref.startswith("wxclaw://file/"):
kind = "file"
elif media_ref.startswith("wxclaw://voice/"):
kind = "voice"
if not kind:
return None
result = self._download_ref_bytes(ref=media_ref, kind=kind)
return result[0] if result else None
def send_msg(
self,
title: str,
text: Optional[str] = None,
image: Optional[str] = None,
userid: Optional[str] = None,
link: Optional[str] = None,
**kwargs: object,
) -> Optional[bool]:
"""
优先发送组合文字,只有无文字时才尝试发送图片。
各目标独立发送,已发出的分块不会撤回。当前无文字且无可用图片时,
只要存在目标也会返回 True,此结果不表示实际发出消息。
:param title: 可选标题
:param text: 可选正文
:param image: 可选远程图片或图片数据地址;标题、正文或链接产生文字时忽略
:param userid: 显式接收用户;为空时使用默认目标,再回退到近期活跃或全部已知用户
:param link: 追加为详情文字的链接
:param kwargs: 兼容发送接口的附加参数,当前忽略
:return: 至少一个目标的全部发送步骤成功时返回 True;无目标或全部发送失败返回 False
"""
targets = self._get_targets(userid=userid)
if not targets:
logger.warning("未找到可发送的微信 ClawBot 目标")
return False
content = self._compose_text(title=title, text=text, link=link)
# 当前 iLink 发送实现会把图文拆成两条消息发送。
# 为避免用户看到“同一条通知被拆成文本 + 图片”两次触达,这里约定:
# 只要通知里已经有文本内容,就优先只发文本;只有纯图片通知才发送图片。
image_bytes = self._load_remote_image(image) if image and not content else None
ok = False
for target in targets:
context_token = self._get_context_token(target)
if content:
client = self._build_client()
sent = True
for chunk in self._split_content(content):
if not client.send_text(
to_user=target,
text=chunk,
context_token=context_token,
):
sent = False
break
elif image_bytes:
sent = self._build_client().send_image_png(
to_user=target,
image_bytes=image_bytes,
context_token=context_token,
)
else:
client = self._build_client()
sent = True
for chunk in self._split_content(content):
if not client.send_text(
to_user=target,
text=chunk,
context_token=context_token,
):
sent = False
break
ok = ok or bool(sent)
return ok
def send_file(
self,
file_path: str,
file_name: Optional[str] = None,
title: Optional[str] = None,
text: Optional[str] = None,
userid: Optional[str] = None,
**kwargs: object,
) -> Optional[bool]:
"""
发送本地附件;图片类型使用图片协议,其他类型使用文件协议。
:param file_path: 本地普通文件路径;完整读入内存后发送,保留源文件
:param file_name: 普通附件显示名称;为空时使用路径文件名
:param title: 兼容发送接口的标题,当前不发送
:param text: 兼容发送接口的说明,当前不发送
:param userid: 显式接收用户;为空时使用默认目标,再回退到近期活跃或全部已知用户
:param kwargs: 兼容发送接口的附加参数,当前忽略
:return: 至少一个目标的全部发送步骤成功时返回 True;无目标或全部发送失败返回 False
"""
path = Path(file_path)
if not path.exists() or not path.is_file():
logger.warning(f"待发送文件不存在:{file_path}")
return False
file_bytes = path.read_bytes()
effective_name = file_name or path.name
mime_type = self._guess_mime_type(path, file_bytes)
targets = self._get_targets(userid=userid)
if not targets:
return False
ok = False
for target in targets:
context_token = self._get_context_token(target)
# send_file 的主语义是发送附件本体,附加文案可以丢弃,避免出现
# “先发一段说明,再发一个文件”的重复触达体验。
if mime_type.startswith("image/"):
sent = self._build_client().send_image_png(
to_user=target,
image_bytes=file_bytes,
context_token=context_token,
)
else:
sent = self._build_client().send_file_bytes(
to_user=target,
file_bytes=file_bytes,
file_name=effective_name,
mime_type=mime_type,
context_token=context_token,
)
ok = ok or bool(sent)
return ok
def send_medias_msg(
self, medias: List[MediaInfo], userid: Optional[str] = None
) -> Optional[bool]:
"""
发送包含评分和详情地址的媒体候选列表。
:param medias: 按原顺序展示的候选项;空列表不发送
:param userid: 显式接收用户;为空时使用默认目标,再回退到近期活跃或全部已知用户
:return: 至少一个目标的全部发送步骤成功时返回 True;无目标或全部发送失败返回 False
"""
if not medias:
return False
lines = []
for index, media in enumerate(medias, start=1):
line = f"{index}. {media.title_year}"
if media.vote_average:
line += f" 评分:{media.vote_average}"
if media.detail_link:
line += f"\n{media.detail_link}"
lines.append(line)
return self.send_msg(title="媒体列表", text="\n\n".join(lines), userid=userid)
def send_torrents_msg(
self,
torrents: List[Context],
userid: Optional[str] = None,
title: Optional[str] = None,
link: Optional[str] = None,
) -> Optional[bool]:
"""
发送带站点、资源信息和详情地址的种子候选列表。
:param torrents: 按原顺序展示的种子上下文;空列表不发送
:param userid: 显式接收用户;为空时使用默认目标,再回退到近期活跃或全部已知用户
:param title: 列表标题;为空时使用“种子列表”
:param link: 列表末尾的可选详情地址
:return: 至少一个目标的全部发送步骤成功时返回 True;无目标或全部发送失败返回 False
"""
if not torrents:
return False
lines = []
for index, context in enumerate(torrents, start=1):
torrent = context.torrent_info
if torrent is None:
continue
meta = MetaInfo(title=torrent.title or "", subtitle=torrent.description)
text = (
f"{index}.【{torrent.site_name}】{meta.season_episode} {meta.resource_term} "
f"{meta.video_term} {meta.release_group} {size_tools.format_compact_size(torrent.size)} "
f"{torrent.volume_factor} {torrent.seeders}↑"
)
text = re.sub(r"\s+", " ", text).strip()
if torrent.page_url:
text += f"\n{torrent.page_url}"
lines.append(text)
return self.send_msg(
title=title or "种子列表",
text="\n\n".join(lines),
userid=userid,
link=link,
)