mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-06 07:09:09 +08:00
fix(wechatclawbot): 识别 item_list 媒体入站消息并修正 CDN 探测判定 (#7000)
* fix(wechatclawbot): 识别 item_list 媒体入站消息并修正 CDN 探测判定 (#6992) iLink 下行媒体报文把附件放在 item_list 子项中,原解析存在三处缺口, 图片/语音/文件入站消息会被判为空消息丢弃: - item_list 仅按数字 type 处理 1 文本/2 图片/5、6 文件,缺少 3 语音/ 4 文件/5 视频分派;改为按实际出现的子项键名分派,同时兼容历史 type=5/6 文件报文。 - 附件引用只读取顶层 url/aeskey,识别不了 image_item.media.full_url / media.aes_key 等嵌套字段;统一按“顶层优先、media 子对象兜底”提取。 - 消息被解析为空时直接返回 None,日志只剩“未识别到有效消息”;补充 渠道级诊断日志,输出 message_id 与截断后的原始报文。 另外把“微信 ClawBot 媒体 CDN”网络探测的 400 纳入成功状态码:该 CDN 对无参数裸请求固定返回 400,此前探测项恒定误报失败。 tests/test_wechatclawbot.py 整文件迁移为 pytest 原生,并新增 item_list 媒体解析、仅图片消息可入链、空消息诊断日志回归;nettest 契约测试补充 CDN 400 探测用例。复杂度与 mypy 低水位随债务下降同步固化。 * fix(wechatclawbot): 同步架构清单中的 mypy 债务数字 (#6992)
This commit is contained in:
@@ -308,7 +308,9 @@ def _build_notification_module_rules() -> list[NetworkTestRule]:
|
||||
proxy=True,
|
||||
allowed_redirect_prefixes=("https://novac2c.cdn.weixin.qq.com/",),
|
||||
module_ids=("WechatClawBotModule",),
|
||||
success_status_codes=(200, 403, 404),
|
||||
# CDN 对不带下载参数的裸请求固定返回 400,能返回 400 即证明连通正常,
|
||||
# 不能据此判定探测失败。
|
||||
success_status_codes=(200, 400, 403, 404),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -292,6 +292,15 @@ class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]):
|
||||
audio_refs = self._normalize_audio_refs(message.get("audio_refs"))
|
||||
files = self._normalize_files(message.get("files"))
|
||||
if not text and not images and not audio_refs and not files:
|
||||
# 渠道报文被解析为空消息时必须留下诊断信息,否则只能在消息入口看到
|
||||
# “未识别到有效消息”,无法定位是哪种入站内容被丢弃。
|
||||
logger.info(
|
||||
"微信 ClawBot 消息未识别到有效内容:source=%s, userid=%s, message_id=%s, body=%s",
|
||||
client_config.name,
|
||||
user_id,
|
||||
message_id,
|
||||
WechatClawBot._short_text(message, max_len=500),
|
||||
)
|
||||
return None
|
||||
if self._is_duplicate_message(client_config.name, message_id):
|
||||
logger.info(
|
||||
|
||||
@@ -10,7 +10,7 @@ import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
@@ -975,30 +975,36 @@ class ILinkClient:
|
||||
attachment: Dict[str, Any],
|
||||
default_name: Optional[str] = None,
|
||||
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
||||
"""从附件信息构建 wxclaw 协议引用。"""
|
||||
"""
|
||||
从附件信息构建 wxclaw 协议引用。
|
||||
|
||||
下行报文可能把下载地址与密钥放在附件的 `media` 子对象中
|
||||
(如 `image_item.media.full_url` / `media.aes_key`),也可能直接放在附件顶层,
|
||||
这里统一按“附件顶层优先、media 子对象兜底”的顺序提取字段。
|
||||
"""
|
||||
if not isinstance(attachment, dict):
|
||||
return None
|
||||
download_url = (
|
||||
attachment.get("download_url")
|
||||
or attachment.get("url")
|
||||
or attachment.get("cdnurl")
|
||||
or attachment.get("cdn_url")
|
||||
or attachment.get("file_url")
|
||||
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": attachment.get("aeskey")
|
||||
or attachment.get("encoding_aes_key")
|
||||
or attachment.get("encrypt_key"),
|
||||
"encrypt_type": attachment.get("encrypt_type"),
|
||||
"mime_type": attachment.get("mime_type")
|
||||
or attachment.get("content_type"),
|
||||
"name": attachment.get("name")
|
||||
or attachment.get("filename")
|
||||
"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": attachment.get("size") or attachment.get("file_size"),
|
||||
"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
|
||||
@@ -1012,6 +1018,58 @@ class ILinkClient:
|
||||
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,
|
||||
@@ -1228,41 +1286,7 @@ class ILinkClient:
|
||||
for one in item_list:
|
||||
if not isinstance(one, dict):
|
||||
continue
|
||||
item_type = one.get("type")
|
||||
if item_type == 1 and isinstance(one.get("text_item"), dict):
|
||||
append_text((one.get("text_item") or {}).get("text"))
|
||||
elif item_type == 2 and isinstance(one.get("image_item"), dict):
|
||||
image_ref = self._build_attachment_ref(
|
||||
"image", (one.get("image_item") or {}).get("media") 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 in {5, 6}:
|
||||
file_ref = self._build_attachment_ref(
|
||||
"file", (one.get("file_item") or {}).get("media") or {}
|
||||
)
|
||||
if file_ref:
|
||||
ref, payload = file_ref
|
||||
files.append(
|
||||
{
|
||||
"ref": ref,
|
||||
"name": (one.get("file_item") or {}).get("name")
|
||||
or (one.get("file_item") or {}).get("file_name")
|
||||
or payload.get("name"),
|
||||
"mime_type": (one.get("file_item") or {}).get("mime_type")
|
||||
or payload.get("mime_type"),
|
||||
"size": (one.get("file_item") or {}).get("size")
|
||||
or payload.get("size"),
|
||||
}
|
||||
)
|
||||
self._append_item_list_entry(one, append_text, images, audio_refs, files)
|
||||
|
||||
if not text_parts:
|
||||
append_text(
|
||||
|
||||
@@ -108,7 +108,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 |
|
||||
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
|
||||
| 全量 mypy 历史债务 | 9,163 / 508 文件 | 通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| 全量 mypy 历史债务 | 9,159 / 508 文件 | 通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项 |
|
||||
| Ruff 历史诊断 | 100 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"app/modules/trimemedia/api.py:Api.request": 16,
|
||||
"app/modules/ugreen/ugreen.py:Ugreen.get_tv_episodes": 21,
|
||||
"app/modules/wechat/__init__.py:WechatModule.message_parser": 20,
|
||||
"app/modules/wechatclawbot/wechatclawbot.py:ILinkClient._parse_incoming": 34,
|
||||
"app/modules/wechatclawbot/wechatclawbot.py:ILinkClient._parse_incoming": 29,
|
||||
"app/modules/zspace/zspace.py:ZSpace.get_items": 16,
|
||||
"app/modules/zspace/zspace.py:ZSpace.get_tv_episodes": 18,
|
||||
"app/modules/zspace/zspace.py:ZSpace.get_webhook_message": 17,
|
||||
|
||||
@@ -2352,8 +2352,7 @@
|
||||
},
|
||||
"app/modules/wechatclawbot/wechatclawbot.py": {
|
||||
"arg-type": 2,
|
||||
"assignment": 2,
|
||||
"comparison-overlap": 3,
|
||||
"assignment": 1,
|
||||
"no-any-return": 3,
|
||||
"no-untyped-def": 4,
|
||||
"type-arg": 2,
|
||||
|
||||
@@ -121,6 +121,25 @@ def test_network_proxy_probe_uses_configured_github_identity(github_headers) ->
|
||||
assert "github-token" not in public_target.address
|
||||
|
||||
|
||||
def test_wechatclawbot_cdn_probe_accepts_parameterless_400() -> None:
|
||||
"""微信 ClawBot 媒体 CDN 对无参数裸请求返回 400,探针应视为连通正常。"""
|
||||
captured = {}
|
||||
|
||||
async def respond(method, url, **options):
|
||||
"""模拟 CDN 对裸地址请求返回 400 空响应。"""
|
||||
captured.update(method=method, url=url, **options)
|
||||
return SimpleNamespace(status_code=400, headers={}, text="", aclose=AsyncMock())
|
||||
|
||||
transport = SimpleNamespace(request=AsyncMock(side_effect=respond))
|
||||
result = asyncio.run(
|
||||
_network_test_service(transport).execute(target_id="wechatclawbot_cdn")
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert captured["method"] == "GET"
|
||||
assert captured["url"] == "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
|
||||
|
||||
def test_tmdb_web_probe_identifies_as_moviepilot() -> None:
|
||||
"""TMDB 网站探针应使用程序自身 UA,浏览器 UA 会被站点人机校验页拦成 403。"""
|
||||
captured = {}
|
||||
|
||||
@@ -1,95 +1,155 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.modules.wechatclawbot import WechatClawBotModule
|
||||
from app.modules.wechatclawbot.wechatclawbot import ILinkClient, WechatClawBot
|
||||
|
||||
CLIENT_BASE_URL = "https://ilinkai.weixin.qq.com"
|
||||
|
||||
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": "你好"}}],
|
||||
}
|
||||
)
|
||||
def _create_client(**kwargs) -> ILinkClient:
|
||||
"""构造指向 iLink 服务端的客户端实例。"""
|
||||
return ILinkClient(base_url=CLIENT_BASE_URL, **kwargs)
|
||||
|
||||
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": "刷新订阅",
|
||||
}
|
||||
)
|
||||
def _empty_state() -> dict:
|
||||
"""构造未登录的微信 ClawBot 持久化状态。"""
|
||||
return {
|
||||
"bot_token": None,
|
||||
"account_id": None,
|
||||
"sync_buf": None,
|
||||
"qrcode": {},
|
||||
"known_targets": {},
|
||||
"user_context_tokens": {},
|
||||
"base_url": CLIENT_BASE_URL,
|
||||
}
|
||||
|
||||
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)
|
||||
def _create_bot() -> WechatClawBot:
|
||||
"""构造不自动轮询的微信 ClawBot 实例。"""
|
||||
with patch.object(WechatClawBot, "_load_state", return_value=_empty_state()):
|
||||
return WechatClawBot(name="wechatclawbot-test", auto_start_polling=False)
|
||||
|
||||
def test_ilink_extract_updates_keeps_empty_sync_buf(self):
|
||||
client = ILinkClient(
|
||||
base_url="https://ilinkai.weixin.qq.com",
|
||||
bot_token="token",
|
||||
sync_buf="cursor-old",
|
||||
)
|
||||
|
||||
items, sync_buf = client._extract_updates(
|
||||
{
|
||||
"ret": 0,
|
||||
"get_updates_buf": "",
|
||||
"msgs": [
|
||||
{
|
||||
"message_id": "msg-1001",
|
||||
"from_user_id": "wxid_user_1",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "你好"}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
def _module_config() -> SimpleNamespace:
|
||||
"""构造消息解析所需的渠道配置。"""
|
||||
return SimpleNamespace(name="wechatclawbot-test", config={})
|
||||
|
||||
self.assertEqual(sync_buf, "")
|
||||
self.assertEqual(len(items), 1)
|
||||
|
||||
def test_ilink_poll_updates_resets_sync_buf_when_server_returns_empty_cursor(self):
|
||||
client = ILinkClient(
|
||||
base_url="https://ilinkai.weixin.qq.com",
|
||||
bot_token="token",
|
||||
sync_buf="cursor-old",
|
||||
)
|
||||
response = MagicMock()
|
||||
response.json.return_value = {
|
||||
def test_ilink_parse_incoming_uses_seq_as_message_id_fallback():
|
||||
client = _create_client()
|
||||
|
||||
message = client._parse_incoming(
|
||||
{
|
||||
"seq": 123456,
|
||||
"from_user_id": "wxid_user_1",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "你好"}}],
|
||||
}
|
||||
)
|
||||
|
||||
assert message is not None
|
||||
assert message.message_id == "123456"
|
||||
assert message.text == "你好"
|
||||
|
||||
|
||||
def test_ilink_parse_incoming_extracts_item_list_media():
|
||||
"""iLink 下行媒体放在 item_list 子项的 media 中,应解析为可直接下载的附件引用。"""
|
||||
client = _create_client()
|
||||
image_url = "https://novac2c.cdn.weixin.qq.com/c2c/download?fileid=image-1"
|
||||
voice_url = "https://novac2c.cdn.weixin.qq.com/c2c/download?fileid=voice-1"
|
||||
file_url = "https://novac2c.cdn.weixin.qq.com/c2c/download?fileid=file-1"
|
||||
video_url = "https://novac2c.cdn.weixin.qq.com/c2c/download?fileid=video-1"
|
||||
media_aes_key = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
|
||||
|
||||
message = client._parse_incoming(
|
||||
{
|
||||
"message_id": "msg-media-1",
|
||||
"from_user_id": "wxid_user_1",
|
||||
"message_type": 1,
|
||||
"item_list": [
|
||||
{"type": 1, "text_item": {"text": "看这张图"}},
|
||||
{
|
||||
"type": 2,
|
||||
"image_item": {
|
||||
"aeskey": "0123456789abcdef0123456789abcdef",
|
||||
"media": {"full_url": image_url, "aes_key": media_aes_key},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": 3,
|
||||
"voice_item": {"media": {"full_url": voice_url, "aes_key": media_aes_key}},
|
||||
},
|
||||
{
|
||||
"type": 4,
|
||||
"file_item": {
|
||||
"file_name": "报告.pdf",
|
||||
"size": 1024,
|
||||
"media": {"full_url": file_url, "aes_key": media_aes_key},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": 5,
|
||||
"video_item": {"media": {"full_url": video_url, "aes_key": media_aes_key}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert message is not None
|
||||
assert message.text == "看这张图"
|
||||
assert len(message.images) == 1
|
||||
image_payload = WechatClawBot._decode_ref_payload(message.images[0]["ref"], "image")
|
||||
assert image_payload["url"] == image_url
|
||||
assert image_payload["aeskey"] == "0123456789abcdef0123456789abcdef"
|
||||
assert len(message.audio_refs) == 1
|
||||
voice_payload = WechatClawBot._decode_ref_payload(message.audio_refs[0], "voice")
|
||||
assert voice_payload["url"] == voice_url
|
||||
assert voice_payload["aeskey"] == media_aes_key
|
||||
assert len(message.files) == 2
|
||||
file_payload = WechatClawBot._decode_ref_payload(message.files[0]["ref"], "file")
|
||||
assert file_payload["url"] == file_url
|
||||
assert file_payload["name"] == "报告.pdf"
|
||||
assert message.files[0]["size"] == 1024
|
||||
video_payload = WechatClawBot._decode_ref_payload(message.files[1]["ref"], "file")
|
||||
assert video_payload["url"] == video_url
|
||||
assert message.files[1]["name"] == "video.mp4"
|
||||
|
||||
|
||||
def test_ilink_parse_incoming_keeps_legacy_file_item_types():
|
||||
"""历史报文把文件放在 type=5/6 的 file_item 中,仍需保持兼容。"""
|
||||
client = _create_client()
|
||||
file_url = "https://novac2c.cdn.weixin.qq.com/c2c/download?fileid=legacy-1"
|
||||
|
||||
message = client._parse_incoming(
|
||||
{
|
||||
"message_id": "msg-file-legacy",
|
||||
"from_user_id": "wxid_user_1",
|
||||
"item_list": [
|
||||
{
|
||||
"type": 6,
|
||||
"file_item": {
|
||||
"name": "archive.zip",
|
||||
"media": {"full_url": file_url},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert message is not None
|
||||
assert len(message.files) == 1
|
||||
file_payload = WechatClawBot._decode_ref_payload(message.files[0]["ref"], "file")
|
||||
assert file_payload["url"] == file_url
|
||||
assert file_payload["name"] == "archive.zip"
|
||||
|
||||
|
||||
def test_ilink_extract_updates_keeps_empty_sync_buf():
|
||||
client = _create_client(bot_token="token", sync_buf="cursor-old")
|
||||
|
||||
items, sync_buf = client._extract_updates(
|
||||
{
|
||||
"ret": 0,
|
||||
"get_updates_buf": "",
|
||||
"msgs": [
|
||||
@@ -100,276 +160,342 @@ class WechatClawBotTest(unittest.TestCase):
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
with patch("app.modules.wechatclawbot.wechatclawbot.RequestUtils.post", return_value=response):
|
||||
messages, sync_buf, result = client.poll_updates()
|
||||
assert sync_buf == ""
|
||||
assert len(items) == 1
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(sync_buf, "")
|
||||
self.assertEqual(client.sync_buf, "")
|
||||
self.assertEqual(len(messages), 1)
|
||||
|
||||
def test_ilink_poll_updates_accepts_canonical_payload_without_explicit_success_flag(self):
|
||||
client = ILinkClient(
|
||||
base_url="https://ilinkai.weixin.qq.com",
|
||||
bot_token="token",
|
||||
sync_buf="cursor-old",
|
||||
)
|
||||
response = MagicMock()
|
||||
response.json.return_value = {
|
||||
"sync_buf": "cursor-new",
|
||||
"msgs": [
|
||||
def test_ilink_poll_updates_resets_sync_buf_when_server_returns_empty_cursor():
|
||||
client = _create_client(bot_token="token", sync_buf="cursor-old")
|
||||
response = MagicMock()
|
||||
response.json.return_value = {
|
||||
"ret": 0,
|
||||
"get_updates_buf": "",
|
||||
"msgs": [
|
||||
{
|
||||
"message_id": "msg-1001",
|
||||
"from_user_id": "wxid_user_1",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "你好"}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with patch("app.modules.wechatclawbot.wechatclawbot.RequestUtils.post", return_value=response):
|
||||
messages, sync_buf, result = client.poll_updates()
|
||||
|
||||
assert result["success"]
|
||||
assert sync_buf == ""
|
||||
assert client.sync_buf == ""
|
||||
assert len(messages) == 1
|
||||
|
||||
|
||||
def test_ilink_poll_updates_accepts_canonical_payload_without_explicit_success_flag():
|
||||
client = _create_client(bot_token="token", sync_buf="cursor-old")
|
||||
response = MagicMock()
|
||||
response.json.return_value = {
|
||||
"sync_buf": "cursor-new",
|
||||
"msgs": [
|
||||
{
|
||||
"message_id": "msg-1002",
|
||||
"from_user_id": "wxid_user_2",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "收到"}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with patch("app.modules.wechatclawbot.wechatclawbot.RequestUtils.post", return_value=response):
|
||||
messages, sync_buf, result = client.poll_updates()
|
||||
|
||||
assert result["success"]
|
||||
assert sync_buf == "cursor-new"
|
||||
assert client.sync_buf == "cursor-new"
|
||||
assert len(messages) == 1
|
||||
assert messages[0].text == "收到"
|
||||
|
||||
|
||||
def test_ilink_poll_updates_rejects_noncanonical_nested_success_payload():
|
||||
client = _create_client(bot_token="token", sync_buf="cursor-old")
|
||||
response = MagicMock()
|
||||
response.json.return_value = {
|
||||
"ret": 0,
|
||||
"data": {
|
||||
"get_updates_buf": "cursor-new",
|
||||
"messages": [
|
||||
{
|
||||
"message_id": "msg-1002",
|
||||
"from_user_id": "wxid_user_2",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "收到"}}],
|
||||
"message_id": "msg-1001",
|
||||
"from_user_id": "wxid_user_1",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "你好"}}],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
with patch("app.modules.wechatclawbot.wechatclawbot.RequestUtils.post", return_value=response):
|
||||
messages, sync_buf, result = client.poll_updates()
|
||||
|
||||
assert not result["success"]
|
||||
assert result["message"] == "轮询响应结构非官方,缺少顶层 msgs 字段"
|
||||
assert sync_buf == "cursor-old"
|
||||
assert client.sync_buf == "cursor-old"
|
||||
assert messages == []
|
||||
|
||||
|
||||
def test_ilink_poll_updates_rejects_failed_payload_even_if_it_contains_messages():
|
||||
client = _create_client(bot_token="token", sync_buf="cursor-old")
|
||||
failed_response = MagicMock()
|
||||
failed_response.json.return_value = {
|
||||
"ret": -2,
|
||||
"errmsg": "cursor invalid",
|
||||
"data": {
|
||||
"sync_buf": "cursor-old",
|
||||
"messages": [
|
||||
{
|
||||
"message_id": "msg-dup-1",
|
||||
"from_user_id": "wxid_user_1",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "旧消息"}}],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.modules.wechatclawbot.wechatclawbot.RequestUtils.post",
|
||||
return_value=failed_response,
|
||||
) as mock_post:
|
||||
messages, sync_buf, result = client.poll_updates()
|
||||
|
||||
assert not result["success"]
|
||||
assert result["message"] == "cursor invalid"
|
||||
assert sync_buf == "cursor-old"
|
||||
assert client.sync_buf == "cursor-old"
|
||||
assert messages == []
|
||||
mock_post.assert_called_once()
|
||||
request_body = mock_post.call_args.kwargs["json"]
|
||||
assert "get_updates_buf" in request_body
|
||||
assert "sync_buf" not in request_body
|
||||
assert "syncBuf" not in request_body
|
||||
|
||||
|
||||
def test_ilink_test_connection_accepts_getconfig_ilink_user_id_limitation():
|
||||
client = _create_client(bot_token="token")
|
||||
response = MagicMock()
|
||||
response.json.return_value = {
|
||||
"ret": -1,
|
||||
"errmsg": "ilink_user_id required",
|
||||
}
|
||||
|
||||
with patch("app.modules.wechatclawbot.wechatclawbot.RequestUtils.post", return_value=response):
|
||||
ok, message = client.test_connection()
|
||||
|
||||
# `ilink_user_id required` 仅表示自检接口缺少额外参数,不代表连接失败:视为连接正常
|
||||
assert ok
|
||||
assert message == "连接正常"
|
||||
|
||||
|
||||
def test_wechatclawbot_message_parser_deduplicates_message_id():
|
||||
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=_module_config()):
|
||||
first = module.message_parser(
|
||||
source="wechatclawbot-test",
|
||||
body=body,
|
||||
form={},
|
||||
args={},
|
||||
)
|
||||
second = module.message_parser(
|
||||
source="wechatclawbot-test",
|
||||
body=body,
|
||||
form={},
|
||||
args={},
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert first.message_id == "msg-1001"
|
||||
assert second is None
|
||||
|
||||
|
||||
def test_wechatclawbot_message_parser_accepts_image_only_message():
|
||||
"""图片入站消息(无文本)应进入 Agent 链路,而不是被判为无效消息丢弃。"""
|
||||
module = WechatClawBotModule()
|
||||
client = _create_client()
|
||||
parsed_message = client._parse_incoming(
|
||||
{
|
||||
"message_id": "msg-image-only",
|
||||
"from_user_id": "wxid_user_1",
|
||||
"item_list": [
|
||||
{
|
||||
"type": 2,
|
||||
"image_item": {
|
||||
"aeskey": "0123456789abcdef0123456789abcdef",
|
||||
"media": {
|
||||
"full_url": "https://novac2c.cdn.weixin.qq.com/c2c/download?fileid=image-only",
|
||||
"aes_key": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert parsed_message is not None
|
||||
|
||||
with patch("app.modules.wechatclawbot.wechatclawbot.RequestUtils.post", return_value=response):
|
||||
messages, sync_buf, result = client.poll_updates()
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(sync_buf, "cursor-new")
|
||||
self.assertEqual(client.sync_buf, "cursor-new")
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEqual(messages[0].text, "收到")
|
||||
|
||||
def test_ilink_poll_updates_rejects_noncanonical_nested_success_payload(self):
|
||||
client = ILinkClient(
|
||||
base_url="https://ilinkai.weixin.qq.com",
|
||||
bot_token="token",
|
||||
sync_buf="cursor-old",
|
||||
)
|
||||
response = MagicMock()
|
||||
response.json.return_value = {
|
||||
"ret": 0,
|
||||
"data": {
|
||||
"get_updates_buf": "cursor-new",
|
||||
"messages": [
|
||||
{
|
||||
"message_id": "msg-1001",
|
||||
"from_user_id": "wxid_user_1",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "你好"}}],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
with patch("app.modules.wechatclawbot.wechatclawbot.RequestUtils.post", return_value=response):
|
||||
messages, sync_buf, result = client.poll_updates()
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["message"], "轮询响应结构非官方,缺少顶层 msgs 字段")
|
||||
self.assertEqual(sync_buf, "cursor-old")
|
||||
self.assertEqual(client.sync_buf, "cursor-old")
|
||||
self.assertEqual(messages, [])
|
||||
|
||||
def test_ilink_poll_updates_rejects_failed_payload_even_if_it_contains_messages(self):
|
||||
client = ILinkClient(
|
||||
base_url="https://ilinkai.weixin.qq.com",
|
||||
bot_token="token",
|
||||
sync_buf="cursor-old",
|
||||
)
|
||||
failed_response = MagicMock()
|
||||
failed_response.json.return_value = {
|
||||
"ret": -2,
|
||||
"errmsg": "cursor invalid",
|
||||
"data": {
|
||||
"sync_buf": "cursor-old",
|
||||
"messages": [
|
||||
{
|
||||
"message_id": "msg-dup-1",
|
||||
"from_user_id": "wxid_user_1",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "旧消息"}}],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.modules.wechatclawbot.wechatclawbot.RequestUtils.post",
|
||||
return_value=failed_response,
|
||||
) as mock_post:
|
||||
messages, sync_buf, result = client.poll_updates()
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["message"], "cursor invalid")
|
||||
self.assertEqual(sync_buf, "cursor-old")
|
||||
self.assertEqual(client.sync_buf, "cursor-old")
|
||||
self.assertEqual(messages, [])
|
||||
mock_post.assert_called_once()
|
||||
request_body = mock_post.call_args.kwargs["json"]
|
||||
self.assertIn("get_updates_buf", request_body)
|
||||
self.assertNotIn("sync_buf", request_body)
|
||||
self.assertNotIn("syncBuf", request_body)
|
||||
|
||||
def test_ilink_test_connection_accepts_getconfig_ilink_user_id_limitation(self):
|
||||
client = ILinkClient(
|
||||
base_url="https://ilinkai.weixin.qq.com",
|
||||
bot_token="token",
|
||||
)
|
||||
response = MagicMock()
|
||||
response.json.return_value = {
|
||||
"ret": -1,
|
||||
"errmsg": "ilink_user_id required",
|
||||
}
|
||||
|
||||
with patch("app.modules.wechatclawbot.wechatclawbot.RequestUtils.post", return_value=response):
|
||||
ok, message = client.test_connection()
|
||||
|
||||
# `ilink_user_id required` 仅表示自检接口缺少额外参数,不代表连接失败:视为连接正常
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(message, "连接正常")
|
||||
|
||||
def test_wechatclawbot_send_msg_uses_plain_text_payload(self):
|
||||
state = {
|
||||
"bot_token": None,
|
||||
"account_id": None,
|
||||
"sync_buf": None,
|
||||
"qrcode": {},
|
||||
"known_targets": {},
|
||||
"user_context_tokens": {},
|
||||
"base_url": "https://ilinkai.weixin.qq.com",
|
||||
}
|
||||
with patch.object(WechatClawBot, "_load_state", return_value=state):
|
||||
bot = WechatClawBot(name="wechatclawbot-test", auto_start_polling=False)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_text.return_value = True
|
||||
|
||||
with patch.object(bot, "_build_client", return_value=mock_client):
|
||||
result = bot.send_msg(
|
||||
title="测试标题",
|
||||
text="测试正文",
|
||||
userid="wxid_user_1",
|
||||
link="https://example.com/detail",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_client.send_text.assert_called_once_with(
|
||||
to_user="wxid_user_1",
|
||||
text="测试标题\n\n测试正文\n\n查看详情:https://example.com/detail",
|
||||
context_token=None,
|
||||
with patch.object(module, "get_config", return_value=_module_config()):
|
||||
parsed = module.message_parser(
|
||||
source="wechatclawbot-test",
|
||||
body=json.dumps(parsed_message.to_message_payload()),
|
||||
form={},
|
||||
args={},
|
||||
)
|
||||
|
||||
def test_wechatclawbot_send_msg_prefers_text_when_text_and_image_coexist(self):
|
||||
state = {
|
||||
"bot_token": None,
|
||||
"account_id": None,
|
||||
"sync_buf": None,
|
||||
"qrcode": {},
|
||||
"known_targets": {},
|
||||
"user_context_tokens": {},
|
||||
"base_url": "https://ilinkai.weixin.qq.com",
|
||||
assert parsed is not None
|
||||
assert parsed.images is not None
|
||||
assert parsed.images[0].ref.startswith("wxclaw://image/")
|
||||
assert not parsed.text
|
||||
|
||||
|
||||
def test_wechatclawbot_message_parser_logs_unrecognized_content():
|
||||
"""无法识别的入站内容应留下诊断日志,而不是静默丢弃。"""
|
||||
module = WechatClawBotModule()
|
||||
body = json.dumps(
|
||||
{
|
||||
"__channel__": "wechatclawbot",
|
||||
"userid": "wxid_user_1",
|
||||
"message_id": "msg-unknown-1",
|
||||
"item_list": [{"type": 99, "unknown_item": {"foo": "bar"}}],
|
||||
}
|
||||
with patch.object(WechatClawBot, "_load_state", return_value=state):
|
||||
bot = WechatClawBot(name="wechatclawbot-test", auto_start_polling=False)
|
||||
)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_text.return_value = True
|
||||
mock_client.send_image_png.return_value = True
|
||||
mock_client.send_image_text_png.return_value = True
|
||||
with (
|
||||
patch.object(module, "get_config", return_value=_module_config()),
|
||||
patch("app.modules.wechatclawbot.logger") as mock_logger,
|
||||
):
|
||||
parsed = module.message_parser(
|
||||
source="wechatclawbot-test",
|
||||
body=body,
|
||||
form={},
|
||||
args={},
|
||||
)
|
||||
|
||||
assert parsed is None
|
||||
mock_logger.info.assert_called_once()
|
||||
assert "未识别到有效内容" in mock_logger.info.call_args.args[0]
|
||||
|
||||
|
||||
def test_wechatclawbot_send_msg_uses_plain_text_payload():
|
||||
bot = _create_bot()
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_text.return_value = True
|
||||
|
||||
with patch.object(bot, "_build_client", return_value=mock_client):
|
||||
result = bot.send_msg(
|
||||
title="测试标题",
|
||||
text="测试正文",
|
||||
userid="wxid_user_1",
|
||||
link="https://example.com/detail",
|
||||
)
|
||||
|
||||
assert result
|
||||
mock_client.send_text.assert_called_once_with(
|
||||
to_user="wxid_user_1",
|
||||
text="测试标题\n\n测试正文\n\n查看详情:https://example.com/detail",
|
||||
context_token=None,
|
||||
)
|
||||
|
||||
|
||||
def test_wechatclawbot_send_msg_prefers_text_when_text_and_image_coexist():
|
||||
bot = _create_bot()
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_text.return_value = True
|
||||
mock_client.send_image_png.return_value = True
|
||||
mock_client.send_image_text_png.return_value = True
|
||||
|
||||
with (
|
||||
patch.object(bot, "_build_client", return_value=mock_client),
|
||||
patch.object(bot, "_load_remote_image", return_value=b"image-bytes") as mock_load_image,
|
||||
):
|
||||
result = bot.send_msg(
|
||||
title="测试标题",
|
||||
text="测试正文",
|
||||
image="https://example.com/test.png",
|
||||
userid="wxid_user_1",
|
||||
)
|
||||
|
||||
assert result
|
||||
mock_load_image.assert_not_called()
|
||||
mock_client.send_text.assert_called_once_with(
|
||||
to_user="wxid_user_1",
|
||||
text="测试标题\n\n测试正文",
|
||||
context_token=None,
|
||||
)
|
||||
mock_client.send_image_png.assert_not_called()
|
||||
mock_client.send_image_text_png.assert_not_called()
|
||||
|
||||
|
||||
def test_wechatclawbot_send_file_prefers_image_when_image_file_has_caption():
|
||||
bot = _create_bot()
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_text.return_value = True
|
||||
mock_client.send_image_png.return_value = True
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png") as image_file:
|
||||
image_file.write(b"\x89PNG\r\n\x1a\nfake-png")
|
||||
image_file.flush()
|
||||
with (
|
||||
patch.object(bot, "_build_client", return_value=mock_client),
|
||||
patch.object(bot, "_load_remote_image", return_value=b"image-bytes") as mock_load_image,
|
||||
patch.object(bot, "_guess_mime_type", return_value="image/png"),
|
||||
):
|
||||
result = bot.send_msg(
|
||||
title="测试标题",
|
||||
text="测试正文",
|
||||
image="https://example.com/test.png",
|
||||
result = bot.send_file(
|
||||
file_path=image_file.name,
|
||||
title="图片标题",
|
||||
text="图片说明",
|
||||
userid="wxid_user_1",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_load_image.assert_not_called()
|
||||
mock_client.send_text.assert_called_once_with(
|
||||
to_user="wxid_user_1",
|
||||
text="测试标题\n\n测试正文",
|
||||
context_token=None,
|
||||
)
|
||||
mock_client.send_image_png.assert_not_called()
|
||||
mock_client.send_image_text_png.assert_not_called()
|
||||
assert result
|
||||
mock_client.send_text.assert_not_called()
|
||||
mock_client.send_image_png.assert_called_once_with(
|
||||
to_user="wxid_user_1",
|
||||
image_bytes=b"\x89PNG\r\n\x1a\nfake-png",
|
||||
context_token=None,
|
||||
)
|
||||
|
||||
def test_wechatclawbot_send_file_prefers_image_when_image_file_has_caption(self):
|
||||
state = {
|
||||
"bot_token": None,
|
||||
"account_id": None,
|
||||
"sync_buf": None,
|
||||
"qrcode": {},
|
||||
"known_targets": {},
|
||||
"user_context_tokens": {},
|
||||
"base_url": "https://ilinkai.weixin.qq.com",
|
||||
}
|
||||
with patch.object(WechatClawBot, "_load_state", return_value=state):
|
||||
bot = WechatClawBot(name="wechatclawbot-test", auto_start_polling=False)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_text.return_value = True
|
||||
mock_client.send_image_png.return_value = True
|
||||
def test_wechatclawbot_send_file_prefers_file_when_generic_file_has_caption():
|
||||
bot = _create_bot()
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_text.return_value = True
|
||||
mock_client.send_file_bytes.return_value = True
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png") as image_file:
|
||||
image_file.write(b"\x89PNG\r\n\x1a\nfake-png")
|
||||
image_file.flush()
|
||||
with (
|
||||
patch.object(bot, "_build_client", return_value=mock_client),
|
||||
patch.object(bot, "_guess_mime_type", return_value="image/png"),
|
||||
):
|
||||
result = bot.send_file(
|
||||
file_path=image_file.name,
|
||||
title="图片标题",
|
||||
text="图片说明",
|
||||
userid="wxid_user_1",
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(suffix=".txt") as text_file:
|
||||
text_file.write(b"plain-text")
|
||||
text_file.flush()
|
||||
with (
|
||||
patch.object(bot, "_build_client", return_value=mock_client),
|
||||
patch.object(bot, "_guess_mime_type", return_value="text/plain"),
|
||||
):
|
||||
result = bot.send_file(
|
||||
file_path=text_file.name,
|
||||
file_name="report.txt",
|
||||
title="文件标题",
|
||||
text="文件说明",
|
||||
userid="wxid_user_1",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_client.send_text.assert_not_called()
|
||||
mock_client.send_image_png.assert_called_once_with(
|
||||
to_user="wxid_user_1",
|
||||
image_bytes=b"\x89PNG\r\n\x1a\nfake-png",
|
||||
context_token=None,
|
||||
)
|
||||
|
||||
def test_wechatclawbot_send_file_prefers_file_when_generic_file_has_caption(self):
|
||||
state = {
|
||||
"bot_token": None,
|
||||
"account_id": None,
|
||||
"sync_buf": None,
|
||||
"qrcode": {},
|
||||
"known_targets": {},
|
||||
"user_context_tokens": {},
|
||||
"base_url": "https://ilinkai.weixin.qq.com",
|
||||
}
|
||||
with patch.object(WechatClawBot, "_load_state", return_value=state):
|
||||
bot = WechatClawBot(name="wechatclawbot-test", auto_start_polling=False)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_text.return_value = True
|
||||
mock_client.send_file_bytes.return_value = True
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".txt") as text_file:
|
||||
text_file.write(b"plain-text")
|
||||
text_file.flush()
|
||||
with (
|
||||
patch.object(bot, "_build_client", return_value=mock_client),
|
||||
patch.object(bot, "_guess_mime_type", return_value="text/plain"),
|
||||
):
|
||||
result = bot.send_file(
|
||||
file_path=text_file.name,
|
||||
file_name="report.txt",
|
||||
title="文件标题",
|
||||
text="文件说明",
|
||||
userid="wxid_user_1",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_client.send_text.assert_not_called()
|
||||
mock_client.send_file_bytes.assert_called_once_with(
|
||||
to_user="wxid_user_1",
|
||||
file_bytes=b"plain-text",
|
||||
file_name="report.txt",
|
||||
mime_type="text/plain",
|
||||
context_token=None,
|
||||
)
|
||||
assert result
|
||||
mock_client.send_text.assert_not_called()
|
||||
mock_client.send_file_bytes.assert_called_once_with(
|
||||
to_user="wxid_user_1",
|
||||
file_bytes=b"plain-text",
|
||||
file_name="report.txt",
|
||||
mime_type="text/plain",
|
||||
context_token=None,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user