feat(plugins): add standalone_sender_fn for out-of-process cron delivery

Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.

This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.

Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.

Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
  block CRLF command injection; bounded nick-collision retries; JOIN
  before PRIVMSG so channels with the default `+n` mode accept the
  delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
  Bot Framework hosts (`smba.trafficmanager.net`,
  `smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
  tenant_id constrained to the documented Bot Framework character set;
  per-request timeouts so a slow STS endpoint cannot starve the
  activity POST.
* Google Chat: chat_id and thread_id validated against strict
  resource-name regexes; service-account refresh wrapped in
  `asyncio.wait_for` so a hung token endpoint cannot stall the
  scheduler.

Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.

Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
This commit is contained in:
GodsBoy 2026-05-08 12:23:26 +02:00 committed by kshitij
parent 3801825efd
commit 93e25ceb13
11 changed files with 1456 additions and 24 deletions

View file

@ -703,3 +703,177 @@ class TestTeamsMessageHandling:
await adapter._on_message(ctx)
assert adapter.handle_message.await_count == 1
# ── _standalone_send (out-of-process cron delivery) ──────────────────────
class _FakeAiohttpResponse:
def __init__(self, status: int, payload, text_body: str = ""):
self.status = status
self._payload = payload
self._text = text_body or (str(payload) if payload is not None else "")
async def json(self):
return self._payload
async def text(self):
return self._text
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
class _FakeAiohttpSession:
"""Scripted aiohttp.ClientSession with a queue of responses so tests
can assert calls in order."""
def __init__(self, scripts):
self._scripts = list(scripts)
self.calls: list[tuple[str, dict]] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
def post(self, url, **kwargs):
self.calls.append((url, kwargs))
if not self._scripts:
raise AssertionError(f"No scripted response for POST {url}")
return self._scripts.pop(0)
def _install_fake_aiohttp(monkeypatch, session):
"""Replace ``aiohttp`` in ``sys.modules`` so ``import aiohttp as _aiohttp``
inside ``_standalone_send`` picks up our fake."""
fake_aiohttp = types.SimpleNamespace(
ClientSession=lambda timeout=None: session,
ClientTimeout=lambda total=None: None,
)
monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp)
class TestTeamsStandaloneSend:
@pytest.mark.asyncio
async def test_standalone_send_acquires_token_and_posts_activity(self, monkeypatch):
monkeypatch.setenv("TEAMS_CLIENT_ID", "client-id")
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "secret")
monkeypatch.setenv("TEAMS_TENANT_ID", "tenant")
monkeypatch.delenv("TEAMS_SERVICE_URL", raising=False)
token_resp = _FakeAiohttpResponse(200, {"access_token": "the-token"})
activity_resp = _FakeAiohttpResponse(200, {"id": "msg-99"})
session = _FakeAiohttpSession([token_resp, activity_resp])
_install_fake_aiohttp(monkeypatch, session)
result = await _teams_mod._standalone_send(
PlatformConfig(enabled=True, extra={}),
"19:abc@thread.skype",
"hello cron",
)
assert result == {"success": True, "message_id": "msg-99"}
assert len(session.calls) == 2
token_url, token_kwargs = session.calls[0]
assert "login.microsoftonline.com/tenant/oauth2/v2.0/token" in token_url
assert token_kwargs["data"]["client_id"] == "client-id"
assert token_kwargs["data"]["client_secret"] == "secret"
assert token_kwargs["data"]["scope"] == "https://api.botframework.com/.default"
activity_url, activity_kwargs = session.calls[1]
# Default service URL when TEAMS_SERVICE_URL is unset
assert "smba.trafficmanager.net" in activity_url
assert "/v3/conversations/19:abc@thread.skype/activities" in activity_url
assert activity_kwargs["headers"]["Authorization"] == "Bearer the-token"
assert activity_kwargs["json"]["text"] == "hello cron"
assert activity_kwargs["json"]["type"] == "message"
@pytest.mark.asyncio
async def test_standalone_send_returns_error_when_unconfigured(self, monkeypatch):
for var in ("TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"):
monkeypatch.delenv(var, raising=False)
result = await _teams_mod._standalone_send(
PlatformConfig(enabled=True, extra={}),
"19:abc@thread.skype",
"hi",
)
assert "error" in result
assert "TEAMS_CLIENT_ID" in result["error"]
@pytest.mark.asyncio
async def test_standalone_send_propagates_token_failure(self, monkeypatch):
monkeypatch.setenv("TEAMS_CLIENT_ID", "client-id")
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "secret")
monkeypatch.setenv("TEAMS_TENANT_ID", "tenant")
token_resp = _FakeAiohttpResponse(
401,
{"error": "unauthorized_client"},
text_body='{"error":"unauthorized_client"}',
)
session = _FakeAiohttpSession([token_resp])
_install_fake_aiohttp(monkeypatch, session)
result = await _teams_mod._standalone_send(
PlatformConfig(enabled=True, extra={}),
"19:abc@thread.skype",
"hi",
)
assert "error" in result
assert "401" in result["error"]
assert "token" in result["error"].lower()
@pytest.mark.asyncio
async def test_standalone_send_rejects_off_allowlist_service_url(self, monkeypatch):
monkeypatch.setenv("TEAMS_CLIENT_ID", "client-id")
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "secret")
monkeypatch.setenv("TEAMS_TENANT_ID", "tenant")
# SSRF attempt: point us at an attacker-controlled host
monkeypatch.setenv("TEAMS_SERVICE_URL", "https://attacker.example.com/teams/")
# If the allowlist check fails to fire, the fake session will assert
# because no scripts are queued; a passing test means we returned
# before any HTTP call.
session = _FakeAiohttpSession([])
_install_fake_aiohttp(monkeypatch, session)
result = await _teams_mod._standalone_send(
PlatformConfig(enabled=True, extra={}),
"19:abc@thread.skype",
"hi",
)
assert "error" in result
assert "allowlist" in result["error"].lower()
assert len(session.calls) == 0, "must not call any HTTP endpoint with a tampered service URL"
@pytest.mark.asyncio
async def test_standalone_send_rejects_chat_id_with_path_traversal(self, monkeypatch):
monkeypatch.setenv("TEAMS_CLIENT_ID", "client-id")
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "secret")
monkeypatch.setenv("TEAMS_TENANT_ID", "tenant")
monkeypatch.delenv("TEAMS_SERVICE_URL", raising=False)
session = _FakeAiohttpSession([])
_install_fake_aiohttp(monkeypatch, session)
# Attempt to break out of /v3/conversations/<id>/activities via a `/`
result = await _teams_mod._standalone_send(
PlatformConfig(enabled=True, extra={}),
"19:abc/activities/19:other@thread.skype",
"hi",
)
assert "error" in result
assert "Bot Framework conversation ID" in result["error"]
assert len(session.calls) == 0