Fix Weixin media uploads and refresh lockfile

This commit is contained in:
Young Sherlock 2026-04-15 21:05:19 +08:00 committed by Teknium
parent 3a0ec1d935
commit 8dcd08d8bb
2 changed files with 129 additions and 8 deletions

View file

@ -1685,23 +1685,34 @@ class WeixinAdapter(BasePlatformAdapter):
self, self,
chat_id: str, chat_id: str,
image_path: str, image_path: str,
caption: str = "", caption: Optional[str] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult: ) -> SendResult:
return await self.send_document(chat_id, file_path=image_path, caption=caption, metadata=metadata) del reply_to, kwargs
return await self.send_document(
chat_id=chat_id,
file_path=image_path,
caption=caption,
metadata=metadata,
)
async def send_document( async def send_document(
self, self,
chat_id: str, chat_id: str,
file_path: str, file_path: str,
caption: str = "", caption: Optional[str] = None,
file_name: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult: ) -> SendResult:
del file_name, reply_to, metadata, kwargs
if not self._send_session or not self._token: if not self._send_session or not self._token:
return SendResult(success=False, error="Not connected") return SendResult(success=False, error="Not connected")
try: try:
message_id = await self._send_file(chat_id, file_path, caption) message_id = await self._send_file(chat_id, file_path, caption or "")
return SendResult(success=True, message_id=message_id) return SendResult(success=True, message_id=message_id)
except Exception as exc: except Exception as exc:
logger.error("[%s] send_document failed to=%s: %s", self.name, _safe_id(chat_id), exc) logger.error("[%s] send_document failed to=%s: %s", self.name, _safe_id(chat_id), exc)
@ -1811,7 +1822,6 @@ class WeixinAdapter(BasePlatformAdapter):
ciphertext=ciphertext, ciphertext=ciphertext,
upload_url=upload_url, upload_url=upload_url,
) )
context_token = self._token_store.get(self._account_id, chat_id) context_token = self._token_store.get(self._account_id, chat_id)
# The iLink API expects aes_key as base64(hex_string), not base64(raw_bytes). # The iLink API expects aes_key as base64(hex_string), not base64(raw_bytes).
# Sending base64(raw_bytes) causes images to show as grey boxes on the # Sending base64(raw_bytes) causes images to show as grey boxes on the

View file

@ -1,6 +1,7 @@
"""Tests for the Weixin platform adapter.""" """Tests for the Weixin platform adapter."""
import asyncio import asyncio
import base64
import json import json
import os import os
from pathlib import Path from pathlib import Path
@ -8,6 +9,7 @@ from unittest.mock import AsyncMock, patch
from gateway.config import PlatformConfig from gateway.config import PlatformConfig
from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides
from gateway.platforms.base import SendResult
from gateway.platforms import weixin from gateway.platforms import weixin
from gateway.platforms.weixin import ContextTokenStore, WeixinAdapter from gateway.platforms.weixin import ContextTokenStore, WeixinAdapter
from tools.send_message_tool import _parse_target_ref, _send_to_platform from tools.send_message_tool import _parse_target_ref, _send_to_platform
@ -357,6 +359,115 @@ class TestWeixinChunkDelivery:
assert first_try["client_id"] == retry["client_id"] assert first_try["client_id"] == retry["client_id"]
class TestWeixinOutboundMedia:
def test_send_image_file_accepts_keyword_image_path(self):
adapter = _make_adapter()
expected = SendResult(success=True, message_id="msg-1")
adapter.send_document = AsyncMock(return_value=expected)
result = asyncio.run(
adapter.send_image_file(
chat_id="wxid_test123",
image_path="/tmp/demo.png",
caption="截图说明",
reply_to="reply-1",
metadata={"thread_id": "t-1"},
)
)
assert result == expected
adapter.send_document.assert_awaited_once_with(
chat_id="wxid_test123",
file_path="/tmp/demo.png",
caption="截图说明",
metadata={"thread_id": "t-1"},
)
def test_send_document_accepts_keyword_file_path(self):
adapter = _make_adapter()
adapter._session = object()
adapter._send_session = adapter._session
adapter._token = "test-token"
adapter._send_file = AsyncMock(return_value="msg-2")
result = asyncio.run(
adapter.send_document(
chat_id="wxid_test123",
file_path="/tmp/report.pdf",
caption="报告请看",
file_name="renamed.pdf",
reply_to="reply-1",
metadata={"thread_id": "t-1"},
)
)
assert result.success is True
assert result.message_id == "msg-2"
adapter._send_file.assert_awaited_once_with("wxid_test123", "/tmp/report.pdf", "报告请看")
def test_send_file_uses_post_for_upload_full_url_and_hex_encoded_aes_key(self, tmp_path):
class _UploadResponse:
def __init__(self):
self.status = 200
self.headers = {"x-encrypted-param": "enc-param"}
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def read(self):
return b""
async def text(self):
return ""
class _RecordingSession:
def __init__(self):
self.post_calls = []
def post(self, url, **kwargs):
self.post_calls.append((url, kwargs))
return _UploadResponse()
def put(self, *_args, **_kwargs):
raise AssertionError("upload_full_url branch should use POST")
image_path = tmp_path / "demo.png"
image_path.write_bytes(b"fake-png-bytes")
adapter = _make_adapter()
session = _RecordingSession()
adapter._session = session
adapter._send_session = session
adapter._token = "test-token"
adapter._base_url = "https://weixin.example.com"
adapter._cdn_base_url = "https://cdn.example.com/c2c"
adapter._token_store.get = lambda account_id, chat_id: None
aes_key = bytes(range(16))
expected_aes_key = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii")
with patch("gateway.platforms.weixin._get_upload_url", new=AsyncMock(return_value={"upload_full_url": "https://upload.example.com/media"})), \
patch("gateway.platforms.weixin._api_post", new_callable=AsyncMock) as api_post_mock, \
patch("gateway.platforms.weixin.secrets.token_hex", return_value="filekey-123"), \
patch("gateway.platforms.weixin.secrets.token_bytes", return_value=aes_key):
message_id = asyncio.run(adapter._send_file("wxid_test123", str(image_path), ""))
assert message_id.startswith("hermes-weixin-")
assert len(session.post_calls) == 1
upload_url, upload_kwargs = session.post_calls[0]
assert upload_url == "https://upload.example.com/media"
assert upload_kwargs["headers"] == {"Content-Type": "application/octet-stream"}
assert upload_kwargs["data"]
assert upload_kwargs["timeout"].total == 120
payload = api_post_mock.await_args.kwargs["payload"]
media = payload["msg"]["item_list"][0]["image_item"]["media"]
assert media["encrypt_query_param"] == "enc-param"
assert media["aes_key"] == expected_aes_key
class TestWeixinRemoteMediaSafety: class TestWeixinRemoteMediaSafety:
def test_download_remote_media_blocks_unsafe_urls(self): def test_download_remote_media_blocks_unsafe_urls(self):
adapter = _make_adapter() adapter = _make_adapter()
@ -544,7 +655,7 @@ class TestWeixinSendImageFileParameterName:
assert result.success is True assert result.success is True
send_document_mock.assert_awaited_once_with( send_document_mock.assert_awaited_once_with(
"wxid_test123", chat_id="wxid_test123",
file_path="/tmp/test_image.png", file_path="/tmp/test_image.png",
caption="Test caption", caption="Test caption",
metadata={"thread_id": "thread-123"}, metadata={"thread_id": "thread-123"},
@ -569,9 +680,9 @@ class TestWeixinSendImageFileParameterName:
assert result.success is True assert result.success is True
send_document_mock.assert_awaited_once_with( send_document_mock.assert_awaited_once_with(
"wxid_test123", chat_id="wxid_test123",
file_path="/tmp/test_image.jpg", file_path="/tmp/test_image.jpg",
caption="", caption=None,
metadata=None, metadata=None,
) )