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:
Teknium 2026-03-29 21:29:13 -07:00 committed by GitHub
parent e296efbf24
commit ce2841f3c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2150 additions and 8 deletions

View file

@ -163,6 +163,7 @@ def _deliver_result(job: dict, content: str) -> None:
"homeassistant": Platform.HOMEASSISTANT,
"dingtalk": Platform.DINGTALK,
"feishu": Platform.FEISHU,
"wecom": Platform.WECOM,
"email": Platform.EMAIL,
"sms": Platform.SMS,
}

View file

@ -58,6 +58,7 @@ class Platform(Enum):
API_SERVER = "api_server"
WEBHOOK = "webhook"
FEISHU = "feishu"
WECOM = "wecom"
@dataclass
@ -278,6 +279,9 @@ class GatewayConfig:
# Feishu uses extra dict for app credentials
elif platform == Platform.FEISHU and config.extra.get("app_id"):
connected.append(platform)
# WeCom uses extra dict for bot credentials
elif platform == Platform.WECOM and config.extra.get("bot_id"):
connected.append(platform)
return connected
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
@ -841,6 +845,28 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
name=os.getenv("FEISHU_HOME_CHANNEL_NAME", "Home"),
)
# WeCom (Enterprise WeChat)
wecom_bot_id = os.getenv("WECOM_BOT_ID")
wecom_secret = os.getenv("WECOM_SECRET")
if wecom_bot_id and wecom_secret:
if Platform.WECOM not in config.platforms:
config.platforms[Platform.WECOM] = PlatformConfig()
config.platforms[Platform.WECOM].enabled = True
config.platforms[Platform.WECOM].extra.update({
"bot_id": wecom_bot_id,
"secret": wecom_secret,
})
wecom_ws_url = os.getenv("WECOM_WEBSOCKET_URL", "")
if wecom_ws_url:
config.platforms[Platform.WECOM].extra["websocket_url"] = wecom_ws_url
wecom_home = os.getenv("WECOM_HOME_CHANNEL")
if wecom_home:
config.platforms[Platform.WECOM].home_channel = HomeChannel(
platform=Platform.WECOM,
chat_id=wecom_home,
name=os.getenv("WECOM_HOME_CHANNEL_NAME", "Home"),
)
# Session settings
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
if idle_minutes:

1338
gateway/platforms/wecom.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1027,6 +1027,7 @@ class GatewayRunner:
"SMS_ALLOWED_USERS", "MATTERMOST_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(
@ -1036,7 +1037,8 @@ class GatewayRunner:
"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")
"FEISHU_ALLOW_ALL_USERS",
"WECOM_ALLOW_ALL_USERS")
)
if not _any_allowlist and not _allow_all:
logger.warning(
@ -1486,6 +1488,13 @@ class GatewayRunner:
return None
return FeishuAdapter(config)
elif platform == Platform.WECOM:
from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
if not check_wecom_requirements():
logger.warning("WeCom: aiohttp not installed or WECOM_BOT_ID/SECRET not set")
return None
return WeComAdapter(config)
elif platform == Platform.MATTERMOST:
from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements
if not check_mattermost_requirements():
@ -1553,6 +1562,7 @@ class GatewayRunner:
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
Platform.WECOM: "WECOM_ALLOWED_USERS",
}
platform_allow_all_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
@ -1566,6 +1576,7 @@ class GatewayRunner:
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS",
Platform.WECOM: "WECOM_ALLOW_ALL_USERS",
}
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)

View file

@ -35,6 +35,7 @@ _EXTRA_ENV_KEYS = frozenset({
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
"WECOM_BOT_ID", "WECOM_SECRET",
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",

View file

@ -1351,6 +1351,30 @@ _PLATFORMS = [
"help": "Chat ID for scheduled results and notifications."},
],
},
{
"key": "wecom",
"label": "WeCom (Enterprise WeChat)",
"emoji": "💬",
"token_var": "WECOM_BOT_ID",
"setup_instructions": [
"1. Go to WeCom Admin Console → Applications → Create AI Bot",
"2. Copy the Bot ID and Secret from the bot's credentials page",
"3. The bot connects via WebSocket — no public endpoint needed",
"4. Add the bot to a group chat or message it directly in WeCom",
"5. Restrict access with WECOM_ALLOWED_USERS for production use",
],
"vars": [
{"name": "WECOM_BOT_ID", "prompt": "Bot ID", "password": False,
"help": "The Bot ID from your WeCom AI Bot."},
{"name": "WECOM_SECRET", "prompt": "Secret", "password": True,
"help": "The secret from your WeCom AI Bot."},
{"name": "WECOM_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False,
"is_allowlist": True,
"help": "Restrict which WeCom users can interact with the bot."},
{"name": "WECOM_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False,
"help": "Chat ID for scheduled results and notifications."},
],
},
]

View file

@ -29,6 +29,7 @@ PLATFORMS = {
"matrix": "💬 Matrix",
"dingtalk": "💬 DingTalk",
"feishu": "🪽 Feishu",
"wecom": "💬 WeCom",
}
# ─── Config Helpers ───────────────────────────────────────────────────────────

View file

@ -254,6 +254,9 @@ def show_status(args):
"Slack": ("SLACK_BOT_TOKEN", None),
"Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"),
"SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"),
"DingTalk": ("DINGTALK_CLIENT_ID", None),
"Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"),
"WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"),
}
for name, (token_var, home_var) in platforms.items():

View file

@ -142,6 +142,7 @@ PLATFORMS = {
"matrix": {"label": "💬 Matrix", "default_toolset": "hermes-matrix"},
"dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
"feishu": {"label": "🪽 Feishu", "default_toolset": "hermes-feishu"},
"wecom": {"label": "💬 WeCom", "default_toolset": "hermes-wecom"},
"api_server": {"label": "🌐 API Server", "default_toolset": "hermes-api-server"},
"mattermost": {"label": "💬 Mattermost", "default_toolset": "hermes-mattermost"},
}

View file

@ -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

View file

@ -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
View 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"

View file

@ -372,7 +372,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
},
"deliver": {
"type": "string",
"description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, feishu, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'"
"description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'"
},
"model": {
"type": "string",

View file

@ -130,6 +130,7 @@ def _handle_send(args):
"homeassistant": Platform.HOMEASSISTANT,
"dingtalk": Platform.DINGTALK,
"feishu": Platform.FEISHU,
"wecom": Platform.WECOM,
"email": Platform.EMAIL,
"sms": Platform.SMS,
}
@ -368,6 +369,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
result = await _send_dingtalk(pconfig.extra, chat_id, chunk)
elif platform == Platform.FEISHU:
result = await _send_feishu(pconfig, chat_id, chunk, thread_id=thread_id)
elif platform == Platform.WECOM:
result = await _send_wecom(pconfig.extra, chat_id, chunk)
else:
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
@ -794,6 +797,33 @@ async def _send_dingtalk(extra, chat_id, message):
return {"error": f"DingTalk send failed: {e}"}
async def _send_wecom(extra, chat_id, message):
"""Send via WeCom using the adapter's WebSocket send pipeline."""
try:
from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
if not check_wecom_requirements():
return {"error": "WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET."}
except ImportError:
return {"error": "WeCom adapter not available."}
try:
from gateway.config import PlatformConfig
pconfig = PlatformConfig(extra=extra)
adapter = WeComAdapter(pconfig)
connected = await adapter.connect()
if not connected:
return {"error": f"WeCom: failed to connect — {adapter.fatal_error_message or 'unknown error'}"}
try:
result = await adapter.send(chat_id, message)
if not result.success:
return {"error": f"WeCom send failed: {result.error}"}
return {"success": True, "platform": "wecom", "chat_id": chat_id, "message_id": result.message_id}
finally:
await adapter.disconnect()
except Exception as e:
return {"error": f"WeCom send failed: {e}"}
async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None):
"""Send via Feishu/Lark using the adapter's send pipeline."""
try:

View file

@ -357,6 +357,12 @@ TOOLSETS = {
"includes": []
},
"hermes-wecom": {
"description": "WeCom bot toolset - enterprise WeChat messaging (full access)",
"tools": _HERMES_CORE_TOOLS,
"includes": []
},
"hermes-sms": {
"description": "SMS bot toolset - interact with Hermes via SMS (Twilio)",
"tools": _HERMES_CORE_TOOLS,
@ -366,7 +372,7 @@ TOOLSETS = {
"hermes-gateway": {
"description": "Gateway toolset - union of all messaging platform tools",
"tools": [],
"includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu"]
"includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom"]
}
}

View file

@ -196,6 +196,19 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `DINGTALK_CLIENT_ID` | DingTalk bot AppKey from developer portal ([open.dingtalk.com](https://open.dingtalk.com)) |
| `DINGTALK_CLIENT_SECRET` | DingTalk bot AppSecret from developer portal |
| `DINGTALK_ALLOWED_USERS` | Comma-separated DingTalk user IDs allowed to message the bot |
| `FEISHU_APP_ID` | Feishu/Lark bot App ID from [open.feishu.cn](https://open.feishu.cn/) |
| `FEISHU_APP_SECRET` | Feishu/Lark bot App Secret |
| `FEISHU_DOMAIN` | `feishu` (China) or `lark` (international). Default: `feishu` |
| `FEISHU_CONNECTION_MODE` | `websocket` (recommended) or `webhook`. Default: `websocket` |
| `FEISHU_ENCRYPT_KEY` | Optional encryption key for webhook mode |
| `FEISHU_VERIFICATION_TOKEN` | Optional verification token for webhook mode |
| `FEISHU_ALLOWED_USERS` | Comma-separated Feishu user IDs allowed to message the bot |
| `FEISHU_HOME_CHANNEL` | Feishu chat ID for cron delivery and notifications |
| `WECOM_BOT_ID` | WeCom AI Bot ID from admin console |
| `WECOM_SECRET` | WeCom AI Bot secret |
| `WECOM_WEBSOCKET_URL` | Custom WebSocket URL (default: `wss://openws.work.weixin.qq.com`) |
| `WECOM_ALLOWED_USERS` | Comma-separated WeCom user IDs allowed to message the bot |
| `WECOM_HOME_CHANNEL` | WeCom chat ID for cron delivery and notifications |
| `MATTERMOST_URL` | Mattermost server URL (e.g. `https://mm.example.com`) |
| `MATTERMOST_TOKEN` | Bot token or personal access token for Mattermost |
| `MATTERMOST_ALLOWED_USERS` | Comma-separated Mattermost user IDs allowed to message the bot |

View file

@ -22,6 +22,7 @@ Toolsets are named bundles of tools that you can enable with `hermes chat --tool
| `hermes-api-server` | platform | _(same as hermes-cli)_ |
| `hermes-dingtalk` | platform | _(same as hermes-cli)_ |
| `hermes-feishu` | platform | _(same as hermes-cli)_ |
| `hermes-wecom` | platform | _(same as hermes-cli)_ |
| `hermes-discord` | platform | _(same as hermes-cli)_ |
| `hermes-email` | platform | _(same as hermes-cli)_ |
| `hermes-gateway` | composite | Union of all messaging platform toolsets |

View file

@ -6,7 +6,7 @@ description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal,
# Messaging Gateway
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes).
@ -28,6 +28,7 @@ flowchart TB
mx[Matrix]
dt[DingTalk]
fs[Feishu/Lark]
wc[WeCom]
api["API Server<br/>(OpenAI-compatible)"]
wh[Webhooks]
end
@ -330,6 +331,7 @@ Each platform has its own toolset:
| Matrix | `hermes-matrix` | Full tools including terminal |
| DingTalk | `hermes-dingtalk` | Full tools including terminal |
| Feishu/Lark | `hermes-feishu` | Full tools including terminal |
| WeCom | `hermes-wecom` | Full tools including terminal |
| API Server | `hermes` (default) | Full tools including terminal |
| Webhooks | `hermes-webhook` | Full tools including terminal |
@ -347,5 +349,6 @@ Each platform has its own toolset:
- [Matrix Setup](matrix.md)
- [DingTalk Setup](dingtalk.md)
- [Feishu/Lark Setup](feishu.md)
- [WeCom Setup](wecom.md)
- [Open WebUI + API Server](open-webui.md)
- [Webhooks](webhooks.md)

View file

@ -0,0 +1,86 @@
---
sidebar_position: 14
title: "WeCom (Enterprise WeChat)"
description: "Connect Hermes Agent to WeCom via the AI Bot WebSocket gateway"
---
# WeCom (Enterprise WeChat)
Connect Hermes to [WeCom](https://work.weixin.qq.com/) (企业微信), Tencent's enterprise messaging platform. The adapter uses WeCom's AI Bot WebSocket gateway for real-time bidirectional communication — no public endpoint or webhook needed.
## Prerequisites
- A WeCom organization account
- An AI Bot created in the WeCom Admin Console
- The Bot ID and Secret from the bot's credentials page
## Setup
### 1. Create an AI Bot
1. Log in to the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame)
2. Navigate to **Applications****Create Application** → **AI Bot**
3. Configure the bot name and description
4. Copy the **Bot ID** and **Secret** from the credentials page
### 2. Configure Hermes
Run the interactive setup:
```bash
hermes gateway setup
```
Select **WeCom** and enter your Bot ID and Secret.
Or set environment variables in `~/.hermes/.env`:
```bash
WECOM_BOT_ID=your-bot-id
WECOM_SECRET=your-secret
# Optional: restrict access
WECOM_ALLOWED_USERS=user_id_1,user_id_2
# Optional: home channel for cron/notifications
WECOM_HOME_CHANNEL=chat_id
```
### 3. Start the gateway
```bash
hermes gateway start
```
## Features
- **WebSocket transport** — persistent connection, no public endpoint needed
- **DM and group messaging** — configurable access policies
- **Media support** — images, files, voice, video upload and download
- **AES-encrypted media** — automatic decryption for inbound attachments
- **Quote context** — preserves reply threading
- **Markdown rendering** — rich text responses
- **Auto-reconnect** — exponential backoff on connection drops
## Configuration Options
Set these in `config.yaml` under `platforms.wecom.extra`:
| Key | Default | Description |
|-----|---------|-------------|
| `bot_id` | — | WeCom AI Bot ID (required) |
| `secret` | — | WeCom AI Bot Secret (required) |
| `websocket_url` | `wss://openws.work.weixin.qq.com` | WebSocket gateway URL |
| `dm_policy` | `open` | DM access: `open`, `allowlist`, `disabled`, `pairing` |
| `group_policy` | `open` | Group access: `open`, `allowlist`, `disabled` |
| `allow_from` | `[]` | User IDs allowed for DMs (when dm_policy=allowlist) |
| `group_allow_from` | `[]` | Group IDs allowed (when group_policy=allowlist) |
## Troubleshooting
| Problem | Fix |
|---------|-----|
| "WECOM_BOT_ID and WECOM_SECRET are required" | Set both env vars or configure in setup wizard |
| "invalid secret (errcode=40013)" | Verify the secret matches your bot's credentials |
| "Timed out waiting for subscribe acknowledgement" | Check network connectivity to `openws.work.weixin.qq.com` |
| Bot doesn't respond in groups | Check `group_policy` setting and group allowlist |

View file

@ -55,6 +55,7 @@ const sidebars: SidebarsConfig = {
'user-guide/messaging/matrix',
'user-guide/messaging/dingtalk',
'user-guide/messaging/feishu',
'user-guide/messaging/wecom',
'user-guide/messaging/open-webui',
'user-guide/messaging/webhooks',
],