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.
This commit is contained in:
kshitijk4poor 2026-05-23 17:08:58 +05:30 committed by Teknium
parent 00ec0b617c
commit af973e4071
14 changed files with 402 additions and 105 deletions

View file

@ -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", {})

View file

@ -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():

View file

@ -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,

View file

@ -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)")

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View file

@ -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 YAMLenv 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,
)

View file

@ -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

View file

@ -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

View file

@ -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"},

View file

@ -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:

View file

@ -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"),

View file

@ -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

View file

@ -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

View file

@ -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.