mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
Second migration of an existing built-in platform adapter after Discord (PR #30591) — follows the same shape established by IRC / Teams / LINE / Google Chat / SimpleX and the playbook in `references/platform-plugin-migration.md`. Advances the umbrella refactor in #3823. Matches Discord's parity bar — adapter under `plugins/platforms/mattermost/` with the standard `__init__.py` / `adapter.py` / `plugin.yaml` shell, `register(ctx)` entry point, **no back-compat shim** at the old import path, and full parity for all five hooks Discord uses plus the `apply_yaml_config_fn` hook (mattermost is the second consumer of #25443 after Discord): * `standalone_sender_fn` — out-of-process cron delivery via Mattermost REST API. Picks up the thread_id + media_files capabilities the legacy `_send_mattermost` lacked (parity with Discord's `_standalone_send`). * `setup_fn` — interactive `hermes setup gateway` wizard. * `apply_yaml_config_fn` — translates `config.yaml` `mattermost:` keys (`require_mention`, `free_response_channels`, `allowed_channels`) into `MATTERMOST_*` env vars (replaces the hardcoded block in `gateway/config.py`). * `is_connected` — declares connection state from `MATTERMOST_TOKEN` + `MATTERMOST_URL`. * `check_fn` — verifies aiohttp is installed and both required env vars are set. * plus `allowed_users_env`, `allow_all_env`, `cron_deliver_env_var`, `max_message_length` (4000 — Mattermost practical limit), `emoji`, `required_env`, `install_hint`. Files ----- * `gateway/platforms/mattermost.py` (873 LOC) → `plugins/platforms/mattermost/adapter.py` (git rename, R071) + appended `register()` block, hook helpers, and `_standalone_send` with media upload + thread_id support. * New `plugins/platforms/mattermost/{__init__.py, plugin.yaml}` with `requires_env` / `optional_env` declarations covering MATTERMOST_URL, MATTERMOST_TOKEN, MATTERMOST_ALLOWED_USERS, MATTERMOST_ALLOW_ALL_USERS, MATTERMOST_HOME_CHANNEL, MATTERMOST_REPLY_MODE, MATTERMOST_REQUIRE_MENTION, MATTERMOST_FREE_RESPONSE_CHANNELS, MATTERMOST_ALLOWED_CHANNELS. * `gateway/config.py`: delete 17-LOC `mattermost_cfg` YAML→env bridge (moved into plugin's `_apply_yaml_config`). * `gateway/run.py::_create_adapter`: delete `Platform.MATTERMOST elif` — replaced by the existing generic plugin-registry-first dispatch. * `tools/send_message_tool.py`: delete `_send_mattermost` (22 LOC) + `Platform.MATTERMOST elif` in `_send_to_platform` — the `else` branch already routes plugin platforms through `_send_via_adapter`, which hits the registry's `standalone_sender_fn`. * `hermes_cli/setup.py`: delete `_setup_mattermost` (44 LOC) — replaced by the plugin's `interactive_setup`. * `hermes_cli/gateway.py`: delete `_PLATFORMS["mattermost"]` dict entry (3 LOC) — plugin's `setup_fn` is dispatched via the plugin path in `_configure_platform`. * Consumer rewrite: 5 test files (test_mattermost.py, test_media_download_retry.py, test_send_multiple_images.py, test_stream_consumer.py, test_ws_auth_retry.py) get `gateway.platforms.mattermost` → `plugins.platforms.mattermost.adapter` with the bulk-rewrite recipe from the platform-plugin-migration playbook. Single `mock.patch` string in test_stream_consumer.py also repointed. * `tests/tools/test_send_message_missing_platforms.py`: thin `(token, extra, chat_id, message)` compat shim around the plugin's `_standalone_send(pconfig, …)` so existing test bodies continue to work without rewriting every signature. Validation ---------- * Plugin discovery: mattermost registers from `plugins/platforms/mattermost/` alongside discord / teams / irc / line / google_chat / simplex. All 9 hooks present (setup_fn, standalone_sender_fn, apply_yaml_config_fn, is_connected, check_fn, allowed_users_env, allow_all_env, cron_deliver_env_var, max_message_length=4000). * Mattermost-touching tests: 62/62 pass (`test_mattermost.py` + `test_send_message_missing_platforms.py`). * Targeted selectors (mattermost or platform_registry or stream_consumer or ws_auth_retry or media_download_retry or send_multiple_images or send_message_tool or platform_connected): 433/433 pass. * Full sweep (`scripts/run_tests.sh tests/gateway/ tests/cron/ tests/tools/test_send_message_tool.py tests/tools/test_send_message_missing_platforms.py tests/integration/`): **6220/6220 pass in 47.8s, 0 failures**. * Lint: ruff clean on all touched files. * Git identity verified: kshitijk4poor. * Rename detection: R071 (similarity dropped from a hypothetical R09x by the ~320-line appended register block — ~36% growth over the 873-LoC base, vs Discord's 5101 LoC base which kept R091). Closes part of #3823.
374 lines
15 KiB
Python
374 lines
15 KiB
Python
"""Tests for _send_mattermost, _send_matrix, _send_homeassistant, _send_dingtalk."""
|
|
|
|
import asyncio
|
|
import os
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from tools.send_message_tool import (
|
|
_send_dingtalk,
|
|
_send_homeassistant,
|
|
_send_matrix,
|
|
)
|
|
|
|
# ``_send_mattermost`` moved into the mattermost plugin
|
|
# (``plugins/platforms/mattermost/adapter.py::_standalone_send``). Keep a
|
|
# thin ``(token, extra, chat_id, message)``-shaped wrapper so existing test
|
|
# bodies continue to work without rewriting every signature.
|
|
from plugins.platforms.mattermost.adapter import (
|
|
_standalone_send as _mattermost_standalone_send,
|
|
)
|
|
|
|
|
|
async def _send_mattermost(token, extra, chat_id, message):
|
|
"""Pre-migration ``(token, extra, chat_id, message)`` shim around the
|
|
plugin's ``_standalone_send(pconfig, chat_id, message)``.
|
|
"""
|
|
pconfig = SimpleNamespace(token=token, extra=extra or {})
|
|
return await _mattermost_standalone_send(pconfig, chat_id, message)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_aiohttp_resp(status, json_data=None, text_data=None):
|
|
"""Build a minimal async-context-manager mock for an aiohttp response."""
|
|
resp = AsyncMock()
|
|
resp.status = status
|
|
resp.json = AsyncMock(return_value=json_data or {})
|
|
resp.text = AsyncMock(return_value=text_data or "")
|
|
return resp
|
|
|
|
|
|
def _make_aiohttp_session(resp):
|
|
"""Wrap a response mock in a session mock that supports async-with for post/put."""
|
|
request_ctx = MagicMock()
|
|
request_ctx.__aenter__ = AsyncMock(return_value=resp)
|
|
request_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
session = MagicMock()
|
|
session.post = MagicMock(return_value=request_ctx)
|
|
session.put = MagicMock(return_value=request_ctx)
|
|
|
|
session_ctx = MagicMock()
|
|
session_ctx.__aenter__ = AsyncMock(return_value=session)
|
|
session_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
return session_ctx, session
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _send_mattermost
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSendMattermost:
|
|
def test_success(self):
|
|
resp = _make_aiohttp_resp(201, json_data={"id": "post123"})
|
|
session_ctx, session = _make_aiohttp_session(resp)
|
|
|
|
with patch("aiohttp.ClientSession", return_value=session_ctx), \
|
|
patch.dict(os.environ, {"MATTERMOST_URL": "", "MATTERMOST_TOKEN": ""}, clear=False):
|
|
extra = {"url": "https://mm.example.com"}
|
|
result = asyncio.run(_send_mattermost("tok-abc", extra, "channel1", "hello"))
|
|
|
|
assert result == {"success": True, "platform": "mattermost", "chat_id": "channel1", "message_id": "post123"}
|
|
session.post.assert_called_once()
|
|
call_kwargs = session.post.call_args
|
|
assert call_kwargs[0][0] == "https://mm.example.com/api/v4/posts"
|
|
assert call_kwargs[1]["headers"]["Authorization"] == "Bearer tok-abc"
|
|
assert call_kwargs[1]["json"] == {"channel_id": "channel1", "message": "hello"}
|
|
|
|
def test_http_error(self):
|
|
resp = _make_aiohttp_resp(400, text_data="Bad Request")
|
|
session_ctx, _ = _make_aiohttp_session(resp)
|
|
|
|
with patch("aiohttp.ClientSession", return_value=session_ctx):
|
|
result = asyncio.run(_send_mattermost(
|
|
"tok", {"url": "https://mm.example.com"}, "ch", "hi"
|
|
))
|
|
|
|
assert "error" in result
|
|
assert "400" in result["error"]
|
|
assert "Bad Request" in result["error"]
|
|
|
|
def test_missing_config(self):
|
|
with patch.dict(os.environ, {"MATTERMOST_URL": "", "MATTERMOST_TOKEN": ""}, clear=False):
|
|
result = asyncio.run(_send_mattermost("", {}, "ch", "hi"))
|
|
|
|
assert "error" in result
|
|
assert "MATTERMOST_URL" in result["error"] or "not configured" in result["error"]
|
|
|
|
def test_env_var_fallback(self):
|
|
resp = _make_aiohttp_resp(200, json_data={"id": "p99"})
|
|
session_ctx, session = _make_aiohttp_session(resp)
|
|
|
|
with patch("aiohttp.ClientSession", return_value=session_ctx), \
|
|
patch.dict(os.environ, {"MATTERMOST_URL": "https://mm.env.com", "MATTERMOST_TOKEN": "env-tok"}, clear=False):
|
|
result = asyncio.run(_send_mattermost("", {}, "ch", "hi"))
|
|
|
|
assert result["success"] is True
|
|
call_kwargs = session.post.call_args
|
|
assert "https://mm.env.com" in call_kwargs[0][0]
|
|
assert call_kwargs[1]["headers"]["Authorization"] == "Bearer env-tok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _send_matrix
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSendMatrix:
|
|
def test_success(self):
|
|
resp = _make_aiohttp_resp(200, json_data={"event_id": "$abc123"})
|
|
session_ctx, session = _make_aiohttp_session(resp)
|
|
|
|
with patch("aiohttp.ClientSession", return_value=session_ctx), \
|
|
patch.dict(os.environ, {"MATRIX_HOMESERVER": "", "MATRIX_ACCESS_TOKEN": ""}, clear=False):
|
|
extra = {"homeserver": "https://matrix.example.com"}
|
|
result = asyncio.run(_send_matrix("syt_tok", extra, "!room:example.com", "hello matrix"))
|
|
|
|
assert result == {
|
|
"success": True,
|
|
"platform": "matrix",
|
|
"chat_id": "!room:example.com",
|
|
"message_id": "$abc123",
|
|
}
|
|
session.put.assert_called_once()
|
|
call_kwargs = session.put.call_args
|
|
url = call_kwargs[0][0]
|
|
assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/%21room%3Aexample.com/send/m.room.message/")
|
|
assert call_kwargs[1]["headers"]["Authorization"] == "Bearer syt_tok"
|
|
payload = call_kwargs[1]["json"]
|
|
assert payload["msgtype"] == "m.text"
|
|
assert payload["body"] == "hello matrix"
|
|
|
|
def test_http_error(self):
|
|
resp = _make_aiohttp_resp(403, text_data="Forbidden")
|
|
session_ctx, _ = _make_aiohttp_session(resp)
|
|
|
|
with patch("aiohttp.ClientSession", return_value=session_ctx):
|
|
result = asyncio.run(_send_matrix(
|
|
"tok", {"homeserver": "https://matrix.example.com"},
|
|
"!room:example.com", "hi"
|
|
))
|
|
|
|
assert "error" in result
|
|
assert "403" in result["error"]
|
|
assert "Forbidden" in result["error"]
|
|
|
|
def test_missing_config(self):
|
|
with patch.dict(os.environ, {"MATRIX_HOMESERVER": "", "MATRIX_ACCESS_TOKEN": ""}, clear=False):
|
|
result = asyncio.run(_send_matrix("", {}, "!room:example.com", "hi"))
|
|
|
|
assert "error" in result
|
|
assert "MATRIX_HOMESERVER" in result["error"] or "not configured" in result["error"]
|
|
|
|
def test_env_var_fallback(self):
|
|
resp = _make_aiohttp_resp(200, json_data={"event_id": "$ev1"})
|
|
session_ctx, session = _make_aiohttp_session(resp)
|
|
|
|
with patch("aiohttp.ClientSession", return_value=session_ctx), \
|
|
patch.dict(os.environ, {
|
|
"MATRIX_HOMESERVER": "https://matrix.env.com",
|
|
"MATRIX_ACCESS_TOKEN": "env-tok",
|
|
}, clear=False):
|
|
result = asyncio.run(_send_matrix("", {}, "!r:env.com", "hi"))
|
|
|
|
assert result["success"] is True
|
|
url = session.put.call_args[0][0]
|
|
assert "matrix.env.com" in url
|
|
|
|
def test_txn_id_is_unique_across_calls(self):
|
|
"""Each call should generate a distinct transaction ID in the URL."""
|
|
txn_ids = []
|
|
|
|
def capture(*args, **kwargs):
|
|
url = args[0]
|
|
txn_ids.append(url.rsplit("/", 1)[-1])
|
|
ctx = MagicMock()
|
|
ctx.__aenter__ = AsyncMock(return_value=_make_aiohttp_resp(200, json_data={"event_id": "$x"}))
|
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
return ctx
|
|
|
|
session = MagicMock()
|
|
session.put = capture
|
|
session_ctx = MagicMock()
|
|
session_ctx.__aenter__ = AsyncMock(return_value=session)
|
|
session_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
extra = {"homeserver": "https://matrix.example.com"}
|
|
|
|
import time
|
|
with patch("aiohttp.ClientSession", return_value=session_ctx):
|
|
asyncio.run(_send_matrix("tok", extra, "!r:example.com", "first"))
|
|
time.sleep(0.002)
|
|
with patch("aiohttp.ClientSession", return_value=session_ctx):
|
|
asyncio.run(_send_matrix("tok", extra, "!r:example.com", "second"))
|
|
|
|
assert len(txn_ids) == 2
|
|
assert txn_ids[0] != txn_ids[1]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _send_homeassistant
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSendHomeAssistant:
|
|
def test_success(self):
|
|
resp = _make_aiohttp_resp(200)
|
|
session_ctx, session = _make_aiohttp_session(resp)
|
|
|
|
with patch("aiohttp.ClientSession", return_value=session_ctx), \
|
|
patch.dict(os.environ, {"HASS_URL": "", "HASS_TOKEN": ""}, clear=False):
|
|
extra = {"url": "https://hass.example.com"}
|
|
result = asyncio.run(_send_homeassistant("hass-tok", extra, "mobile_app_phone", "alert!"))
|
|
|
|
assert result == {"success": True, "platform": "homeassistant", "chat_id": "mobile_app_phone"}
|
|
session.post.assert_called_once()
|
|
call_kwargs = session.post.call_args
|
|
assert call_kwargs[0][0] == "https://hass.example.com/api/services/notify/notify"
|
|
assert call_kwargs[1]["headers"]["Authorization"] == "Bearer hass-tok"
|
|
assert call_kwargs[1]["json"] == {"message": "alert!", "target": "mobile_app_phone"}
|
|
|
|
def test_http_error(self):
|
|
resp = _make_aiohttp_resp(401, text_data="Unauthorized")
|
|
session_ctx, _ = _make_aiohttp_session(resp)
|
|
|
|
with patch("aiohttp.ClientSession", return_value=session_ctx):
|
|
result = asyncio.run(_send_homeassistant(
|
|
"bad-tok", {"url": "https://hass.example.com"},
|
|
"target", "msg"
|
|
))
|
|
|
|
assert "error" in result
|
|
assert "401" in result["error"]
|
|
assert "Unauthorized" in result["error"]
|
|
|
|
def test_missing_config(self):
|
|
with patch.dict(os.environ, {"HASS_URL": "", "HASS_TOKEN": ""}, clear=False):
|
|
result = asyncio.run(_send_homeassistant("", {}, "target", "msg"))
|
|
|
|
assert "error" in result
|
|
assert "HASS_URL" in result["error"] or "not configured" in result["error"]
|
|
|
|
def test_env_var_fallback(self):
|
|
resp = _make_aiohttp_resp(200)
|
|
session_ctx, session = _make_aiohttp_session(resp)
|
|
|
|
with patch("aiohttp.ClientSession", return_value=session_ctx), \
|
|
patch.dict(os.environ, {"HASS_URL": "https://hass.env.com", "HASS_TOKEN": "env-tok"}, clear=False):
|
|
result = asyncio.run(_send_homeassistant("", {}, "notify_target", "hi"))
|
|
|
|
assert result["success"] is True
|
|
url = session.post.call_args[0][0]
|
|
assert "hass.env.com" in url
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _send_dingtalk
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSendDingtalk:
|
|
def _make_httpx_resp(self, status_code=200, json_data=None):
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
resp.json = MagicMock(return_value=json_data or {"errcode": 0, "errmsg": "ok"})
|
|
resp.raise_for_status = MagicMock()
|
|
return resp
|
|
|
|
def _make_httpx_client(self, resp):
|
|
client = AsyncMock()
|
|
client.post = AsyncMock(return_value=resp)
|
|
client_ctx = MagicMock()
|
|
client_ctx.__aenter__ = AsyncMock(return_value=client)
|
|
client_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
return client_ctx, client
|
|
|
|
def test_success(self):
|
|
resp = self._make_httpx_resp(json_data={"errcode": 0, "errmsg": "ok"})
|
|
client_ctx, client = self._make_httpx_client(resp)
|
|
|
|
with patch("httpx.AsyncClient", return_value=client_ctx):
|
|
extra = {"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=abc"}
|
|
result = asyncio.run(_send_dingtalk(extra, "ignored", "hello dingtalk"))
|
|
|
|
assert result == {"success": True, "platform": "dingtalk", "chat_id": "ignored"}
|
|
client.post.assert_awaited_once()
|
|
call_kwargs = client.post.await_args
|
|
assert call_kwargs[0][0] == "https://oapi.dingtalk.com/robot/send?access_token=abc"
|
|
assert call_kwargs[1]["json"] == {"msgtype": "text", "text": {"content": "hello dingtalk"}}
|
|
|
|
def test_api_error_in_response_body(self):
|
|
"""DingTalk always returns HTTP 200 but signals errors via errcode."""
|
|
resp = self._make_httpx_resp(json_data={"errcode": 310000, "errmsg": "sign not match"})
|
|
client_ctx, _ = self._make_httpx_client(resp)
|
|
|
|
with patch("httpx.AsyncClient", return_value=client_ctx):
|
|
result = asyncio.run(_send_dingtalk(
|
|
{"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=bad"},
|
|
"ch", "hi"
|
|
))
|
|
|
|
assert "error" in result
|
|
assert "sign not match" in result["error"]
|
|
|
|
def test_http_error(self):
|
|
"""If raise_for_status throws, the error is caught and returned."""
|
|
resp = self._make_httpx_resp(status_code=429)
|
|
resp.raise_for_status = MagicMock(side_effect=Exception("429 Too Many Requests"))
|
|
client_ctx, _ = self._make_httpx_client(resp)
|
|
|
|
with patch("httpx.AsyncClient", return_value=client_ctx):
|
|
result = asyncio.run(_send_dingtalk(
|
|
{"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=tok"},
|
|
"ch", "hi"
|
|
))
|
|
|
|
assert "error" in result
|
|
assert "DingTalk send failed" in result["error"]
|
|
|
|
def test_http_error_redacts_access_token_in_exception_text(self):
|
|
token = "supersecret-access-token-123456789"
|
|
resp = self._make_httpx_resp(status_code=401)
|
|
resp.raise_for_status = MagicMock(
|
|
side_effect=Exception(
|
|
f"POST https://oapi.dingtalk.com/robot/send?access_token={token} returned 401"
|
|
)
|
|
)
|
|
client_ctx, _ = self._make_httpx_client(resp)
|
|
|
|
with patch("httpx.AsyncClient", return_value=client_ctx):
|
|
result = asyncio.run(
|
|
_send_dingtalk(
|
|
{"webhook_url": f"https://oapi.dingtalk.com/robot/send?access_token={token}"},
|
|
"ch",
|
|
"hi",
|
|
)
|
|
)
|
|
|
|
assert "error" in result
|
|
assert token not in result["error"]
|
|
assert "access_token=***" in result["error"]
|
|
|
|
def test_missing_config(self):
|
|
with patch.dict(os.environ, {"DINGTALK_WEBHOOK_URL": ""}, clear=False):
|
|
result = asyncio.run(_send_dingtalk({}, "ch", "hi"))
|
|
|
|
assert "error" in result
|
|
assert "DINGTALK_WEBHOOK_URL" in result["error"] or "not configured" in result["error"]
|
|
|
|
def test_env_var_fallback(self):
|
|
resp = self._make_httpx_resp(json_data={"errcode": 0, "errmsg": "ok"})
|
|
client_ctx, client = self._make_httpx_client(resp)
|
|
|
|
with patch("httpx.AsyncClient", return_value=client_ctx), \
|
|
patch.dict(os.environ, {"DINGTALK_WEBHOOK_URL": "https://oapi.dingtalk.com/robot/send?access_token=env"}, clear=False):
|
|
result = asyncio.run(_send_dingtalk({}, "ch", "hi"))
|
|
|
|
assert result["success"] is True
|
|
call_kwargs = client.post.await_args
|
|
assert "access_token=env" in call_kwargs[0][0]
|