mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
e296efbf24
commit
ce2841f3c9
20 changed files with 2150 additions and 8 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1338
gateway/platforms/wecom.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ PLATFORMS = {
|
|||
"matrix": "💬 Matrix",
|
||||
"dingtalk": "💬 DingTalk",
|
||||
"feishu": "🪽 Feishu",
|
||||
"wecom": "💬 WeCom",
|
||||
}
|
||||
|
||||
# ─── Config Helpers ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
86
website/docs/user-guide/messaging/wecom.md
Normal file
86
website/docs/user-guide/messaging/wecom.md
Normal 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 |
|
||||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue