Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions plugins/platforms/wecom/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1725,6 +1725,61 @@ async def _standalone_send(
return {"error": f"WeCom send failed: {e}"}


async def _callback_standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Out-of-process WeCom Callback delivery via the proactive send API.

Implements the standalone_sender_fn contract so ``deliver=wecom_callback``
cron jobs and ``send_message(platform="wecom_callback")`` succeed when
they run separately from the gateway. Without this the registry fallback
in tools/send_message_tool.py has nothing to call and the send fails.

Deliberately does NOT call ``connect()``. Callback delivery is outbound
only — the aiohttp app, the bound port and the poll loop exist purely to
*receive* callbacks. ``connect()`` would try to bind the callback port and
refuse outright when the gateway already holds it, so an ephemeral
connect/disconnect (the pattern the WebSocket-based ``wecom`` sender uses)
is wrong here. Only the outbound HTTP client is opened, and it is closed
again afterwards.
"""
del thread_id, media_files, force_document # text-only proactive send
from plugins.platforms.wecom.callback_adapter import (
check_wecom_callback_requirements,
)

if not check_wecom_callback_requirements():
return {
"error": (
"WeCom Callback requirements not met. Need aiohttp + httpx and "
"WECOM_CALLBACK_CORP_ID/WECOM_CALLBACK_CORP_SECRET."
)
}
try:
adapter = _build_callback_adapter(pconfig)
adapter._ensure_http_client()
try:
result = await adapter.send(chat_id, message)
if not result.success:
return {"error": f"WeCom Callback send failed: {result.error}"}
return {
"success": True,
"platform": "wecom_callback",
"chat_id": chat_id,
"message_id": result.message_id,
}
finally:
await adapter.aclose_http_client()
except Exception as e:
return {"error": f"WeCom Callback send failed: {e}"}


def interactive_setup() -> None:
"""Interactive setup for WeCom — QR scan or manual credential input.

Expand Down Expand Up @@ -1899,6 +1954,7 @@ def register(ctx) -> None:
install_hint="Run `hermes setup` to install WeCom support.",
allowed_users_env="WECOM_CALLBACK_ALLOWED_USERS",
allow_all_env="WECOM_CALLBACK_ALLOW_ALL_USERS",
standalone_sender_fn=_callback_standalone_send,
emoji="💼",
allow_update_command=True,
)
28 changes: 25 additions & 3 deletions plugins/platforms/wecom/callback_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,30 @@ def _normalize_apps(extra: Dict[str, Any]) -> List[Dict[str, Any]]:
# Lifecycle
# ------------------------------------------------------------------

def _ensure_http_client(self) -> None:
"""Create the outbound HTTP client, without starting the server.

``send()`` only needs this client — the aiohttp app, the bound port
and the poll loop exist purely to *receive* callbacks. Separating the
two lets an out-of-process sender (cron, ``send_message``) reuse the
real send path without binding the callback port, which ``connect()``
would refuse anyway when the gateway already holds it.
"""
if self._http_client is not None:
return
# Tighter keepalive so idle CLOSE_WAIT drains promptly (#18451).
from gateway.platforms._http_client_limits import platform_httpx_limits

self._http_client = httpx.AsyncClient(
timeout=20.0, limits=platform_httpx_limits()
)

async def aclose_http_client(self) -> None:
"""Close the outbound client opened by :meth:`_ensure_http_client`."""
if self._http_client is not None:
await self._http_client.aclose()
self._http_client = None

async def connect(self, *, is_reconnect: bool = False) -> bool:
# ``is_reconnect`` is forwarded by GatewayRunner on every retry per
# the BasePlatformAdapter.connect contract. Callback adapters have
Expand All @@ -147,9 +171,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
pass

try:
# Tighter keepalive so idle CLOSE_WAIT drains promptly (#18451).
from gateway.platforms._http_client_limits import platform_httpx_limits
self._http_client = httpx.AsyncClient(timeout=20.0, limits=platform_httpx_limits())
self._ensure_http_client()
# client_max_size rejects oversized bodies at the aiohttp layer
# (413) before our handler — and before any signature work — runs.
self._app = web.Application(client_max_size=_MAX_BODY)
Expand Down
168 changes: 168 additions & 0 deletions tests/gateway/test_wecom_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,171 @@ async def test_oversized_body_rejected_with_413(self):
assert response.status == 413




class TestStandaloneSend:
"""Out-of-process delivery for ``wecom_callback``.

``send_message(platform="wecom_callback")`` and ``deliver=wecom_callback``
cron jobs route through the registry's ``standalone_sender_fn``. The
callback platform registered none, so those sends had nothing to call.

The sender must NOT go through ``connect()``: callback delivery is
outbound-only, while ``connect()`` binds the callback port — and refuses
outright when the gateway already holds it, which is precisely when an
out-of-process send happens.
"""

def test_registered_on_the_callback_platform(self):
import plugins.platforms.wecom.adapter as wecom_adapter

registered = {}

class _Ctx:
def register_platform(self, **kwargs):
registered[kwargs["name"]] = kwargs

wecom_adapter.register(_Ctx())

assert registered["wecom"].get("standalone_sender_fn") is not None
assert registered["wecom_callback"].get("standalone_sender_fn") is not None, (
"deliver=wecom_callback has no sender to call"
)

def test_send_succeeds_without_binding_the_callback_port(self, monkeypatch):
import plugins.platforms.wecom.adapter as wecom_adapter
from plugins.platforms.wecom.callback_adapter import WecomCallbackAdapter

sent = {}

class _Result:
success = True
message_id = "msg-1"
error = None

async def _fake_send(self, chat_id, content, **_kw):
sent["chat_id"] = chat_id
sent["content"] = content
return _Result()

def _explode(self, *a, **k):
raise AssertionError(
"connect() must not run — it binds the callback port"
)

monkeypatch.setattr(WecomCallbackAdapter, "send", _fake_send)
monkeypatch.setattr(WecomCallbackAdapter, "connect", _explode)
monkeypatch.setattr(
wecom_adapter, "_build_callback_adapter",
lambda cfg: WecomCallbackAdapter(cfg),
)
monkeypatch.setattr(
"plugins.platforms.wecom.callback_adapter.check_wecom_callback_requirements",
lambda: True,
)

result = asyncio.run(
wecom_adapter._callback_standalone_send(_config(), "app:user1", "hello")
)

assert result == {
"success": True,
"platform": "wecom_callback",
"chat_id": "app:user1",
"message_id": "msg-1",
}
assert sent == {"chat_id": "app:user1", "content": "hello"}

def test_http_client_is_opened_and_closed(self, monkeypatch):
"""The outbound client is the only resource the sender may hold."""
import plugins.platforms.wecom.adapter as wecom_adapter
from plugins.platforms.wecom.callback_adapter import WecomCallbackAdapter

seen = {}

class _Result:
success = True
message_id = "m"
error = None

async def _fake_send(self, chat_id, content, **_kw):
seen["client_during_send"] = self._http_client is not None
return _Result()

adapter_holder = {}

def _build(cfg):
adapter = WecomCallbackAdapter(cfg)
adapter_holder["a"] = adapter
return adapter

monkeypatch.setattr(WecomCallbackAdapter, "send", _fake_send)
monkeypatch.setattr(wecom_adapter, "_build_callback_adapter", _build)
monkeypatch.setattr(
"plugins.platforms.wecom.callback_adapter.check_wecom_callback_requirements",
lambda: True,
)

asyncio.run(
wecom_adapter._callback_standalone_send(_config(), "app:user1", "hi")
)

assert seen["client_during_send"] is True
assert adapter_holder["a"]._http_client is None, "client was left open"

def test_send_failure_is_reported(self, monkeypatch):
import plugins.platforms.wecom.adapter as wecom_adapter
from plugins.platforms.wecom.callback_adapter import WecomCallbackAdapter

class _Result:
success = False
message_id = None
error = "errcode 40013"

async def _fake_send(self, chat_id, content, **_kw):
return _Result()

monkeypatch.setattr(WecomCallbackAdapter, "send", _fake_send)
monkeypatch.setattr(
wecom_adapter, "_build_callback_adapter",
lambda cfg: WecomCallbackAdapter(cfg),
)
monkeypatch.setattr(
"plugins.platforms.wecom.callback_adapter.check_wecom_callback_requirements",
lambda: True,
)

result = asyncio.run(
wecom_adapter._callback_standalone_send(_config(), "app:user1", "hi")
)

assert "errcode 40013" in result["error"]
assert "success" not in result

def test_missing_requirements_reported_not_raised(self, monkeypatch):
import plugins.platforms.wecom.adapter as wecom_adapter

monkeypatch.setattr(
"plugins.platforms.wecom.callback_adapter.check_wecom_callback_requirements",
lambda: False,
)

result = asyncio.run(
wecom_adapter._callback_standalone_send(_config(), "app:user1", "hi")
)

assert "requirements not met" in result["error"].lower()


class TestEnsureHttpClientSeam:
def test_idempotent_and_closes(self):
adapter = WecomCallbackAdapter(_config())
adapter._ensure_http_client()
first = adapter._http_client
adapter._ensure_http_client()

assert adapter._http_client is first, "must not replace a live client"

asyncio.run(adapter.aclose_http_client())
assert adapter._http_client is None
asyncio.run(adapter.aclose_http_client()) # second close is harmless
Loading