diff --git a/gateway/config.py b/gateway/config.py index ec98532a273..6f30ee70643 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1089,22 +1089,8 @@ def load_gateway_config() -> GatewayConfig: allowed = ",".join(str(v) for v in allowed) os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed) - # Mattermost settings → env vars (env vars take precedence) - mattermost_cfg = yaml_cfg.get("mattermost", {}) - if isinstance(mattermost_cfg, dict): - if "require_mention" in mattermost_cfg and not os.getenv("MATTERMOST_REQUIRE_MENTION"): - os.environ["MATTERMOST_REQUIRE_MENTION"] = str(mattermost_cfg["require_mention"]).lower() - frc = mattermost_cfg.get("free_response_channels") - if frc is not None and not os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS"): - if isinstance(frc, list): - frc = ",".join(str(v) for v in frc) - os.environ["MATTERMOST_FREE_RESPONSE_CHANNELS"] = str(frc) - # allowed_channels: if set, bot ONLY responds in these channels (whitelist) - ac = mattermost_cfg.get("allowed_channels") - if ac is not None and not os.getenv("MATTERMOST_ALLOWED_CHANNELS"): - if isinstance(ac, list): - ac = ",".join(str(v) for v in ac) - os.environ["MATTERMOST_ALLOWED_CHANNELS"] = str(ac) + # Mattermost config bridge moved into plugins/platforms/mattermost/ + # adapter.py::_apply_yaml_config — see #25443 (apply_yaml_config_fn). # Matrix settings → env vars (env vars take precedence) matrix_cfg = yaml_cfg.get("matrix", {}) diff --git a/gateway/run.py b/gateway/run.py index 5089586386e..367adbe61db 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6226,13 +6226,6 @@ class GatewayRunner: return None return WeixinAdapter(config) - elif platform == Platform.MATTERMOST: - from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements - if not check_mattermost_requirements(): - logger.warning("Mattermost: MATTERMOST_TOKEN or MATTERMOST_URL not set, or aiohttp missing") - return None - return MattermostAdapter(config) - elif platform == Platform.MATRIX: from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements if not check_matrix_requirements(): diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 79d90c9e6ef..67d8136499d 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -4728,7 +4728,9 @@ def _builtin_setup_fn(key: str): # via the plugin path in _configure_platform(). "slack": _s._setup_slack, "matrix": _s._setup_matrix, - "mattermost": _s._setup_mattermost, + # mattermost moved into the plugin: setup_fn is registered by + # plugins/platforms/mattermost/adapter.py::register() and dispatched + # via the plugin path in _configure_platform(). "bluebubbles": _s._setup_bluebubbles, "webhooks": _s._setup_webhooks, "signal": _setup_signal, diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 16eeba4e825..78222a85b90 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2261,50 +2261,6 @@ def _setup_matrix(): save_env_value("MATRIX_HOME_ROOM", home_room) -def _setup_mattermost(): - """Configure Mattermost bot credentials.""" - print_header("Mattermost") - existing = get_env_value("MATTERMOST_TOKEN") - if existing: - print_info("Mattermost: already configured") - if not prompt_yes_no("Reconfigure Mattermost?", False): - return - - print_info("Works with any self-hosted Mattermost instance.") - print_info(" 1. In Mattermost: Integrations → Bot Accounts → Add Bot Account") - print_info(" 2. Copy the bot token") - print() - mm_url = prompt("Mattermost server URL (e.g. https://mm.example.com)") - if mm_url: - save_env_value("MATTERMOST_URL", mm_url.rstrip("/")) - token = prompt("Bot token", password=True) - if not token: - return - save_env_value("MATTERMOST_TOKEN", token) - print_success("Mattermost token saved") - - print() - print_info("🔒 Security: Restrict who can use your bot") - print_info(" To find your user ID: click your avatar → Profile") - print_info(" or use the API: GET /api/v4/users/me") - print() - allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") - if allowed_users: - save_env_value("MATTERMOST_ALLOWED_USERS", allowed_users.replace(" ", "")) - print_success("Mattermost allowlist configured") - else: - print_info("⚠️ No allowlist set - anyone who can message the bot can use it!") - - print() - print_info("📬 Home Channel: where Hermes delivers cron job results and notifications.") - print_info(" To get a channel ID: click channel name → View Info → copy the ID") - print_info(" You can also set this later by typing /set-home in a Mattermost channel.") - home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") - if home_channel: - save_env_value("MATTERMOST_HOME_CHANNEL", home_channel) - print_info(" Open config in your editor: hermes config edit") - - def _setup_bluebubbles(): """Configure BlueBubbles iMessage gateway.""" print_header("BlueBubbles (iMessage)") diff --git a/plugins/platforms/mattermost/__init__.py b/plugins/platforms/mattermost/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/mattermost/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/mattermost.py b/plugins/platforms/mattermost/adapter.py similarity index 71% rename from gateway/platforms/mattermost.py rename to plugins/platforms/mattermost/adapter.py index 6bfa6ac4372..bb6dc9b81f2 100644 --- a/gateway/platforms/mattermost.py +++ b/plugins/platforms/mattermost/adapter.py @@ -871,3 +871,322 @@ class MattermostAdapter(BasePlatformAdapter): await self.handle_message(msg_event) + + +# --------------------------------------------------------------------------- +# Plugin standalone-send (out-of-process cron delivery via Mattermost REST) +# --------------------------------------------------------------------------- + + +async def _standalone_send( + pconfig, + chat_id: str, + message: str, + *, + thread_id: Optional[str] = None, + media_files: Optional[list] = None, + force_document: bool = False, +) -> Dict[str, Any]: + """Send via the Mattermost v4 REST API without a live gateway adapter. + + Used by ``tools/send_message_tool._send_via_adapter`` when the gateway + runner is not in this process (typical for cron jobs running out-of-process). + Reads ``MATTERMOST_TOKEN`` from ``pconfig.token`` (set by the gateway + config loader from env) and falls back to the ``MATTERMOST_TOKEN`` env + var. Server URL comes from ``pconfig.extra["url"]`` (set by the YAML + bridge / env loader) or the ``MATTERMOST_URL`` env var. + + Thread replies (Mattermost CRT) are supported via the ``root_id`` field + on the ``POST /posts`` payload — pass ``thread_id`` when threading is + desired. ``media_files`` are uploaded via ``POST /files`` + (multipart/form-data), then their returned ``file_id`` values are + attached to the post. + + ``force_document`` is accepted for signature parity with other + standalone senders but unused — Mattermost stores every uploaded file + as a generic attachment regardless. + """ + try: + import aiohttp + except ImportError: + return {"error": "aiohttp not installed. Run: pip install aiohttp"} + + base_url = ( + (getattr(pconfig, "extra", {}) or {}).get("url") + or os.getenv("MATTERMOST_URL", "") + ).rstrip("/") + token = (getattr(pconfig, "token", None) or os.getenv("MATTERMOST_TOKEN", "")).strip() + if not base_url or not token: + return { + "error": ( + "Mattermost standalone send: MATTERMOST_URL and " + "MATTERMOST_TOKEN must both be set" + ) + } + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + upload_headers = {"Authorization": f"Bearer {token}"} + + media_files = media_files or [] + + try: + # Resolve proxy + session kwargs once so a single ClientSession can + # cover the optional file uploads + final post. + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _proxy = resolve_proxy_url(platform_env_var="MATTERMOST_PROXY") + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) + + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=60), + **_sess_kw, + ) as session: + # 1. Upload media (if any) and collect file_ids. + file_ids: List[str] = [] + for media in media_files: + file_path = media.get("path") if isinstance(media, dict) else media + if not file_path or not os.path.exists(file_path): + continue + form = aiohttp.FormData() + # Mattermost requires channel_id on file uploads so the + # server can attribute them. + form.add_field("channel_id", chat_id) + with open(file_path, "rb") as fh: + form.add_field( + "files", + fh.read(), + filename=os.path.basename(file_path), + ) + async with session.post( + f"{base_url}/api/v4/files", + data=form, + headers=upload_headers, + **_req_kw, + ) as upload_resp: + if upload_resp.status not in {200, 201}: + body = await upload_resp.text() + return { + "error": ( + f"Mattermost file upload failed " + f"({upload_resp.status}): {body[:400]}" + ) + } + upload_data = await upload_resp.json() + for info in upload_data.get("file_infos", []): + if info.get("id"): + file_ids.append(info["id"]) + + # 2. Post the message (with thread root + attached file_ids). + payload: Dict[str, Any] = { + "channel_id": chat_id, + "message": message, + } + if thread_id: + payload["root_id"] = thread_id + if file_ids: + payload["file_ids"] = file_ids + async with session.post( + f"{base_url}/api/v4/posts", + headers=headers, + json=payload, + **_req_kw, + ) as resp: + if resp.status not in {200, 201}: + body = await resp.text() + return { + "error": ( + f"Mattermost API error ({resp.status}): " + f"{body[:400]}" + ) + } + data = await resp.json() + return { + "success": True, + "platform": "mattermost", + "chat_id": chat_id, + "message_id": data.get("id"), + } + except aiohttp.ClientError as exc: + return {"error": f"Mattermost send failed (network): {exc}"} + except Exception as exc: # noqa: BLE001 + return {"error": f"Mattermost send failed: {exc}"} + + +# --------------------------------------------------------------------------- +# Interactive setup wizard +# --------------------------------------------------------------------------- + + +def interactive_setup() -> None: + """Guide the user through Mattermost bot setup. + + Mirrors Discord/Teams' ``interactive_setup`` shape: lazy-imports CLI + helpers so the plugin's import surface stays small, prompts for the + server URL + bot token, captures an allowlist, and offers to set a + home channel. Replaces the central + ``hermes_cli/setup.py::_setup_mattermost`` function this migration + removes. + """ + from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.cli_output import ( + prompt, + prompt_yes_no, + print_header, + print_info, + print_success, + ) + + print_header("Mattermost") + existing = get_env_value("MATTERMOST_TOKEN") + if existing: + print_info("Mattermost: already configured") + if not prompt_yes_no("Reconfigure Mattermost?", False): + return + + print_info("Works with any self-hosted Mattermost instance.") + print_info(" 1. In Mattermost: Integrations → Bot Accounts → Add Bot Account") + print_info(" 2. Copy the bot token") + print() + mm_url = prompt("Mattermost server URL (e.g. https://mm.example.com)") + if mm_url: + save_env_value("MATTERMOST_URL", mm_url.rstrip("/")) + token = prompt("Bot token", password=True) + if not token: + return + save_env_value("MATTERMOST_TOKEN", token) + print_success("Mattermost token saved") + + print() + print_info("🔒 Security: Restrict who can use your bot") + print_info(" To find your user ID: click your avatar → Profile") + print_info(" or use the API: GET /api/v4/users/me") + print() + allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") + if allowed_users: + save_env_value("MATTERMOST_ALLOWED_USERS", allowed_users.replace(" ", "")) + print_success("Mattermost allowlist configured") + else: + print_info("⚠️ No allowlist set - anyone who can message the bot can use it!") + + print() + print_info("📬 Home Channel: where Hermes delivers cron job results and notifications.") + print_info(" To get a channel ID: click channel name → View Info → copy the ID") + print_info(" You can also set this later by typing /set-home in a Mattermost channel.") + home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") + if home_channel: + save_env_value("MATTERMOST_HOME_CHANNEL", home_channel) + print_info(" Open config in your editor: hermes config edit") + + +# --------------------------------------------------------------------------- +# YAML → env config bridge (apply_yaml_config_fn, #25443) +# --------------------------------------------------------------------------- + + +def _apply_yaml_config(yaml_cfg: dict, mattermost_cfg: dict) -> dict | None: + """Translate ``config.yaml`` ``mattermost:`` keys into env vars. + + Implements the ``apply_yaml_config_fn`` contract (#24836 / #25443). + Mirrors the legacy ``mattermost_cfg`` block that used to live in + ``gateway/config.py::load_gateway_config()`` before this migration. + + The MattermostAdapter reads its runtime configuration via + ``os.getenv()`` for ``MATTERMOST_REQUIRE_MENTION``, + ``MATTERMOST_FREE_RESPONSE_CHANNELS``, and + ``MATTERMOST_ALLOWED_CHANNELS``. Rather than rewrite those call sites + to read from ``PlatformConfig.extra``, this hook keeps the env-driven + model and merely owns the YAML→env translation here, next to the + adapter that consumes it. + + Env vars take precedence over YAML — every assignment is guarded + by ``not os.getenv(...)`` so an explicit env var survives a config.yaml + update. Returns ``None`` because no extras are seeded into + ``PlatformConfig.extra`` directly (everything flows through env). + """ + if "require_mention" in mattermost_cfg and not os.getenv("MATTERMOST_REQUIRE_MENTION"): + os.environ["MATTERMOST_REQUIRE_MENTION"] = str(mattermost_cfg["require_mention"]).lower() + frc = mattermost_cfg.get("free_response_channels") + if frc is not None and not os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["MATTERMOST_FREE_RESPONSE_CHANNELS"] = str(frc) + # allowed_channels: if set, bot ONLY responds in these channels (whitelist) + ac = mattermost_cfg.get("allowed_channels") + if ac is not None and not os.getenv("MATTERMOST_ALLOWED_CHANNELS"): + if isinstance(ac, list): + ac = ",".join(str(v) for v in ac) + os.environ["MATTERMOST_ALLOWED_CHANNELS"] = str(ac) + return None # all settings flow through env; nothing to merge into extras + + +# --------------------------------------------------------------------------- +# is_connected probe +# --------------------------------------------------------------------------- + + +def _is_connected(config) -> bool: + """Mattermost is considered connected when BOTH MATTERMOST_TOKEN and + MATTERMOST_URL are set. + + Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via + the plugin's own bound import) so tests that patch + ``gateway_mod.get_env_value`` can suppress ambient env vars. Matches + what the legacy connected-platforms check did before this migration. + """ + import hermes_cli.gateway as gateway_mod + return bool( + (gateway_mod.get_env_value("MATTERMOST_TOKEN") or "").strip() + and (gateway_mod.get_env_value("MATTERMOST_URL") or "").strip() + ) + + +# --------------------------------------------------------------------------- +# Plugin registration entry point +# --------------------------------------------------------------------------- + + +def _build_adapter(config): + """Factory wrapper that constructs MattermostAdapter from a PlatformConfig.""" + return MattermostAdapter(config) + + +def register(ctx) -> None: + """Plugin entry point — called by the Hermes plugin system.""" + ctx.register_platform( + name="mattermost", + label="Mattermost", + adapter_factory=_build_adapter, + check_fn=check_mattermost_requirements, + is_connected=_is_connected, + required_env=["MATTERMOST_URL", "MATTERMOST_TOKEN"], + install_hint="pip install aiohttp", + # Interactive setup wizard — replaces the central + # hermes_cli/setup.py::_setup_mattermost function. + setup_fn=interactive_setup, + # YAML→env config bridge — owns the translation of + # ``config.yaml`` ``mattermost:`` keys (require_mention, + # free_response_channels, allowed_channels) into ``MATTERMOST_*`` + # env vars that the adapter reads via ``os.getenv()``. Replaces + # the hardcoded block that used to live in ``gateway/config.py``. + # Hook contract: #24836 / #25443. + apply_yaml_config_fn=_apply_yaml_config, + # Auth env vars for _is_user_authorized() integration. + allowed_users_env="MATTERMOST_ALLOWED_USERS", + allow_all_env="MATTERMOST_ALLOW_ALL_USERS", + # Cron home-channel delivery. + cron_deliver_env_var="MATTERMOST_HOME_CHANNEL", + # Out-of-process cron delivery via Mattermost REST API. Without + # this hook, ``deliver=mattermost`` cron jobs fail with "No live + # adapter" when cron runs separately from the gateway. Mirrors + # the Discord / Teams pattern. + standalone_sender_fn=_standalone_send, + # Mattermost practical post-length limit (server default is 16383 + # but 4000 is the readable threshold the adapter has used since + # day one). + max_message_length=MAX_POST_LENGTH, + # Display + emoji="💬", + allow_update_command=True, + ) diff --git a/plugins/platforms/mattermost/plugin.yaml b/plugins/platforms/mattermost/plugin.yaml new file mode 100644 index 00000000000..3ee5814cde8 --- /dev/null +++ b/plugins/platforms/mattermost/plugin.yaml @@ -0,0 +1,49 @@ +name: mattermost-platform +label: Mattermost +kind: platform +version: 1.0.0 +description: > + Mattermost gateway adapter for Hermes Agent. + Connects to a self-hosted or cloud Mattermost instance via the v4 REST + API + WebSocket event stream and relays messages between Mattermost + channels/DMs and the Hermes agent. Supports thread-mode replies, native + file uploads, channel-scoped allowlists, and home-channel cron delivery. +author: NousResearch +requires_env: + - name: MATTERMOST_URL + description: "Mattermost server URL (e.g. https://mm.example.com)" + prompt: "Mattermost server URL" + password: false + - name: MATTERMOST_TOKEN + description: "Bot account token or personal-access token" + prompt: "Mattermost bot token" + password: true +optional_env: + - name: MATTERMOST_ALLOWED_USERS + description: "Comma-separated Mattermost user IDs allowed to talk to the bot" + prompt: "Allowed users (comma-separated)" + password: false + - name: MATTERMOST_ALLOW_ALL_USERS + description: "Allow any Mattermost user to trigger the bot (dev only)" + prompt: "Allow all users? (true/false)" + password: false + - name: MATTERMOST_HOME_CHANNEL + description: "Default channel ID for cron / notification delivery" + prompt: "Home channel ID" + password: false + - name: MATTERMOST_REPLY_MODE + description: "How replies are sent: 'thread' (nested) or 'off' (flat). Default: off." + prompt: "Reply mode (thread|off)" + password: false + - name: MATTERMOST_REQUIRE_MENTION + description: "Require @bot mention in channels (default true). Set false for free-response everywhere." + prompt: "Require @mention? (true/false)" + password: false + - name: MATTERMOST_FREE_RESPONSE_CHANNELS + description: "Comma-separated channel IDs where @mention is not required." + prompt: "Free-response channel IDs (comma-separated)" + password: false + - name: MATTERMOST_ALLOWED_CHANNELS + description: "If set, the bot only responds in these channels (whitelist)." + prompt: "Allowed channel IDs (comma-separated)" + password: false diff --git a/tests/gateway/test_mattermost.py b/tests/gateway/test_mattermost.py index 933f3021682..cafe5ad68a4 100644 --- a/tests/gateway/test_mattermost.py +++ b/tests/gateway/test_mattermost.py @@ -71,7 +71,7 @@ class TestMattermostConfigLoading: def _make_adapter(): """Create a MattermostAdapter with mocked config.""" - from gateway.platforms.mattermost import MattermostAdapter + from plugins.platforms.mattermost.adapter import MattermostAdapter config = PlatformConfig( enabled=True, token="test-token", @@ -637,19 +637,19 @@ class TestMattermostRequirements: def test_check_requirements_with_token_and_url(self, monkeypatch): monkeypatch.setenv("MATTERMOST_TOKEN", "test-token") monkeypatch.setenv("MATTERMOST_URL", "https://mm.example.com") - from gateway.platforms.mattermost import check_mattermost_requirements + from plugins.platforms.mattermost.adapter import check_mattermost_requirements assert check_mattermost_requirements() is True def test_check_requirements_without_token(self, monkeypatch): monkeypatch.delenv("MATTERMOST_TOKEN", raising=False) monkeypatch.delenv("MATTERMOST_URL", raising=False) - from gateway.platforms.mattermost import check_mattermost_requirements + from plugins.platforms.mattermost.adapter import check_mattermost_requirements assert check_mattermost_requirements() is False def test_check_requirements_without_url(self, monkeypatch): monkeypatch.setenv("MATTERMOST_TOKEN", "test-token") monkeypatch.delenv("MATTERMOST_URL", raising=False) - from gateway.platforms.mattermost import check_mattermost_requirements + from plugins.platforms.mattermost.adapter import check_mattermost_requirements assert check_mattermost_requirements() is False diff --git a/tests/gateway/test_media_download_retry.py b/tests/gateway/test_media_download_retry.py index c43ad0929c6..5991b85e4eb 100644 --- a/tests/gateway/test_media_download_retry.py +++ b/tests/gateway/test_media_download_retry.py @@ -829,7 +829,7 @@ class TestSlackDownloadSlackFileBytes: def _make_mm_adapter(): """Build a minimal MattermostAdapter with mocked internals.""" - from gateway.platforms.mattermost import MattermostAdapter + from plugins.platforms.mattermost.adapter import MattermostAdapter config = PlatformConfig( enabled=True, token="mm-token-fake", extra={"url": "https://mm.example.com"}, diff --git a/tests/gateway/test_send_multiple_images.py b/tests/gateway/test_send_multiple_images.py index 5f6f3e7b771..6bff0f09a36 100644 --- a/tests/gateway/test_send_multiple_images.py +++ b/tests/gateway/test_send_multiple_images.py @@ -344,7 +344,7 @@ class TestSlackMultiImage: # --------------------------------------------------------------------------- -from gateway.platforms.mattermost import MattermostAdapter # noqa: E402 +from plugins.platforms.mattermost.adapter import MattermostAdapter # noqa: E402 class TestMattermostMultiImage: diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 24c984f0cc6..3a6baa65b05 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -152,7 +152,7 @@ class TestEditMessageFinalizeSignature: ("plugins.platforms.discord.adapter", "DiscordAdapter"), ("gateway.platforms.slack", "SlackAdapter"), ("gateway.platforms.matrix", "MatrixAdapter"), - ("gateway.platforms.mattermost", "MattermostAdapter"), + ("plugins.platforms.mattermost.adapter", "MattermostAdapter"), ("gateway.platforms.feishu", "FeishuAdapter"), ("gateway.platforms.whatsapp", "WhatsAppAdapter"), ("gateway.platforms.dingtalk", "DingTalkAdapter"), diff --git a/tests/gateway/test_ws_auth_retry.py b/tests/gateway/test_ws_auth_retry.py index 0da3979330a..e413a30f938 100644 --- a/tests/gateway/test_ws_auth_retry.py +++ b/tests/gateway/test_ws_auth_retry.py @@ -31,7 +31,7 @@ class TestMattermostWSAuthRetry: headers=MagicMock(), ) - from gateway.platforms.mattermost import MattermostAdapter + from plugins.platforms.mattermost.adapter import MattermostAdapter adapter = MattermostAdapter.__new__(MattermostAdapter) adapter._closing = False @@ -61,7 +61,7 @@ class TestMattermostWSAuthRetry: headers=MagicMock(), ) - from gateway.platforms.mattermost import MattermostAdapter + from plugins.platforms.mattermost.adapter import MattermostAdapter adapter = MattermostAdapter.__new__(MattermostAdapter) adapter._closing = False @@ -79,7 +79,7 @@ class TestMattermostWSAuthRetry: def test_transient_error_retries(self): """A transient ConnectionError should retry (not stop immediately).""" - from gateway.platforms.mattermost import MattermostAdapter + from plugins.platforms.mattermost.adapter import MattermostAdapter adapter = MattermostAdapter.__new__(MattermostAdapter) adapter._closing = False diff --git a/tests/tools/test_send_message_missing_platforms.py b/tests/tools/test_send_message_missing_platforms.py index cda43aad24f..cb201f8914b 100644 --- a/tests/tools/test_send_message_missing_platforms.py +++ b/tests/tools/test_send_message_missing_platforms.py @@ -8,10 +8,25 @@ from unittest.mock import AsyncMock, MagicMock, patch from tools.send_message_tool import ( _send_dingtalk, _send_homeassistant, - _send_mattermost, _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 diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 0f83e40c3c9..4494fbd0cf9 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -761,8 +761,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, result = await _send_email(pconfig.extra, chat_id, chunk) elif platform == Platform.SMS: result = await _send_sms(pconfig.api_key, chat_id, chunk) - elif platform == Platform.MATTERMOST: - result = await _send_mattermost(pconfig.token, pconfig.extra, chat_id, chunk) elif platform == Platform.MATRIX: result = await _send_matrix(pconfig.token, pconfig.extra, chat_id, chunk) elif platform == Platform.HOMEASSISTANT: @@ -1358,30 +1356,6 @@ async def _send_sms(auth_token, chat_id, message): return _error(f"SMS send failed: {e}") -async def _send_mattermost(token, extra, chat_id, message): - """Send via Mattermost REST API.""" - try: - import aiohttp - except ImportError: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} - try: - base_url = (extra.get("url") or os.getenv("MATTERMOST_URL", "")).rstrip("/") - token = token or os.getenv("MATTERMOST_TOKEN", "") - if not base_url or not token: - return {"error": "Mattermost not configured (MATTERMOST_URL, MATTERMOST_TOKEN required)"} - url = f"{base_url}/api/v4/posts" - headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: - async with session.post(url, headers=headers, json={"channel_id": chat_id, "message": message}) as resp: - if resp.status not in {200, 201}: - body = await resp.text() - return _error(f"Mattermost API error ({resp.status}): {body}") - data = await resp.json() - return {"success": True, "platform": "mattermost", "chat_id": chat_id, "message_id": data.get("id")} - except Exception as e: - return _error(f"Mattermost send failed: {e}") - - async def _send_matrix(token, extra, chat_id, message): """Send via Matrix Client-Server API.