fix(tools): implement send_message routing for Matrix, Mattermost, HomeAssistant, DingTalk (#3796)
* fix(tools): implement send_message routing for Matrix, Mattermost, HomeAssistant, DingTalk
Matrix, Mattermost, HomeAssistant, and DingTalk were present in
platform_map but fell through to the "not yet implemented" else branch,
causing send_message tool calls to silently fail on these platforms.
Add four async sender functions:
- _send_mattermost: POST /api/v4/posts via Mattermost REST API
- _send_matrix: PUT /_matrix/client/v3/rooms/.../send via Matrix CS API
- _send_homeassistant: POST /api/services/notify/notify via HA REST API
- _send_dingtalk: POST to session webhook URL
Add routing in _send_to_platform() and 17 unit tests covering success,
HTTP errors, missing config, env var fallback, and Matrix txn_id uniqueness.
* fix: pass platform tokens explicitly to Mattermost/Matrix/HA senders
The original PR passed pconfig.extra to sender functions, but tokens
live at pconfig.token (not in extra). This caused the senders to always
fall through to env var lookup instead of using the gateway-resolved
token.
Changes:
- Mattermost/Matrix/HA: accept token as first arg, matching the
Telegram/Discord/Slack sender pattern
- DingTalk: add DINGTALK_WEBHOOK_URL env var fallback + docstring
explaining the session-webhook vs robot-webhook difference
- Tests updated for new signatures + new DingTalk env var test
---------
Co-authored-by: sprmn24 <oncuevtv@gmail.com>
2026-03-29 15:17:46 -07:00
|
|
|
"""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,
|
|
|
|
|
)
|
|
|
|
|
|
refactor(gateway): migrate Mattermost adapter to bundled plugin
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.
2026-05-23 17:08:58 +05:30
|
|
|
# ``_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)
|
|
|
|
|
|
fix(tools): implement send_message routing for Matrix, Mattermost, HomeAssistant, DingTalk (#3796)
* fix(tools): implement send_message routing for Matrix, Mattermost, HomeAssistant, DingTalk
Matrix, Mattermost, HomeAssistant, and DingTalk were present in
platform_map but fell through to the "not yet implemented" else branch,
causing send_message tool calls to silently fail on these platforms.
Add four async sender functions:
- _send_mattermost: POST /api/v4/posts via Mattermost REST API
- _send_matrix: PUT /_matrix/client/v3/rooms/.../send via Matrix CS API
- _send_homeassistant: POST /api/services/notify/notify via HA REST API
- _send_dingtalk: POST to session webhook URL
Add routing in _send_to_platform() and 17 unit tests covering success,
HTTP errors, missing config, env var fallback, and Matrix txn_id uniqueness.
* fix: pass platform tokens explicitly to Mattermost/Matrix/HA senders
The original PR passed pconfig.extra to sender functions, but tokens
live at pconfig.token (not in extra). This caused the senders to always
fall through to env var lookup instead of using the gateway-resolved
token.
Changes:
- Mattermost/Matrix/HA: accept token as first arg, matching the
Telegram/Discord/Slack sender pattern
- DingTalk: add DINGTALK_WEBHOOK_URL env var fallback + docstring
explaining the session-webhook vs robot-webhook difference
- Tests updated for new signatures + new DingTalk env var test
---------
Co-authored-by: sprmn24 <oncuevtv@gmail.com>
2026-03-29 15:17:46 -07:00
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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]
|
2026-04-15 17:35:52 -07:00
|
|
|
assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/%21room%3Aexample.com/send/m.room.message/")
|
fix(tools): implement send_message routing for Matrix, Mattermost, HomeAssistant, DingTalk (#3796)
* fix(tools): implement send_message routing for Matrix, Mattermost, HomeAssistant, DingTalk
Matrix, Mattermost, HomeAssistant, and DingTalk were present in
platform_map but fell through to the "not yet implemented" else branch,
causing send_message tool calls to silently fail on these platforms.
Add four async sender functions:
- _send_mattermost: POST /api/v4/posts via Mattermost REST API
- _send_matrix: PUT /_matrix/client/v3/rooms/.../send via Matrix CS API
- _send_homeassistant: POST /api/services/notify/notify via HA REST API
- _send_dingtalk: POST to session webhook URL
Add routing in _send_to_platform() and 17 unit tests covering success,
HTTP errors, missing config, env var fallback, and Matrix txn_id uniqueness.
* fix: pass platform tokens explicitly to Mattermost/Matrix/HA senders
The original PR passed pconfig.extra to sender functions, but tokens
live at pconfig.token (not in extra). This caused the senders to always
fall through to env var lookup instead of using the gateway-resolved
token.
Changes:
- Mattermost/Matrix/HA: accept token as first arg, matching the
Telegram/Discord/Slack sender pattern
- DingTalk: add DINGTALK_WEBHOOK_URL env var fallback + docstring
explaining the session-webhook vs robot-webhook difference
- Tests updated for new signatures + new DingTalk env var test
---------
Co-authored-by: sprmn24 <oncuevtv@gmail.com>
2026-03-29 15:17:46 -07:00
|
|
|
assert call_kwargs[1]["headers"]["Authorization"] == "Bearer syt_tok"
|
fix(tests): fix several failing/flaky tests on main (#6777)
* fix(tests): mock is_safe_url in tests that use example.com
Tests using example.com URLs were failing because is_safe_url does a real DNS lookup which fails in environments where example.com doesn't resolve, causing the request to be blocked before reaching the already-mocked HTTP client. This should fix around 17 failing tests.
These tests test logic, caching, etc. so mocking this method should not modify them in any way. TestMattermostSendUrlAsFile was already doing this so we follow the same pattern.
* fix(test): use case-insensitive lookup for model context length check
DEFAULT_CONTEXT_LENGTHS uses inconsistent casing (MiniMax keys are lowercase, Qwen keys are mixed-case) so the test was broken in some cases since it couldn't find the model.
* fix(test): patch is_linux in systemd gateway restart test
The test only patched is_macos to False but didn't patch is_linux to True. On macOS hosts, is_linux() returns False and the systemd restart code path is skipped entirely, making the assertion fail.
* fix(test): use non-blocklisted env var in docker forward_env tests
GITHUB_TOKEN is in api_key_env_vars and thus in _HERMES_PROVIDER_ENV_BLOCKLIST so the env var is silently dropped, we replace it with a non-blocked one like DATABASE_URL so the tests actually work.
* fix(test): fully isolate _has_any_provider_configured from host env
_has_any_provider_configured() checks all env vars from PROVIDER_REGISTRY (not just the 5 the tests were clearing) and also calls get_auth_status() which detects gh auth token for Copilot. On machines with any of these set, the function returns True before reaching the code path under test.
Clear all registry vars and mock get_auth_status so host credentials don't interfere.
* fix(test): correct path to hermes_base_env.py in tool parser tests
Path(__file__).parent.parent resolved to tests/, not the project root.
The file lives at environments/hermes_base_env.py so we need one more parent level.
* fix(test): accept optional HTML fields in Matrix send payload
_send_matrix sometimes adds format and formatted_body when the markdown library is installed. The test was doing an exact dict equality check which broke. Check required fields instead.
* fix(test): add config.yaml to codex vision requirements test
The test only wrote auth.json but not config.yaml, so _read_main_provider() returned empty and vision auto-detect never tried the codex provider. Add a config.yaml pointing at openai-codex so the fallback path actually resolves the client.
* fix(test): clear OPENROUTER_API_KEY in _isolate_hermes_home
run_agent.py calls load_hermes_dotenv() at import time, which injects API keys from ~/.hermes/.env into os.environ before any test fixture runs. This caused test_agent_loop_tool_calling to make real API calls instead of skipping, which ends up making some tests fail.
* fix(test): add get_rate_limit_state to agent mock in usage report tests
_show_usage now calls agent.get_rate_limit_state() for rate limit
display. The SimpleNamespace mock was missing this method.
* fix(test): update expected Camofox config version from 12 to 13
* fix(test): mock _get_enabled_platforms in nous managed defaults test
Importing gateway.run leaks DISCORD_BOT_TOKEN into os.environ, which makes _get_enabled_platforms() return ["cli", "discord"] instead of just ["cli"]. tools_command loops per platform, so apply_nous_managed_defaults
runs twice: the first call sets config values, the second sees them as
already configured and returns an empty set, causing the assertion to
fail.
2026-04-09 17:17:06 -03:00
|
|
|
payload = call_kwargs[1]["json"]
|
|
|
|
|
assert payload["msgtype"] == "m.text"
|
|
|
|
|
assert payload["body"] == "hello matrix"
|
fix(tools): implement send_message routing for Matrix, Mattermost, HomeAssistant, DingTalk (#3796)
* fix(tools): implement send_message routing for Matrix, Mattermost, HomeAssistant, DingTalk
Matrix, Mattermost, HomeAssistant, and DingTalk were present in
platform_map but fell through to the "not yet implemented" else branch,
causing send_message tool calls to silently fail on these platforms.
Add four async sender functions:
- _send_mattermost: POST /api/v4/posts via Mattermost REST API
- _send_matrix: PUT /_matrix/client/v3/rooms/.../send via Matrix CS API
- _send_homeassistant: POST /api/services/notify/notify via HA REST API
- _send_dingtalk: POST to session webhook URL
Add routing in _send_to_platform() and 17 unit tests covering success,
HTTP errors, missing config, env var fallback, and Matrix txn_id uniqueness.
* fix: pass platform tokens explicitly to Mattermost/Matrix/HA senders
The original PR passed pconfig.extra to sender functions, but tokens
live at pconfig.token (not in extra). This caused the senders to always
fall through to env var lookup instead of using the gateway-resolved
token.
Changes:
- Mattermost/Matrix/HA: accept token as first arg, matching the
Telegram/Discord/Slack sender pattern
- DingTalk: add DINGTALK_WEBHOOK_URL env var fallback + docstring
explaining the session-webhook vs robot-webhook difference
- Tests updated for new signatures + new DingTalk env var test
---------
Co-authored-by: sprmn24 <oncuevtv@gmail.com>
2026-03-29 15:17:46 -07:00
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
|
2026-04-07 01:02:56 +03:00
|
|
|
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"]
|
|
|
|
|
|
fix(tools): implement send_message routing for Matrix, Mattermost, HomeAssistant, DingTalk (#3796)
* fix(tools): implement send_message routing for Matrix, Mattermost, HomeAssistant, DingTalk
Matrix, Mattermost, HomeAssistant, and DingTalk were present in
platform_map but fell through to the "not yet implemented" else branch,
causing send_message tool calls to silently fail on these platforms.
Add four async sender functions:
- _send_mattermost: POST /api/v4/posts via Mattermost REST API
- _send_matrix: PUT /_matrix/client/v3/rooms/.../send via Matrix CS API
- _send_homeassistant: POST /api/services/notify/notify via HA REST API
- _send_dingtalk: POST to session webhook URL
Add routing in _send_to_platform() and 17 unit tests covering success,
HTTP errors, missing config, env var fallback, and Matrix txn_id uniqueness.
* fix: pass platform tokens explicitly to Mattermost/Matrix/HA senders
The original PR passed pconfig.extra to sender functions, but tokens
live at pconfig.token (not in extra). This caused the senders to always
fall through to env var lookup instead of using the gateway-resolved
token.
Changes:
- Mattermost/Matrix/HA: accept token as first arg, matching the
Telegram/Discord/Slack sender pattern
- DingTalk: add DINGTALK_WEBHOOK_URL env var fallback + docstring
explaining the session-webhook vs robot-webhook difference
- Tests updated for new signatures + new DingTalk env var test
---------
Co-authored-by: sprmn24 <oncuevtv@gmail.com>
2026-03-29 15:17:46 -07:00
|
|
|
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]
|