mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
feat(gateway): add WeCom callback-mode adapter for self-built apps
Add a second WeCom integration mode for regular enterprise self-built applications. Unlike the existing bot/websocket adapter (wecom.py), this handles WeCom's standard callback flow: WeCom POSTs encrypted XML to an HTTP endpoint, the adapter decrypts, queues for the agent, and immediately acknowledges. The agent's reply is delivered proactively via the message/send API. Key design choice: always acknowledge immediately and use proactive send — agent sessions take 3-30 minutes, so the 5-second inline reply window is never useful. The original PR's Future/pending-reply machinery was removed in favour of this simpler architecture. Features: - AES-CBC encrypt/decrypt (BizMsgCrypt-compatible) - Multi-app routing scoped by corp_id:user_id - Legacy bare user_id fallback for backward compat - Access-token management with auto-refresh - WECOM_CALLBACK_* env var overrides - Port-in-use pre-check before binding - Health endpoint at /health Salvaged from PR #7774 by @chqchshj. Simplified by removing the inline reply Future system and fixing: secrets.choice for nonce generation, immediate plain-text acknowledgment (not encrypted XML containing 'success'), and initial token refresh error handling.
This commit is contained in:
parent
90352b2adf
commit
5f0caf54d6
13 changed files with 800 additions and 5 deletions
185
tests/gateway/test_wecom_callback.py
Normal file
185
tests/gateway/test_wecom_callback.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""Tests for the WeCom callback-mode adapter."""
|
||||
|
||||
import asyncio
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.wecom_callback import WecomCallbackAdapter
|
||||
from gateway.platforms.wecom_crypto import WXBizMsgCrypt
|
||||
|
||||
|
||||
def _app(name="test-app", corp_id="ww1234567890", agent_id="1000002"):
|
||||
return {
|
||||
"name": name,
|
||||
"corp_id": corp_id,
|
||||
"corp_secret": "test-secret",
|
||||
"agent_id": agent_id,
|
||||
"token": "test-callback-token",
|
||||
"encoding_aes_key": "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
|
||||
}
|
||||
|
||||
|
||||
def _config(apps=None):
|
||||
return PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"mode": "callback", "host": "127.0.0.1", "port": 0, "apps": apps or [_app()]},
|
||||
)
|
||||
|
||||
|
||||
class TestWecomCrypto:
|
||||
def test_roundtrip_encrypt_decrypt(self):
|
||||
app = _app()
|
||||
crypt = WXBizMsgCrypt(app["token"], app["encoding_aes_key"], app["corp_id"])
|
||||
encrypted_xml = crypt.encrypt(
|
||||
"<xml><Content>hello</Content></xml>", nonce="nonce123", timestamp="123456",
|
||||
)
|
||||
root = ET.fromstring(encrypted_xml)
|
||||
decrypted = crypt.decrypt(
|
||||
root.findtext("MsgSignature", default=""),
|
||||
root.findtext("TimeStamp", default=""),
|
||||
root.findtext("Nonce", default=""),
|
||||
root.findtext("Encrypt", default=""),
|
||||
)
|
||||
assert b"<Content>hello</Content>" in decrypted
|
||||
|
||||
def test_signature_mismatch_raises(self):
|
||||
app = _app()
|
||||
crypt = WXBizMsgCrypt(app["token"], app["encoding_aes_key"], app["corp_id"])
|
||||
encrypted_xml = crypt.encrypt("<xml/>", nonce="n", timestamp="1")
|
||||
root = ET.fromstring(encrypted_xml)
|
||||
from gateway.platforms.wecom_crypto import SignatureError
|
||||
with pytest.raises(SignatureError):
|
||||
crypt.decrypt("bad-sig", "1", "n", root.findtext("Encrypt", default=""))
|
||||
|
||||
|
||||
class TestWecomCallbackEventConstruction:
|
||||
def test_build_event_extracts_text_message(self):
|
||||
adapter = WecomCallbackAdapter(_config())
|
||||
xml_text = """
|
||||
<xml>
|
||||
<ToUserName>ww1234567890</ToUserName>
|
||||
<FromUserName>zhangsan</FromUserName>
|
||||
<CreateTime>1710000000</CreateTime>
|
||||
<MsgType>text</MsgType>
|
||||
<Content>\u4f60\u597d</Content>
|
||||
<MsgId>123456789</MsgId>
|
||||
</xml>
|
||||
"""
|
||||
event = adapter._build_event(_app(), xml_text)
|
||||
assert event is not None
|
||||
assert event.source is not None
|
||||
assert event.source.user_id == "zhangsan"
|
||||
assert event.source.chat_id == "ww1234567890:zhangsan"
|
||||
assert event.message_id == "123456789"
|
||||
assert event.text == "\u4f60\u597d"
|
||||
|
||||
def test_build_event_returns_none_for_subscribe(self):
|
||||
adapter = WecomCallbackAdapter(_config())
|
||||
xml_text = """
|
||||
<xml>
|
||||
<ToUserName>ww1234567890</ToUserName>
|
||||
<FromUserName>zhangsan</FromUserName>
|
||||
<CreateTime>1710000000</CreateTime>
|
||||
<MsgType>event</MsgType>
|
||||
<Event>subscribe</Event>
|
||||
</xml>
|
||||
"""
|
||||
event = adapter._build_event(_app(), xml_text)
|
||||
assert event is None
|
||||
|
||||
|
||||
class TestWecomCallbackRouting:
|
||||
def test_user_app_key_scopes_across_corps(self):
|
||||
adapter = WecomCallbackAdapter(_config())
|
||||
assert adapter._user_app_key("corpA", "alice") == "corpA:alice"
|
||||
assert adapter._user_app_key("corpB", "alice") == "corpB:alice"
|
||||
assert adapter._user_app_key("corpA", "alice") != adapter._user_app_key("corpB", "alice")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_selects_correct_app_for_scoped_chat_id(self):
|
||||
apps = [
|
||||
_app(name="corp-a", corp_id="corpA", agent_id="1001"),
|
||||
_app(name="corp-b", corp_id="corpB", agent_id="2002"),
|
||||
]
|
||||
adapter = WecomCallbackAdapter(_config(apps=apps))
|
||||
adapter._user_app_map["corpB:alice"] = "corp-b"
|
||||
adapter._access_tokens["corp-b"] = {"token": "tok-b", "expires_at": 9999999999}
|
||||
|
||||
calls = {}
|
||||
|
||||
class FakeResponse:
|
||||
def json(self):
|
||||
return {"errcode": 0, "msgid": "ok1"}
|
||||
|
||||
class FakeClient:
|
||||
async def post(self, url, json):
|
||||
calls["url"] = url
|
||||
calls["json"] = json
|
||||
return FakeResponse()
|
||||
|
||||
adapter._http_client = FakeClient()
|
||||
result = await adapter.send("corpB:alice", "hello")
|
||||
|
||||
assert result.success is True
|
||||
assert calls["json"]["touser"] == "alice"
|
||||
assert calls["json"]["agentid"] == 2002
|
||||
assert "tok-b" in calls["url"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_falls_back_from_bare_user_id_when_unique(self):
|
||||
apps = [_app(name="corp-a", corp_id="corpA", agent_id="1001")]
|
||||
adapter = WecomCallbackAdapter(_config(apps=apps))
|
||||
adapter._user_app_map["corpA:alice"] = "corp-a"
|
||||
adapter._access_tokens["corp-a"] = {"token": "tok-a", "expires_at": 9999999999}
|
||||
|
||||
calls = {}
|
||||
|
||||
class FakeResponse:
|
||||
def json(self):
|
||||
return {"errcode": 0, "msgid": "ok2"}
|
||||
|
||||
class FakeClient:
|
||||
async def post(self, url, json):
|
||||
calls["url"] = url
|
||||
calls["json"] = json
|
||||
return FakeResponse()
|
||||
|
||||
adapter._http_client = FakeClient()
|
||||
result = await adapter.send("alice", "hello")
|
||||
|
||||
assert result.success is True
|
||||
assert calls["json"]["agentid"] == 1001
|
||||
|
||||
|
||||
class TestWecomCallbackPollLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_loop_dispatches_handle_message(self, monkeypatch):
|
||||
adapter = WecomCallbackAdapter(_config())
|
||||
calls = []
|
||||
|
||||
async def fake_handle_message(event):
|
||||
calls.append(event.text)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
|
||||
event = adapter._build_event(
|
||||
_app(),
|
||||
"""
|
||||
<xml>
|
||||
<ToUserName>ww1234567890</ToUserName>
|
||||
<FromUserName>lisi</FromUserName>
|
||||
<CreateTime>1710000000</CreateTime>
|
||||
<MsgType>text</MsgType>
|
||||
<Content>test</Content>
|
||||
<MsgId>m2</MsgId>
|
||||
</xml>
|
||||
""",
|
||||
)
|
||||
task = asyncio.create_task(adapter._poll_loop())
|
||||
await adapter._message_queue.put(event)
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert calls == ["test"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue