feat: implement message deduplication and enhance error handling in WechatClawBot

This commit is contained in:
jxxghp
2026-05-10 23:40:22 +08:00
parent 412e10972f
commit 5021b2c86f
3 changed files with 197 additions and 76 deletions
+35 -2
View File
@@ -1,6 +1,7 @@
import json import json
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
from app.core.cache import TTLCache
from app.core.context import Context, MediaInfo from app.core.context import Context, MediaInfo
from app.log import logger from app.log import logger
from app.modules import _MessageBase, _ModuleBase from app.modules import _MessageBase, _ModuleBase
@@ -10,6 +11,16 @@ from app.schemas.types import MessageChannel, ModuleType
class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]): class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
def __init__(self):
"""初始化模块级去重缓存,拦截 iLink 偶发的重复回放消息。"""
super().__init__()
# iLink 偶发会重复回放同一条 update,这里按 message_id 做渠道内幂等保护。
self._recent_message_ids = TTLCache(
region="wechatclawbot_message_dedup",
maxsize=8192,
ttl=7 * 24 * 60 * 60,
)
def init_module(self) -> None: def init_module(self) -> None:
"""初始化模块。""" """初始化模块。"""
self.stop() self.stop()
@@ -108,6 +119,18 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
) )
return normalized or None return normalized or None
def _is_duplicate_message(
self, source: str, message_id: Optional[Union[str, int]]
) -> bool:
"""按渠道名和消息ID判断是否重复,避免重复回放再次进入业务链路。"""
if message_id in (None, ""):
return False
cache_key = f"{source}:{message_id}"
if self._recent_message_ids.exists(cache_key):
return True
self._recent_message_ids.set(cache_key, True)
return False
def message_parser( def message_parser(
self, source: str, body: Any, form: Any, args: Any self, source: str, body: Any, form: Any, args: Any
) -> Optional[CommingMessage]: ) -> Optional[CommingMessage]:
@@ -131,6 +154,7 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
if not user_id: if not user_id:
return None return None
message_id = message.get("message_id")
text = str(message.get("text") or "").strip() text = str(message.get("text") or "").strip()
username = str(message.get("username") or user_id).strip() or user_id username = str(message.get("username") or user_id).strip() or user_id
images = CommingMessage.MessageImage.normalize_list(message.get("images")) images = CommingMessage.MessageImage.normalize_list(message.get("images"))
@@ -138,6 +162,14 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
files = self._normalize_files(message.get("files")) files = self._normalize_files(message.get("files"))
if not text and not images and not audio_refs and not files: if not text and not images and not audio_refs and not files:
return None return None
if self._is_duplicate_message(client_config.name, message_id):
logger.info(
"忽略重复的微信 ClawBot 消息:source=%s, userid=%s, message_id=%s",
client_config.name,
user_id,
message_id,
)
return None
admins = [ admins = [
admin.strip() admin.strip()
@@ -152,7 +184,8 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
logger.info( logger.info(
f"收到来自 {client_config.name} 的微信 ClawBot 消息:" f"收到来自 {client_config.name} 的微信 ClawBot 消息:"
f"userid={user_id}, text={text}, images={len(images) if images else 0}, " f"userid={user_id}, message_id={message_id}, text={text}, "
f"images={len(images) if images else 0}, "
f"audios={len(audio_refs) if audio_refs else 0}, files={len(files) if files else 0}" f"audios={len(audio_refs) if audio_refs else 0}, files={len(files) if files else 0}"
) )
return CommingMessage( return CommingMessage(
@@ -161,7 +194,7 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
userid=user_id, userid=user_id,
username=username, username=username,
text=text, text=text,
message_id=message.get("message_id"), message_id=message_id,
chat_id=str(message.get("chat_id") or "") or None, chat_id=str(message.get("chat_id") or "") or None,
images=images, images=images,
audio_refs=audio_refs, audio_refs=audio_refs,
+100 -74
View File
@@ -10,7 +10,7 @@ import time
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote from urllib.parse import quote
from Crypto.Cipher import AES from Crypto.Cipher import AES
@@ -69,33 +69,13 @@ class ILinkClient:
account_id: Optional[str] = None, account_id: Optional[str] = None,
sync_buf: Optional[str] = None, sync_buf: Optional[str] = None,
timeout: int = 20, timeout: int = 20,
log_func: Optional[Callable[[str, str], None]] = None,
): ):
"""保存 iLink 会话参数,供二维码登录、长轮询和消息发送复用。"""
self.base_url = (base_url or "https://ilinkai.weixin.qq.com").rstrip("/") self.base_url = (base_url or "https://ilinkai.weixin.qq.com").rstrip("/")
self.bot_token = bot_token self.bot_token = bot_token
self.account_id = account_id self.account_id = account_id
self.sync_buf = sync_buf self.sync_buf = sync_buf
self.timeout = timeout self.timeout = timeout
self._log_func = log_func
def _log(self, level: str, message: str) -> None:
if self._log_func:
try:
self._log_func(level, f"[ILinkClient] {message}")
return
except Exception:
pass
text = f"[WechatClawBot][ILinkClient] {message}"
level_value = (level or "info").lower()
if level_value == "debug":
logger.debug(text)
elif level_value == "warning":
logger.warning(text)
elif level_value == "error":
logger.error(text)
else:
logger.info(text)
def set_credentials( def set_credentials(
self, self,
@@ -537,6 +517,12 @@ class ILinkClient:
plaintext: bytes, plaintext: bytes,
media_types: Optional[List[int]] = None, media_types: Optional[List[int]] = None,
) -> Tuple[Optional[str], Optional[str], Optional[bytes], Optional[int], Optional[str]]: ) -> Tuple[Optional[str], Optional[str], Optional[bytes], Optional[int], Optional[str]]:
"""
向 iLink 申请 CDN 上传参数。
iLink 对同一文件类型的兼容性并不稳定,这里会按候选 media_type 依次尝试,
成功后返回上传地址、AES 密钥和文件元信息给后续 CDN 上传流程复用。
"""
rawsize = len(plaintext) rawsize = len(plaintext)
rawfilemd5 = hashlib.md5(plaintext).hexdigest() rawfilemd5 = hashlib.md5(plaintext).hexdigest()
filesize = self._aes_ecb_padded_size(rawsize) filesize = self._aes_ecb_padded_size(rawsize)
@@ -581,10 +567,7 @@ class ILinkClient:
filesize, filesize,
filekey, filekey,
) )
self._log( logger.warning(f"getuploadurl 失败: resp={self._short_text(last_payload)}")
"warning",
f"getuploadurl 失败: resp={self._short_text(last_payload)}",
)
return None, None, None, None, None return None, None, None, None, None
def _upload_encrypted_to_cdn( def _upload_encrypted_to_cdn(
@@ -595,6 +578,7 @@ class ILinkClient:
plaintext: bytes, plaintext: bytes,
aeskey: bytes, aeskey: bytes,
) -> Tuple[Optional[str], Optional[int]]: ) -> Tuple[Optional[str], Optional[int]]:
"""将文件按协议加密后上传到微信 CDN,并返回后续发送消息所需的下载参数。"""
ciphertext = self._encrypt_aes_ecb(plaintext, aeskey) ciphertext = self._encrypt_aes_ecb(plaintext, aeskey)
if upload_full_url: if upload_full_url:
upload_url = str(upload_full_url).strip() upload_url = str(upload_full_url).strip()
@@ -604,23 +588,23 @@ class ILinkClient:
f"filekey={quote(filekey, safe='')}" f"filekey={quote(filekey, safe='')}"
) )
else: else:
self._log("warning", "CDN 上传失败: 缺少 upload_url 参数") logger.warning("CDN 上传失败: 缺少 upload_url 参数")
return None, None return None, None
resp = RequestUtils( resp = RequestUtils(
headers={"Content-Type": "application/octet-stream"}, headers={"Content-Type": "application/octet-stream"},
timeout=self.timeout, timeout=self.timeout,
).post(upload_url, data=ciphertext) ).post(upload_url, data=ciphertext)
if getattr(resp, "status_code", None) != 200: if getattr(resp, "status_code", None) != 200:
self._log( logger.warning(
"warning", f"CDN 上传失败: http={getattr(resp, 'status_code', None)}, "
f"CDN 上传失败: http={getattr(resp, 'status_code', None)}, err={self._short_text(getattr(resp, 'text', ''))}", f"err={self._short_text(getattr(resp, 'text', ''))}"
) )
return None, None return None, None
download_param = None download_param = None
if resp is not None and getattr(resp, "headers", None): if resp is not None and getattr(resp, "headers", None):
download_param = resp.headers.get("x-encrypted-param") download_param = resp.headers.get("x-encrypted-param")
if not download_param: if not download_param:
self._log("warning", "CDN 上传成功但缺少 x-encrypted-param") logger.warning("CDN 上传成功但缺少 x-encrypted-param")
return None, None return None, None
return str(download_param), len(ciphertext) return str(download_param), len(ciphertext)
@@ -630,6 +614,12 @@ class ILinkClient:
payload_candidates: List[Dict[str, Any]], payload_candidates: List[Dict[str, Any]],
url_candidates: Optional[List[str]] = None, url_candidates: Optional[List[str]] = None,
) -> bool: ) -> bool:
"""
尝试一组候选发送报文,兼容 iLink 不同接口形态。
这里不会只依赖单一 URL 或单一报文结构,而是按“用户候选 -> 接口候选 -> 报文候选”
逐层回退,尽量提高不同账号和不同版本服务端的发送成功率。
"""
url_candidates = url_candidates or [ url_candidates = url_candidates or [
f"{self.base_url}/ilink/bot/sendmessage", f"{self.base_url}/ilink/bot/sendmessage",
f"{self.base_url}/ilink/bot/sendmessage?bot_type=3", f"{self.base_url}/ilink/bot/sendmessage?bot_type=3",
@@ -644,7 +634,7 @@ class ILinkClient:
).post(url, json=request_body) ).post(url, json=request_body)
payload = self._json(resp) payload = self._json(resp)
if self._is_send_success(payload) or self._is_send_http_success(resp, payload): if self._is_send_success(payload) or self._is_send_http_success(resp, payload):
self._log("info", f"发送消息成功: to_user={user_id}, variant={index}") logger.info(f"发送消息成功: to_user={user_id}, variant={index}")
return True return True
http_code = getattr(resp, "status_code", None) http_code = getattr(resp, "status_code", None)
err_msg = ( err_msg = (
@@ -655,16 +645,17 @@ class ILinkClient:
if not err_msg and resp is not None: if not err_msg and resp is not None:
err_msg = self._short_text(getattr(resp, "text", "")) err_msg = self._short_text(getattr(resp, "text", ""))
last_error = f"http={http_code}, err={self._short_text(err_msg)}" last_error = f"http={http_code}, err={self._short_text(err_msg)}"
self._log( logger.debug(
"debug", f"发送候选失败: to_user={user_id}, variant={index}, "
f"发送候选失败: to_user={user_id}, variant={index}, {last_error}, req={self._short_text(request_body)}, resp={self._short_text(payload)}", f"{last_error}, req={self._short_text(request_body)}, "
f"resp={self._short_text(payload)}"
) )
self._log("warning", f"发送消息失败: to_user={to_user}, {last_error}") logger.warning(f"发送消息失败: to_user={to_user}, {last_error}")
return False return False
def get_qrcode(self) -> Dict[str, Any]: def get_qrcode(self) -> Dict[str, Any]:
url = f"{self.base_url}/ilink/bot/get_bot_qrcode?bot_type=3" url = f"{self.base_url}/ilink/bot/get_bot_qrcode?bot_type=3"
self._log("debug", f"请求二维码: {url}") logger.debug(f"请求二维码: {url}")
resp = RequestUtils( resp = RequestUtils(
headers=self._headers(auth_required=False), timeout=self.timeout headers=self._headers(auth_required=False), timeout=self.timeout
).get_res(url) ).get_res(url)
@@ -782,10 +773,10 @@ class ILinkClient:
def send_text(self, to_user: str, text: str, context_token: Optional[str] = None) -> bool: def send_text(self, to_user: str, text: str, context_token: Optional[str] = None) -> bool:
if not self.bot_token: if not self.bot_token:
self._log("warning", "发送消息失败:bot token 未配置") logger.warning("发送消息失败:bot token 未配置")
return False return False
if not to_user or not text: if not to_user or not text:
self._log("warning", "发送消息失败:to_user 或 text 为空") logger.warning("发送消息失败:to_user 或 text 为空")
return False return False
payload_candidates = [ payload_candidates = [
self._build_protocol_text_payload( self._build_protocol_text_payload(
@@ -799,10 +790,10 @@ class ILinkClient:
self, to_user: str, text: str, context_token: Optional[str] = None self, to_user: str, text: str, context_token: Optional[str] = None
) -> bool: ) -> bool:
if not self.bot_token: if not self.bot_token:
self._log("warning", "发送 Markdown 失败:bot token 未配置") logger.warning("发送 Markdown 失败:bot token 未配置")
return False return False
if not to_user or not text: if not to_user or not text:
self._log("warning", "发送 Markdown 失败:to_user 或 text 为空") logger.warning("发送 Markdown 失败:to_user 或 text 为空")
return False return False
payload_candidates = self._build_markdown_payloads(str(to_user), text) payload_candidates = self._build_markdown_payloads(str(to_user), text)
if self._send_payload_candidates(to_user=to_user, payload_candidates=payload_candidates): if self._send_payload_candidates(to_user=to_user, payload_candidates=payload_candidates):
@@ -817,10 +808,10 @@ class ILinkClient:
context_token: Optional[str] = None, context_token: Optional[str] = None,
) -> bool: ) -> bool:
if not self.bot_token: if not self.bot_token:
self._log("warning", "发送图文失败:bot token 未配置") logger.warning("发送图文失败:bot token 未配置")
return False return False
if not to_user or not image_bytes or not text: if not to_user or not image_bytes or not text:
self._log("warning", "发送图文失败:to_user 或 image_bytes 或 text 为空") logger.warning("发送图文失败:to_user 或 image_bytes 或 text 为空")
return False return False
for user_id in self._build_user_candidates(to_user): for user_id in self._build_user_candidates(to_user):
upload_param, upload_full_url, aeskey, _, filekey = self._request_upload_param( upload_param, upload_full_url, aeskey, _, filekey = self._request_upload_param(
@@ -878,10 +869,10 @@ class ILinkClient:
self, to_user: str, image_bytes: bytes, context_token: Optional[str] = None self, to_user: str, image_bytes: bytes, context_token: Optional[str] = None
) -> bool: ) -> bool:
if not self.bot_token: if not self.bot_token:
self._log("warning", "发送图片失败:bot token 未配置") logger.warning("发送图片失败:bot token 未配置")
return False return False
if not to_user or not image_bytes: if not to_user or not image_bytes:
self._log("warning", "发送图片失败:to_user 或 image_bytes 为空") logger.warning("发送图片失败:to_user 或 image_bytes 为空")
return False return False
for user_id in self._build_user_candidates(to_user): for user_id in self._build_user_candidates(to_user):
upload_param, upload_full_url, aeskey, _, filekey = self._request_upload_param( upload_param, upload_full_url, aeskey, _, filekey = self._request_upload_param(
@@ -922,10 +913,10 @@ class ILinkClient:
context_token: Optional[str] = None, context_token: Optional[str] = None,
) -> bool: ) -> bool:
if not self.bot_token: if not self.bot_token:
self._log("warning", "发送文件失败:bot token 未配置") logger.warning("发送文件失败:bot token 未配置")
return False return False
if not to_user or not file_bytes: if not to_user or not file_bytes:
self._log("warning", "发送文件失败:to_user 或 file_bytes 为空") logger.warning("发送文件失败:to_user 或 file_bytes 为空")
return False return False
file_name = file_name or "attachment" file_name = file_name or "attachment"
mime_type = mime_type or "application/octet-stream" mime_type = mime_type or "application/octet-stream"
@@ -1013,6 +1004,12 @@ class ILinkClient:
return value return value
def _parse_incoming(self, item: Dict[str, Any]) -> Optional[ILinkIncomingMessage]: def _parse_incoming(self, item: Dict[str, Any]) -> Optional[ILinkIncomingMessage]:
"""
将 getupdates 返回的原始事件归一化为 MoviePilot 可消费的入站消息。
iLink 返回结构存在多种嵌套和字段别名,这里集中做兼容处理,统一提取
用户、消息ID、文本、图片、语音和文件附件。
"""
if not isinstance(item, dict): if not isinstance(item, dict):
return None return None
message = item message = item
@@ -1088,8 +1085,10 @@ class ILinkClient:
or str(user_id) or str(user_id)
) )
message_id = self._pick_value( message_id = self._pick_value(
message, ["message_id", "msg_id", "id", "client_msg_id", "msgId"] 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"]) ) or self._pick_value(
item, ["message_id", "msg_id", "id", "client_msg_id", "msgId", "seq"]
)
chat_id = self._pick_value( chat_id = self._pick_value(
message, message,
["chat_id", "conversation_id", "room_id", "chatId", "conversationId", "roomId"], ["chat_id", "conversation_id", "room_id", "chatId", "conversationId", "roomId"],
@@ -1261,6 +1260,7 @@ class ILinkClient:
def _extract_updates( def _extract_updates(
self, payload: Dict[str, Any] self, payload: Dict[str, Any]
) -> Tuple[List[Dict[str, Any]], Optional[str]]: ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""从轮询响应中提取消息列表和下一轮使用的 sync_buf 游标。"""
data = payload.get("data") or payload.get("result") or payload data = payload.get("data") or payload.get("result") or payload
sync_buf = ( sync_buf = (
data.get("get_updates_buf") data.get("get_updates_buf")
@@ -1306,6 +1306,12 @@ class ILinkClient:
def poll_updates( def poll_updates(
self, timeout_seconds: int = 25 self, timeout_seconds: int = 25
) -> Tuple[List[ILinkIncomingMessage], Optional[str], Dict[str, Any]]: ) -> Tuple[List[ILinkIncomingMessage], Optional[str], Dict[str, Any]]:
"""
执行一次 iLink 长轮询。
返回值同时包含归一化后的消息、最新游标和原始结果摘要,
便于上层在推进 sync_buf 的同时记录调试信息。
"""
if not self.bot_token: if not self.bot_token:
return [], self.sync_buf, {"success": False, "message": "bot token 未配置"} return [], self.sync_buf, {"success": False, "message": "bot token 未配置"}
url = f"{self.base_url}/ilink/bot/getupdates" url = f"{self.base_url}/ilink/bot/getupdates"
@@ -1407,18 +1413,6 @@ class WechatClawBot:
if self._state.get("bot_token") and self._auto_start_polling: if self._state.get("bot_token") and self._auto_start_polling:
self._start_polling() self._start_polling()
def _log(self, level: str, message: str) -> None:
text = f"[WechatClawBot][{self._config_name}] {message}"
level_value = (level or "info").lower()
if level_value == "debug":
logger.debug(text)
elif level_value == "warning":
logger.warning(text)
elif level_value == "error":
logger.error(text)
else:
logger.info(text)
def _load_state(self) -> Dict[str, Any]: def _load_state(self) -> Dict[str, Any]:
content = self._filecache.get(self._cache_key) content = self._filecache.get(self._cache_key)
if not content: if not content:
@@ -1441,7 +1435,7 @@ class WechatClawBot:
data.setdefault("base_url", self._base_url) data.setdefault("base_url", self._base_url)
return data return data
except Exception as err: except Exception as err:
self._log("warning", f"加载登录状态失败,已重置缓存:{err}") logger.warning(f"加载登录状态失败,已重置缓存:{err}")
return { return {
"bot_token": None, "bot_token": None,
"account_id": None, "account_id": None,
@@ -1460,13 +1454,13 @@ class WechatClawBot:
) )
def _build_client(self) -> ILinkClient: def _build_client(self) -> ILinkClient:
"""根据当前持久化状态构建一次性的 iLink 客户端。"""
return ILinkClient( return ILinkClient(
base_url=self._state.get("base_url") or self._base_url, base_url=self._state.get("base_url") or self._base_url,
bot_token=self._state.get("bot_token"), bot_token=self._state.get("bot_token"),
account_id=self._state.get("account_id"), account_id=self._state.get("account_id"),
sync_buf=self._state.get("sync_buf"), sync_buf=self._state.get("sync_buf"),
timeout=max(self._poll_timeout, 20), timeout=max(self._poll_timeout, 20),
log_func=self._log,
) )
def _update_state(self, **kwargs) -> None: def _update_state(self, **kwargs) -> None:
@@ -1607,7 +1601,7 @@ class WechatClawBot:
if not content_type or "image" in content_type: if not content_type or "image" in content_type:
return resp.content return resp.content
except Exception as err: except Exception as err:
self._log("warning", f"加载图片失败:{err}") logger.warning(f"加载图片失败:{err}")
return None return None
def get_state(self) -> bool: def get_state(self) -> bool:
@@ -1627,9 +1621,15 @@ class WechatClawBot:
self._stop_event.clear() self._stop_event.clear()
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True) self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
self._poll_thread.start() self._poll_thread.start()
self._log("info", "消息轮询线程已启动") logger.info("消息轮询线程已启动")
def _poll_loop(self) -> None: def _poll_loop(self) -> None:
"""
持续拉取微信消息并转发到本地消息入口。
轮询异常时按退避策略重试;本地消息入口失败只记日志,不回滚 sync_buf,
由上层的消息ID去重兜底,避免单次本地异常导致后续消息全部阻塞。
"""
consecutive_failures = 0 consecutive_failures = 0
backoff = [1, 2, 5, 10, 30] backoff = [1, 2, 5, 10, 30]
while not self._stop_event.is_set() and self._state.get("bot_token"): while not self._stop_event.is_set() and self._state.get("bot_token"):
@@ -1648,20 +1648,40 @@ class WechatClawBot:
username=message.username, username=message.username,
context_token=message.context_token, context_token=message.context_token,
) )
response = None
try: try:
RequestUtils(timeout=15).post_res( response = RequestUtils(timeout=15).post_res(
self._message_endpoint, self._message_endpoint,
json=message.to_message_payload(), json=message.to_message_payload(),
) )
if response is None:
logger.error(
f"转发微信 ClawBot 消息失败:message_id={message.message_id}, "
"本地消息入口无响应"
)
elif response.status_code != 200:
logger.error(
"转发微信 ClawBot 消息失败:"
f"message_id={message.message_id}, status={response.status_code}, "
f"body={ILinkClient._short_text(response.text)}"
)
except Exception as err: except Exception as err:
self._log("error", f"转发微信 ClawBot 消息失败:{err}") logger.error(
f"转发微信 ClawBot 消息失败:message_id={message.message_id}, error={err}"
)
finally:
if response is not None:
try:
response.close()
except Exception:
pass
consecutive_failures = 0 consecutive_failures = 0
except Exception as err: except Exception as err:
consecutive_failures += 1 consecutive_failures += 1
delay = backoff[min(consecutive_failures - 1, len(backoff) - 1)] delay = backoff[min(consecutive_failures - 1, len(backoff) - 1)]
self._log("warning", f"轮询异常,{delay}s 后重试:{err}") logger.warning(f"轮询异常,{delay}s 后重试:{err}")
if consecutive_failures >= 10: if consecutive_failures >= 10:
self._log("error", "轮询连续失败,已清理登录状态") logger.error("轮询连续失败,已清理登录状态")
self._clear_login_state() self._clear_login_state()
break break
self._stop_event.wait(delay) self._stop_event.wait(delay)
@@ -1684,7 +1704,10 @@ class WechatClawBot:
def refresh_qrcode(self) -> Dict[str, Any]: def refresh_qrcode(self) -> Dict[str, Any]:
if self._state.get("bot_token"): if self._state.get("bot_token"):
return self.get_status(refresh_remote=False) return self.get_status(refresh_remote=False)
client = ILinkClient(base_url=self._base_url, timeout=max(self._poll_timeout, 20), log_func=self._log) client = ILinkClient(
base_url=self._base_url,
timeout=max(self._poll_timeout, 20),
)
result = client.get_qrcode() result = client.get_qrcode()
if not result.get("success"): if not result.get("success"):
return result return result
@@ -1711,7 +1734,10 @@ class WechatClawBot:
self.refresh_qrcode() self.refresh_qrcode()
qrcode = self._state.get("qrcode") or {} qrcode = self._state.get("qrcode") or {}
if refresh_remote and not self._state.get("bot_token") and qrcode.get("qrcode"): 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), log_func=self._log) client = ILinkClient(
base_url=self._base_url,
timeout=max(self._poll_timeout, 20),
)
result = client.get_qrcode_status(str(qrcode.get("qrcode"))) result = client.get_qrcode_status(str(qrcode.get("qrcode")))
updated_qrcode = dict(qrcode) updated_qrcode = dict(qrcode)
updated_qrcode["status"] = result.get("status") or updated_qrcode.get("status") or "waiting" updated_qrcode["status"] = result.get("status") or updated_qrcode.get("status") or "waiting"
@@ -1919,7 +1945,7 @@ class WechatClawBot:
try: try:
resp = RequestUtils(timeout=30).get_res(download_url) resp = RequestUtils(timeout=30).get_res(download_url)
except Exception as err: except Exception as err:
self._log("error", f"下载 {kind} 失败:{err}") logger.error(f"下载 {kind} 失败:{err}")
return None return None
if not resp or not resp.content: if not resp or not resp.content:
return None return None
@@ -1965,7 +1991,7 @@ class WechatClawBot:
) -> Optional[bool]: ) -> Optional[bool]:
targets = self._get_targets(userid=userid) targets = self._get_targets(userid=userid)
if not targets: if not targets:
self._log("warning", "未找到可发送的微信 ClawBot 目标") logger.warning("未找到可发送的微信 ClawBot 目标")
return False return False
image_bytes = self._load_remote_image(image) if image else None image_bytes = self._load_remote_image(image) if image else None
content = self._compose_markdown(title=title, text=text, link=link) content = self._compose_markdown(title=title, text=text, link=link)
@@ -2010,7 +2036,7 @@ class WechatClawBot:
) -> Optional[bool]: ) -> Optional[bool]:
path = Path(file_path) path = Path(file_path)
if not path.exists() or not path.is_file(): if not path.exists() or not path.is_file():
self._log("warning", f"待发送文件不存在:{file_path}") logger.warning(f"待发送文件不存在:{file_path}")
return False return False
file_bytes = path.read_bytes() file_bytes = path.read_bytes()
effective_name = file_name or path.name effective_name = file_name or path.name
+62
View File
@@ -0,0 +1,62 @@
import json
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from app.modules.wechatclawbot import WechatClawBotModule
from app.modules.wechatclawbot.wechatclawbot import ILinkClient
class WechatClawBotTest(unittest.TestCase):
def test_ilink_parse_incoming_uses_seq_as_message_id_fallback(self):
client = ILinkClient(base_url="https://ilinkai.weixin.qq.com")
message = client._parse_incoming(
{
"seq": 123456,
"from_user_id": "wxid_user_1",
"item_list": [{"type": 1, "text_item": {"text": "你好"}}],
}
)
self.assertIsNotNone(message)
self.assertEqual(message.message_id, "123456")
self.assertEqual(message.text, "你好")
def test_wechatclawbot_message_parser_deduplicates_message_id(self):
module = WechatClawBotModule()
body = json.dumps(
{
"__channel__": "wechatclawbot",
"userid": "wxid_user_1",
"username": "tester",
"message_id": "msg-1001",
"text": "刷新订阅",
}
)
with patch.object(
module,
"get_config",
return_value=SimpleNamespace(name="wechatclawbot-test", config={}),
):
first = module.message_parser(
source="wechatclawbot-test",
body=body,
form={},
args={},
)
second = module.message_parser(
source="wechatclawbot-test",
body=body,
form={},
args={},
)
self.assertIsNotNone(first)
self.assertEqual(first.message_id, "msg-1001")
self.assertIsNone(second)
if __name__ == "__main__":
unittest.main()