feat(gateway): add WeCom (Enterprise WeChat) platform support (#3847)
Adds WeCom as a gateway platform adapter using the AI Bot WebSocket gateway for real-time bidirectional communication. No public endpoint or new pip dependencies needed (uses existing aiohttp + httpx). Features: - WebSocket persistent connection with auto-reconnect (exponential backoff) - DM and group messaging with configurable access policies - Media upload/download with AES decryption for encrypted attachments - Markdown rendering, quote context preservation - Proactive + passive reply message modes - Chunked media upload pipeline (512KB chunks) Cherry-picked from PR #1898 by EvilRan with: - Moved to current main (PR was 300 commits behind) - Skipped base.py regressions (reply_to additions are good but belong in a separate PR since they affect all platforms) - Fixed test assertions to match current base class send() signature (reply_to=None kwarg now explicit) - All 16 integration points added surgically to current main - No new pip dependencies (aiohttp + httpx already installed) Fixes #1898 Co-authored-by: EvilRan <EvilRan@users.noreply.github.com>
This commit is contained in:
@@ -13,7 +13,7 @@ def _would_warn():
|
||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||
"EMAIL_ALLOWED_USERS",
|
||||
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
|
||||
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS",
|
||||
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS")
|
||||
)
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
||||
@@ -22,7 +22,7 @@ def _would_warn():
|
||||
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
|
||||
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
|
||||
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
|
||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS")
|
||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS")
|
||||
)
|
||||
return not _any_allowlist and not _allow_all
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ def _clear_auth_env(monkeypatch) -> None:
|
||||
"SMS_ALLOWED_USERS",
|
||||
"MATTERMOST_ALLOWED_USERS",
|
||||
"MATRIX_ALLOWED_USERS",
|
||||
"DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS",
|
||||
"DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS",
|
||||
"TELEGRAM_ALLOW_ALL_USERS",
|
||||
"DISCORD_ALLOW_ALL_USERS",
|
||||
@@ -31,7 +31,7 @@ def _clear_auth_env(monkeypatch) -> None:
|
||||
"SMS_ALLOW_ALL_USERS",
|
||||
"MATTERMOST_ALLOW_ALL_USERS",
|
||||
"MATRIX_ALLOW_ALL_USERS",
|
||||
"DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS",
|
||||
"DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS",
|
||||
"GATEWAY_ALLOW_ALL_USERS",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
596
tests/gateway/test_wecom.py
Normal file
596
tests/gateway/test_wecom.py
Normal file
@@ -0,0 +1,596 @@
|
||||
"""Tests for the WeCom platform adapter."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import SendResult
|
||||
|
||||
|
||||
class TestWeComRequirements:
|
||||
def test_returns_false_without_aiohttp(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", False)
|
||||
monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", True)
|
||||
from gateway.platforms.wecom import check_wecom_requirements
|
||||
|
||||
assert check_wecom_requirements() is False
|
||||
|
||||
def test_returns_false_without_httpx(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", True)
|
||||
monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", False)
|
||||
from gateway.platforms.wecom import check_wecom_requirements
|
||||
|
||||
assert check_wecom_requirements() is False
|
||||
|
||||
def test_returns_true_when_available(self, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", True)
|
||||
monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", True)
|
||||
from gateway.platforms.wecom import check_wecom_requirements
|
||||
|
||||
assert check_wecom_requirements() is True
|
||||
|
||||
|
||||
class TestWeComAdapterInit:
|
||||
def test_reads_config_from_extra(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"bot_id": "cfg-bot",
|
||||
"secret": "cfg-secret",
|
||||
"websocket_url": "wss://custom.wecom.example/ws",
|
||||
"group_policy": "allowlist",
|
||||
"group_allow_from": ["group-1"],
|
||||
},
|
||||
)
|
||||
adapter = WeComAdapter(config)
|
||||
|
||||
assert adapter._bot_id == "cfg-bot"
|
||||
assert adapter._secret == "cfg-secret"
|
||||
assert adapter._ws_url == "wss://custom.wecom.example/ws"
|
||||
assert adapter._group_policy == "allowlist"
|
||||
assert adapter._group_allow_from == ["group-1"]
|
||||
|
||||
def test_falls_back_to_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("WECOM_BOT_ID", "env-bot")
|
||||
monkeypatch.setenv("WECOM_SECRET", "env-secret")
|
||||
monkeypatch.setenv("WECOM_WEBSOCKET_URL", "wss://env.example/ws")
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
assert adapter._bot_id == "env-bot"
|
||||
assert adapter._secret == "env-secret"
|
||||
assert adapter._ws_url == "wss://env.example/ws"
|
||||
|
||||
|
||||
class TestWeComConnect:
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_records_missing_credentials(self, monkeypatch):
|
||||
import gateway.platforms.wecom as wecom_module
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
monkeypatch.setattr(wecom_module, "AIOHTTP_AVAILABLE", True)
|
||||
monkeypatch.setattr(wecom_module, "HTTPX_AVAILABLE", True)
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
success = await adapter.connect()
|
||||
|
||||
assert success is False
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_code == "wecom_missing_credentials"
|
||||
assert "WECOM_BOT_ID" in (adapter.fatal_error_message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_records_handshake_failure_details(self, monkeypatch):
|
||||
import gateway.platforms.wecom as wecom_module
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
class DummyClient:
|
||||
async def aclose(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(wecom_module, "AIOHTTP_AVAILABLE", True)
|
||||
monkeypatch.setattr(wecom_module, "HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setattr(
|
||||
wecom_module,
|
||||
"httpx",
|
||||
SimpleNamespace(AsyncClient=lambda **kwargs: DummyClient()),
|
||||
)
|
||||
|
||||
adapter = WeComAdapter(
|
||||
PlatformConfig(enabled=True, extra={"bot_id": "bot-1", "secret": "secret-1"})
|
||||
)
|
||||
adapter._open_connection = AsyncMock(side_effect=RuntimeError("invalid secret (errcode=40013)"))
|
||||
|
||||
success = await adapter.connect()
|
||||
|
||||
assert success is False
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_code == "wecom_connect_error"
|
||||
assert "invalid secret" in (adapter.fatal_error_message or "")
|
||||
|
||||
|
||||
class TestWeComReplyMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_passive_reply_stream_when_reply_context_exists(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._reply_req_ids["msg-1"] = "req-1"
|
||||
adapter._send_reply_request = AsyncMock(
|
||||
return_value={"headers": {"req_id": "req-1"}, "errcode": 0}
|
||||
)
|
||||
|
||||
result = await adapter.send("chat-123", "hello from reply", reply_to="msg-1")
|
||||
|
||||
assert result.success is True
|
||||
adapter._send_reply_request.assert_awaited_once()
|
||||
args = adapter._send_reply_request.await_args.args
|
||||
assert args[0] == "req-1"
|
||||
assert args[1]["msgtype"] == "stream"
|
||||
assert args[1]["stream"]["finish"] is True
|
||||
assert args[1]["stream"]["content"] == "hello from reply"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_uses_passive_reply_media_when_reply_context_exists(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._reply_req_ids["msg-1"] = "req-1"
|
||||
adapter._prepare_outbound_media = AsyncMock(
|
||||
return_value={
|
||||
"data": b"image-bytes",
|
||||
"content_type": "image/png",
|
||||
"file_name": "demo.png",
|
||||
"detected_type": "image",
|
||||
"final_type": "image",
|
||||
"rejected": False,
|
||||
"reject_reason": None,
|
||||
"downgraded": False,
|
||||
"downgrade_note": None,
|
||||
}
|
||||
)
|
||||
adapter._upload_media_bytes = AsyncMock(return_value={"media_id": "media-1", "type": "image"})
|
||||
adapter._send_reply_request = AsyncMock(
|
||||
return_value={"headers": {"req_id": "req-1"}, "errcode": 0}
|
||||
)
|
||||
|
||||
result = await adapter.send_image_file("chat-123", "/tmp/demo.png", reply_to="msg-1")
|
||||
|
||||
assert result.success is True
|
||||
adapter._send_reply_request.assert_awaited_once()
|
||||
args = adapter._send_reply_request.await_args.args
|
||||
assert args[0] == "req-1"
|
||||
assert args[1] == {"msgtype": "image", "image": {"media_id": "media-1"}}
|
||||
|
||||
|
||||
class TestExtractText:
|
||||
def test_extracts_plain_text(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
body = {
|
||||
"msgtype": "text",
|
||||
"text": {"content": " hello world "},
|
||||
}
|
||||
text, reply_text = WeComAdapter._extract_text(body)
|
||||
assert text == "hello world"
|
||||
assert reply_text is None
|
||||
|
||||
def test_extracts_mixed_text(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
body = {
|
||||
"msgtype": "mixed",
|
||||
"mixed": {
|
||||
"msg_item": [
|
||||
{"msgtype": "text", "text": {"content": "part1"}},
|
||||
{"msgtype": "image", "image": {"url": "https://example.com/x.png"}},
|
||||
{"msgtype": "text", "text": {"content": "part2"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
text, _reply_text = WeComAdapter._extract_text(body)
|
||||
assert text == "part1\npart2"
|
||||
|
||||
def test_extracts_voice_and_quote(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
body = {
|
||||
"msgtype": "voice",
|
||||
"voice": {"content": "spoken text"},
|
||||
"quote": {"msgtype": "text", "text": {"content": "quoted"}},
|
||||
}
|
||||
text, reply_text = WeComAdapter._extract_text(body)
|
||||
assert text == "spoken text"
|
||||
assert reply_text == "quoted"
|
||||
|
||||
|
||||
class TestCallbackDispatch:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("cmd", ["aibot_msg_callback", "aibot_callback"])
|
||||
async def test_dispatch_accepts_new_and_legacy_callback_cmds(self, cmd):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._on_message = AsyncMock()
|
||||
|
||||
await adapter._dispatch_payload({"cmd": cmd, "headers": {"req_id": "req-1"}, "body": {}})
|
||||
|
||||
adapter._on_message.assert_awaited_once()
|
||||
|
||||
|
||||
class TestPolicyHelpers:
|
||||
def test_dm_allowlist(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(
|
||||
PlatformConfig(enabled=True, extra={"dm_policy": "allowlist", "allow_from": ["user-1"]})
|
||||
)
|
||||
assert adapter._is_dm_allowed("user-1") is True
|
||||
assert adapter._is_dm_allowed("user-2") is False
|
||||
|
||||
def test_group_allowlist_and_per_group_sender_allowlist(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(
|
||||
PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"group_policy": "allowlist",
|
||||
"group_allow_from": ["group-1"],
|
||||
"groups": {"group-1": {"allow_from": ["user-1"]}},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert adapter._is_group_allowed("group-1", "user-1") is True
|
||||
assert adapter._is_group_allowed("group-1", "user-2") is False
|
||||
assert adapter._is_group_allowed("group-2", "user-1") is False
|
||||
|
||||
|
||||
class TestMediaHelpers:
|
||||
def test_detect_wecom_media_type(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
assert WeComAdapter._detect_wecom_media_type("image/png") == "image"
|
||||
assert WeComAdapter._detect_wecom_media_type("video/mp4") == "video"
|
||||
assert WeComAdapter._detect_wecom_media_type("audio/amr") == "voice"
|
||||
assert WeComAdapter._detect_wecom_media_type("application/pdf") == "file"
|
||||
|
||||
def test_voice_non_amr_downgrades_to_file(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
result = WeComAdapter._apply_file_size_limits(128, "voice", "audio/mpeg")
|
||||
|
||||
assert result["final_type"] == "file"
|
||||
assert result["downgraded"] is True
|
||||
assert "AMR" in (result["downgrade_note"] or "")
|
||||
|
||||
def test_oversized_file_is_rejected(self):
|
||||
from gateway.platforms.wecom import ABSOLUTE_MAX_BYTES, WeComAdapter
|
||||
|
||||
result = WeComAdapter._apply_file_size_limits(ABSOLUTE_MAX_BYTES + 1, "file", "application/pdf")
|
||||
|
||||
assert result["rejected"] is True
|
||||
assert "20MB" in (result["reject_reason"] or "")
|
||||
|
||||
def test_decrypt_file_bytes_round_trip(self):
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
plaintext = b"wecom-secret"
|
||||
key = os.urandom(32)
|
||||
pad_len = 32 - (len(plaintext) % 32)
|
||||
padded = plaintext + bytes([pad_len]) * pad_len
|
||||
encryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).encryptor()
|
||||
encrypted = encryptor.update(padded) + encryptor.finalize()
|
||||
|
||||
decrypted = WeComAdapter._decrypt_file_bytes(encrypted, base64.b64encode(key).decode("ascii"))
|
||||
|
||||
assert decrypted == plaintext
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_outbound_media_rejects_placeholder_path(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
with pytest.raises(ValueError, match="placeholder was not replaced"):
|
||||
await adapter._load_outbound_media("<path>")
|
||||
|
||||
|
||||
class TestMediaUpload:
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_media_bytes_uses_sdk_sequence(self, monkeypatch):
|
||||
import gateway.platforms.wecom as wecom_module
|
||||
from gateway.platforms.wecom import (
|
||||
APP_CMD_UPLOAD_MEDIA_CHUNK,
|
||||
APP_CMD_UPLOAD_MEDIA_FINISH,
|
||||
APP_CMD_UPLOAD_MEDIA_INIT,
|
||||
WeComAdapter,
|
||||
)
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
calls = []
|
||||
|
||||
async def fake_send_request(cmd, body, timeout=0):
|
||||
calls.append((cmd, body))
|
||||
if cmd == APP_CMD_UPLOAD_MEDIA_INIT:
|
||||
return {"errcode": 0, "body": {"upload_id": "upload-1"}}
|
||||
if cmd == APP_CMD_UPLOAD_MEDIA_CHUNK:
|
||||
return {"errcode": 0}
|
||||
if cmd == APP_CMD_UPLOAD_MEDIA_FINISH:
|
||||
return {
|
||||
"errcode": 0,
|
||||
"body": {
|
||||
"media_id": "media-1",
|
||||
"type": "file",
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
},
|
||||
}
|
||||
raise AssertionError(f"unexpected cmd {cmd}")
|
||||
|
||||
monkeypatch.setattr(wecom_module, "UPLOAD_CHUNK_SIZE", 4)
|
||||
adapter._send_request = fake_send_request
|
||||
|
||||
result = await adapter._upload_media_bytes(b"abcdefghij", "file", "demo.bin")
|
||||
|
||||
assert result["media_id"] == "media-1"
|
||||
assert [cmd for cmd, _body in calls] == [
|
||||
APP_CMD_UPLOAD_MEDIA_INIT,
|
||||
APP_CMD_UPLOAD_MEDIA_CHUNK,
|
||||
APP_CMD_UPLOAD_MEDIA_CHUNK,
|
||||
APP_CMD_UPLOAD_MEDIA_CHUNK,
|
||||
APP_CMD_UPLOAD_MEDIA_FINISH,
|
||||
]
|
||||
assert calls[1][1]["chunk_index"] == 0
|
||||
assert calls[2][1]["chunk_index"] == 1
|
||||
assert calls[3][1]["chunk_index"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_remote_bytes_rejects_large_content_length(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
class FakeResponse:
|
||||
headers = {"content-length": "10"}
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
async def aiter_bytes(self):
|
||||
yield b"abc"
|
||||
|
||||
class FakeClient:
|
||||
def stream(self, method, url, headers=None):
|
||||
return FakeResponse()
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._http_client = FakeClient()
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds WeCom limit"):
|
||||
await adapter._download_remote_bytes("https://example.com/file.bin", max_bytes=4)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_media_decrypts_url_payload_before_writing(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
plaintext = b"secret document bytes"
|
||||
key = os.urandom(32)
|
||||
pad_len = 32 - (len(plaintext) % 32)
|
||||
padded = plaintext + bytes([pad_len]) * pad_len
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
encryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).encryptor()
|
||||
encrypted = encryptor.update(padded) + encryptor.finalize()
|
||||
adapter._download_remote_bytes = AsyncMock(
|
||||
return_value=(
|
||||
encrypted,
|
||||
{
|
||||
"content-type": "application/octet-stream",
|
||||
"content-disposition": 'attachment; filename="secret.bin"',
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
cached = await adapter._cache_media(
|
||||
"file",
|
||||
{
|
||||
"url": "https://example.com/secret.bin",
|
||||
"aeskey": base64.b64encode(key).decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
assert cached is not None
|
||||
cached_path, content_type = cached
|
||||
assert Path(cached_path).read_bytes() == plaintext
|
||||
assert content_type == "application/octet-stream"
|
||||
|
||||
|
||||
class TestSend:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_proactive_payload(self):
|
||||
from gateway.platforms.wecom import APP_CMD_SEND, WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._send_request = AsyncMock(return_value={"headers": {"req_id": "req-1"}, "errcode": 0})
|
||||
|
||||
result = await adapter.send("chat-123", "Hello WeCom")
|
||||
|
||||
assert result.success is True
|
||||
adapter._send_request.assert_awaited_once_with(
|
||||
APP_CMD_SEND,
|
||||
{
|
||||
"chatid": "chat-123",
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"content": "Hello WeCom"},
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_reports_wecom_errors(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._send_request = AsyncMock(return_value={"errcode": 40001, "errmsg": "bad request"})
|
||||
|
||||
result = await adapter.send("chat-123", "Hello WeCom")
|
||||
|
||||
assert result.success is False
|
||||
assert "40001" in (result.error or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_falls_back_to_text_for_remote_url(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._send_media_source = AsyncMock(return_value=SendResult(success=False, error="upload failed"))
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="msg-1"))
|
||||
|
||||
result = await adapter.send_image("chat-123", "https://example.com/demo.png", caption="demo")
|
||||
|
||||
assert result.success is True
|
||||
adapter.send.assert_awaited_once_with(chat_id="chat-123", content="demo\nhttps://example.com/demo.png", reply_to=None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_sends_caption_and_downgrade_note(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._prepare_outbound_media = AsyncMock(
|
||||
return_value={
|
||||
"data": b"voice-bytes",
|
||||
"content_type": "audio/mpeg",
|
||||
"file_name": "voice.mp3",
|
||||
"detected_type": "voice",
|
||||
"final_type": "file",
|
||||
"rejected": False,
|
||||
"reject_reason": None,
|
||||
"downgraded": True,
|
||||
"downgrade_note": "语音格式 audio/mpeg 不支持,企微仅支持 AMR 格式,已转为文件格式发送",
|
||||
}
|
||||
)
|
||||
adapter._upload_media_bytes = AsyncMock(return_value={"media_id": "media-1", "type": "file"})
|
||||
adapter._send_media_message = AsyncMock(return_value={"headers": {"req_id": "req-media"}, "errcode": 0})
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="msg-1"))
|
||||
|
||||
result = await adapter.send_voice("chat-123", "/tmp/voice.mp3", caption="listen")
|
||||
|
||||
assert result.success is True
|
||||
adapter._send_media_message.assert_awaited_once_with("chat-123", "file", "media-1")
|
||||
assert adapter.send.await_count == 2
|
||||
adapter.send.assert_any_await(chat_id="chat-123", content="listen", reply_to=None)
|
||||
adapter.send.assert_any_await(
|
||||
chat_id="chat-123",
|
||||
content="ℹ️ 语音格式 audio/mpeg 不支持,企微仅支持 AMR 格式,已转为文件格式发送",
|
||||
reply_to=None,
|
||||
)
|
||||
|
||||
|
||||
class TestInboundMessages:
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_builds_event(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._extract_media = AsyncMock(return_value=(["/tmp/test.png"], ["image/png"]))
|
||||
|
||||
payload = {
|
||||
"cmd": "aibot_msg_callback",
|
||||
"headers": {"req_id": "req-1"},
|
||||
"body": {
|
||||
"msgid": "msg-1",
|
||||
"chatid": "group-1",
|
||||
"chattype": "group",
|
||||
"from": {"userid": "user-1"},
|
||||
"msgtype": "text",
|
||||
"text": {"content": "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
await adapter._on_message(payload)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.text == "hello"
|
||||
assert event.source.chat_id == "group-1"
|
||||
assert event.source.user_id == "user-1"
|
||||
assert event.media_urls == ["/tmp/test.png"]
|
||||
assert event.media_types == ["image/png"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_preserves_quote_context(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._extract_media = AsyncMock(return_value=([], []))
|
||||
|
||||
payload = {
|
||||
"cmd": "aibot_msg_callback",
|
||||
"headers": {"req_id": "req-1"},
|
||||
"body": {
|
||||
"msgid": "msg-1",
|
||||
"chatid": "group-1",
|
||||
"chattype": "group",
|
||||
"from": {"userid": "user-1"},
|
||||
"msgtype": "text",
|
||||
"text": {"content": "follow up"},
|
||||
"quote": {"msgtype": "text", "text": {"content": "quoted message"}},
|
||||
},
|
||||
}
|
||||
|
||||
await adapter._on_message(payload)
|
||||
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.reply_to_text == "quoted message"
|
||||
assert event.reply_to_message_id == "quote:msg-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_respects_group_policy(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(
|
||||
PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"group_policy": "allowlist", "group_allow_from": ["group-allowed"]},
|
||||
)
|
||||
)
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._extract_media = AsyncMock(return_value=([], []))
|
||||
|
||||
payload = {
|
||||
"cmd": "aibot_callback",
|
||||
"headers": {"req_id": "req-1"},
|
||||
"body": {
|
||||
"msgid": "msg-1",
|
||||
"chatid": "group-blocked",
|
||||
"chattype": "group",
|
||||
"from": {"userid": "user-1"},
|
||||
"msgtype": "text",
|
||||
"text": {"content": "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
await adapter._on_message(payload)
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
class TestPlatformEnum:
|
||||
def test_wecom_in_platform_enum(self):
|
||||
assert Platform.WECOM.value == "wecom"
|
||||
Reference in New Issue
Block a user