Files
hermes-agent/plugins/platforms/mattermost/adapter.py
T

921 lines
42 KiB
Python

"""Mattermost gateway adapter.
Connects to a self-hosted (or cloud) Mattermost instance via its REST API
(v4) and WebSocket for real-time events. No external Mattermost library
required — uses aiohttp which is already a Hermes dependency.
Environment variables:
MATTERMOST_URL Server URL (e.g. https://mm.example.com)
MATTERMOST_TOKEN Bot token or personal-access token
MATTERMOST_ALLOWED_USERS Comma-separated user IDs
MATTERMOST_HOME_CHANNEL Channel ID for cron/notification delivery
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.base import gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, SendResult
from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret
from gateway.platforms._shared import profile_scoped as _profile_scoped_config_load
logger = logging.getLogger(__name__)
# Server default is 16383, but 4000 is the practical limit for readable messages.
MAX_POST_LENGTH = 4000
# Channel type codes returned by the Mattermost API ("P" private → treat as group).
_CHANNEL_TYPE_MAP = {"D": "dm", "G": "group", "P": "group", "O": "channel"}
_MATTERMOST_DISABLE_MENTIONS_PROPS = {"disable_mentions": True}
# Reconnect parameters (exponential backoff).
_RECONNECT_BASE_DELAY = 2.0
_RECONNECT_MAX_DELAY = 60.0
_RECONNECT_JITTER = 0.2
def _with_mentions_disabled(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Return a post payload that prevents Mattermost from firing mentions."""
props = payload.get("props")
if isinstance(props, dict):
payload["props"] = {**props, **_MATTERMOST_DISABLE_MENTIONS_PROPS}
else:
payload["props"] = dict(_MATTERMOST_DISABLE_MENTIONS_PROPS)
return payload
def _channel_id_set(raw: Any) -> set:
"""Parse a list or comma-separated string of channel IDs into a stripped set."""
items = raw if isinstance(raw, list) else str(raw).split(",")
return {str(c).strip() for c in items if str(c).strip()}
def _csv(value: Any) -> str:
return ",".join(str(v) for v in value) if isinstance(value, list) else str(value)
def _post_result(data: Dict[str, Any], error: str) -> SendResult:
if not data or "id" not in data:
return SendResult(success=False, error=error)
return SendResult(success=True, message_id=data["id"])
def check_mattermost_requirements() -> bool:
"""Return True if the Mattermost adapter runtime dependency is available."""
try:
import aiohttp # noqa: F401
return True
except ImportError:
logger.warning("Mattermost: aiohttp not installed")
return False
def validate_mattermost_config(config: PlatformConfig) -> bool:
"""Return True when Mattermost has enough config to connect."""
extra = getattr(config, "extra", {}) or {}
token = (getattr(config, "token", None) or _get_scoped_secret("MATTERMOST_TOKEN", "")).strip()
url = (extra.get("url", "") or _get_scoped_secret("MATTERMOST_URL", "")).strip()
if not token:
logger.debug("Mattermost: MATTERMOST_TOKEN not set")
return False
if not url:
logger.warning("Mattermost: MATTERMOST_URL not set")
return False
return True
class MattermostAdapter(BasePlatformAdapter):
"""Gateway adapter for Mattermost (self-hosted or cloud)."""
splits_long_messages = True # send() chunks via truncate_message(MAX_POST_LENGTH)
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.MATTERMOST)
self._base_url: str = (config.extra.get("url", "") or _get_scoped_secret("MATTERMOST_URL", "")).rstrip("/")
self._token: str = config.token or _get_scoped_secret("MATTERMOST_TOKEN", "")
self._bot_user_id: str = ""
self._bot_username: str = ""
self._session: Any = None # aiohttp.ClientSession
self._ws: Any = None # aiohttp.ClientWebSocketResponse
self._ws_task: Optional[asyncio.Task] = None
self._reconnect_task: Optional[asyncio.Task] = None
self._closing = False
# Reply mode: "thread" to nest replies, "off" for flat messages.
self._reply_mode: str = (
config.extra.get("reply_mode", "") or _get_scoped_secret("MATTERMOST_REPLY_MODE", "off")
).lower()
self._last_post_status: Optional[int] = None
self._last_post_error: str = ""
self._dedup = MessageDeduplicator()
# --- HTTP helpers ---
def _headers(self) -> Dict[str, str]:
return {"Authorization": f"Bearer {self._token}", "Content-Type": "application/json"}
async def _api(self, method: str, path: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""{method} /api/v4/{path}; POST also records _last_post_status/_last_post_error."""
import aiohttp
if ".." in path:
logger.error("MM API path traversal blocked: %s", path)
return {}
url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
is_post = method == "POST"
if is_post:
self._last_post_status = None
self._last_post_error = ""
kwargs: Dict[str, Any] = {"headers": self._headers()}
if payload is not None:
kwargs["json"] = payload
if method != "PUT": # PUT relies on the session default timeout
kwargs["timeout"] = aiohttp.ClientTimeout(total=30)
try:
async with getattr(self._session, method.lower())(url, **kwargs) as resp:
if is_post:
self._last_post_status = resp.status
if resp.status >= 400:
body = await resp.text()
if is_post:
self._last_post_error = body or ""
logger.error("MM API %s %s → %s: %s", method, path, resp.status, body[:200])
return {}
return await resp.json()
except aiohttp.ClientError as exc:
if is_post:
self._last_post_error = str(exc)
logger.error("MM API %s %s network error: %s", method, path, exc)
return {}
async def _api_get(self, path: str) -> Dict[str, Any]:
return await self._api("GET", path)
async def _api_post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
return await self._api("POST", path, payload)
async def _thread_root_for_send(self, reply_to: Optional[str], metadata: Optional[Dict[str, Any]]) -> Optional[str]:
"""Resolve the Mattermost root_id from reply_to or metadata."""
if self._reply_mode != "thread":
return None
candidate = reply_to
if not candidate and isinstance(metadata, dict):
candidate = metadata.get("thread_id") or metadata.get("root_id")
if not candidate:
return None
return await self._resolve_root_id(str(candidate))
def _last_post_failure_is_broken_thread_root(self) -> bool:
"""Return True only for clear invalid/missing Mattermost thread roots."""
if self._last_post_status not in {400, 404}:
return False
body = (self._last_post_error or "").lower()
if not body:
return False
rootish = any(marker in body for marker in ("root_id", "rootid", "root id", "thread", "post"))
broken = any(marker in body for marker in ("invalid", "not found", "does not exist", "missing"))
return rootish and broken
async def _post_preserving_thread(
self, chat_id: str, payload: Dict[str, Any], metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
"""Post once, optionally falling back flat for final notify content."""
data = await self._api_post("posts", payload)
if data or "root_id" not in payload:
return data
if not (isinstance(metadata, dict) and metadata.get("notify")):
return data
if not self._last_post_failure_is_broken_thread_root():
return data
flat_payload = dict(payload)
flat_payload.pop("root_id", None)
original = str(flat_payload.get("message") or "")
flat_payload["message"] = (
"⚠️ Mattermost thread delivery failed; posting final reply in channel.\n\n" + original
).strip()
logger.warning("Mattermost: falling back to flat channel delivery for notify-worthy post in %s", chat_id)
return await self._api_post("posts", flat_payload)
async def _post_message(
self, chat_id: str, message: str, reply_to: Optional[str], metadata: Optional[Dict[str, Any]],
file_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Build a mentions-disabled post payload (+ optional root_id) and post it."""
base: Dict[str, Any] = {"channel_id": chat_id, "message": message}
if file_ids is not None:
base["file_ids"] = file_ids
payload = _with_mentions_disabled(base)
resolved_root = await self._thread_root_for_send(reply_to, metadata)
if resolved_root:
payload["root_id"] = resolved_root
return await self._post_preserving_thread(chat_id, payload, metadata)
async def _upload_file(
self, channel_id: str, file_data: bytes, filename: str, content_type: str = "application/octet-stream"
) -> Optional[str]:
"""Upload a file and return its file ID, or None on failure."""
import aiohttp
url = f"{self._base_url}/api/v4/files"
form = aiohttp.FormData()
form.add_field("channel_id", channel_id)
form.add_field("files", file_data, filename=filename, content_type=content_type)
headers = {"Authorization": f"Bearer {self._token}"}
async with self._session.post(url, headers=headers, data=form, timeout=aiohttp.ClientTimeout(total=60)) as resp:
if resp.status >= 400:
body = await resp.text()
logger.error("MM file upload → %s: %s", resp.status, body[:200])
return None
data = await resp.json()
infos = data.get("file_infos", [])
return infos[0]["id"] if infos else None
# --- Required overrides ---
async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Connect to Mattermost and start the WebSocket listener."""
import aiohttp
if not self._base_url or not self._token:
logger.error("Mattermost: URL or token not configured")
return False
self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), trust_env=gateway_trust_env())
self._closing = False
me = await self._api_get("users/me")
if not me or "id" not in me:
logger.error("Mattermost: failed to authenticate — check MATTERMOST_TOKEN and MATTERMOST_URL")
await self._session.close()
return False
self._bot_user_id = me["id"]
self._bot_username = me.get("username", "")
logger.info("Mattermost: authenticated as @%s (%s) on %s", self._bot_username, self._bot_user_id, self._base_url)
self._ws_task = asyncio.create_task(self._ws_loop())
self._mark_connected()
# Plugin-registered native handlers (ctx.register_platform_handler).
self._wire_plugin_handlers(None)
return True
async def disconnect(self) -> None:
"""Disconnect from Mattermost."""
self._closing = True
if self._ws_task and not self._ws_task.done():
self._ws_task.cancel()
try:
await self._ws_task
except (asyncio.CancelledError, Exception):
pass
if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
if self._ws:
await self._ws.close()
self._ws = None
if self._session and not self._session.closed:
await self._session.close()
logger.info("Mattermost: disconnected")
async def _resolve_root_id(self, post_id: str) -> str:
"""Resolve a post_id to its thread root_id.
Mattermost requires root_id to be the *root* post; using a reply's own
ID causes "Invalid RootId parameter" errors.
"""
if not post_id:
return post_id
data = await self._api_get(f"posts/{post_id}")
if data and data.get("root_id"):
return data["root_id"]
return post_id
async def send(
self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None
) -> SendResult:
"""Send a message (or multiple chunks) to a channel."""
if not content:
return SendResult(success=True)
chunks = self.truncate_message(self.format_message(content), MAX_POST_LENGTH)
last_id = None
for chunk in chunks:
# Thread support: reply_to or metadata["thread_id"] is the root post ID.
data = await self._post_message(chat_id, chunk, reply_to, metadata)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to create post")
last_id = data["id"]
return SendResult(success=True, message_id=last_id)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Return channel name and type."""
data = await self._api_get(f"channels/{chat_id}")
if not data:
return {"name": chat_id, "type": "channel"}
ch_type = _CHANNEL_TYPE_MAP.get(data.get("type", "O"), "channel")
display_name = data.get("display_name") or data.get("name") or chat_id
return {"name": display_name, "type": ch_type}
# --- Optional overrides ---
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
"""Send a typing indicator."""
await self._api_post(f"users/{self._bot_user_id}/typing", {"channel_id": chat_id})
async def edit_message(
self, chat_id: str, message_id: str, content: str, *, finalize: bool = False
) -> SendResult:
"""Edit an existing post."""
formatted = self.format_message(content)
data = await self._api("PUT", f"posts/{message_id}/patch", _with_mentions_disabled({"message": formatted}))
return _post_result(data, "Failed to edit post")
async def send_image(
self, chat_id: str, image_url: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Download an image and upload it as a file attachment."""
return await self._send_url_as_file(chat_id, image_url, caption, reply_to, "image", metadata)
async def send_image_file(
self, chat_id: str, image_path: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload a local image file."""
return await self._send_local_file(chat_id, image_path, caption, reply_to, metadata=metadata)
async def send_document(
self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload a local file as a document."""
return await self._send_local_file(chat_id, file_path, caption, reply_to, file_name, metadata)
async def send_voice(
self, chat_id: str, audio_path: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload an audio file."""
return await self._send_local_file(chat_id, audio_path, caption, reply_to, metadata=metadata)
async def send_video(
self, chat_id: str, video_path: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload a video file."""
return await self._send_local_file(chat_id, video_path, caption, reply_to, metadata=metadata)
def format_message(self, content: str) -> str:
"""Mattermost renders standard Markdown; reduce ![alt](url) to the bare URL (inline preview)."""
return re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", content)
# --- File helpers ---
async def _send_url_as_file(
self, chat_id: str, url: str, caption: Optional[str], reply_to: Optional[str],
kind: str = "file", metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Download a URL and upload it as a file attachment (text fallback with the URL on failure)."""
from tools.url_safety import is_safe_url
async def fallback() -> SendResult:
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
if not is_safe_url(url):
logger.warning("Mattermost: blocked unsafe URL (SSRF protection)")
return await fallback()
import aiohttp
file_data = None
ct = "application/octet-stream"
fname = url.rsplit("/", 1)[-1].split("?")[0] or f"{kind}.png"
for attempt in range(3):
try:
async with self._session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if (resp.status >= 500 or resp.status == 429) and attempt < 2:
logger.debug("Mattermost download retry %d/2 for %s (status %d)",
attempt + 1, url[:80], resp.status)
await asyncio.sleep(1.5 * (attempt + 1))
continue
if resp.status >= 400:
return await fallback()
file_data = await resp.read()
ct = resp.content_type or "application/octet-stream"
break
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
if attempt < 2:
await asyncio.sleep(1.5 * (attempt + 1))
continue
logger.warning("Mattermost: failed to download %s after %d attempts: %s", url, attempt + 1, exc)
return await fallback()
if file_data is None:
logger.warning("Mattermost: download returned no data for %s", url)
return await fallback()
file_id = await self._upload_file(chat_id, file_data, fname, ct)
if not file_id:
return await fallback()
data = await self._post_message(chat_id, caption or "", reply_to, metadata, [file_id])
return _post_result(data, "Failed to post with file")
async def _send_local_file(
self, chat_id: str, file_path: str, caption: Optional[str], reply_to: Optional[str],
file_name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload a local file and attach it to a post."""
import mimetypes
p = Path(file_path)
if not p.exists():
logger.warning("Mattermost: local file not found, skipping: %s", file_path)
return SendResult(success=True, message_id=None)
fname = file_name or p.name
ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
file_id = await self._upload_file(chat_id, p.read_bytes(), fname, ct)
if not file_id:
return SendResult(success=False, error="File upload failed")
data = await self._post_message(chat_id, caption or "", reply_to, metadata, [file_id])
return _post_result(data, "Failed to post with file")
async def _load_batch_image(self, image_url: str, index: int) -> Optional[Tuple[bytes, str, str]]:
"""Read a file:// or remote image for a batch post → (data, filename, content_type), or None to skip."""
import mimetypes
import aiohttp
from urllib.parse import unquote as _unquote
if image_url.startswith("file://"):
local_path = _unquote(image_url[7:])
p = Path(local_path)
if not p.exists():
logger.warning("Mattermost: skipping missing image %s", local_path)
return None
return p.read_bytes(), p.name, mimetypes.guess_type(p.name)[0] or "image/png"
from tools.url_safety import is_safe_url
if not is_safe_url(image_url):
logger.warning("Mattermost: blocked unsafe image URL in batch")
return None
try:
async with self._session.get(image_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status >= 400:
logger.warning("Mattermost: failed to download image (HTTP %d): %s", resp.status, image_url[:80])
return None
file_data = await resp.read()
ct = resp.content_type or "image/png"
except Exception as dl_err:
logger.warning("Mattermost: download failed for %s: %s", image_url[:80], dl_err)
return None
fname = image_url.rsplit("/", 1)[-1].split("?")[0] or f"image_{index}.png"
return file_data, fname, ct
async def send_multiple_images(
self, chat_id: str, images: List[Tuple[str, str]], metadata: Optional[Dict[str, Any]] = None,
human_delay: float = 0.0,
) -> None:
"""Send a batch of images as one post with multiple attachments.
Mattermost caps ``file_ids`` at 5 per post and uploads one file at a time,
so batches are chunked at 5; each chunk falls back to the base per-image
loop on failure.
"""
if not images:
return
CHUNK = 5 # Mattermost post file_ids cap
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
for chunk_idx, chunk in enumerate(chunks):
if human_delay > 0 and chunk_idx > 0:
await asyncio.sleep(human_delay)
file_ids: List[str] = []
caption_parts: List[str] = []
try:
for image_url, alt_text in chunk:
if alt_text:
caption_parts.append(alt_text)
loaded = await self._load_batch_image(image_url, len(file_ids))
if loaded is None:
continue
fid = await self._upload_file(chat_id, *loaded)
if fid:
file_ids.append(fid)
if not file_ids:
continue
logger.info("Mattermost: sending %d image(s) as single post (chunk %d/%d)",
len(file_ids), chunk_idx + 1, len(chunks))
data = await self._post_message(chat_id, "\n".join(caption_parts), None, metadata, file_ids)
if not data or "id" not in data:
logger.warning("Mattermost: multi-image post failed, falling back")
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
except Exception as e:
logger.warning("Mattermost: multi-image send failed (chunk %d/%d), falling back: %s",
chunk_idx + 1, len(chunks), e, exc_info=True)
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
# --- WebSocket ---
async def _ws_loop(self) -> None:
"""Connect to the WebSocket and listen for events, reconnecting on failure."""
delay = _RECONNECT_BASE_DELAY
while not self._closing:
try:
await self._ws_connect_and_listen()
delay = _RECONNECT_BASE_DELAY # clean disconnect — reset backoff
except asyncio.CancelledError:
return
except Exception as exc:
if self._closing:
return
# Permanent auth/permission failure: escalate via the fatal-error hook
# (a bare return would leave is_connected() reporting healthy with a dead
# listener). Type-based only — substring matching on "401" misclassified
# transient errors.
import aiohttp
if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in {401, 403}:
logger.error("Mattermost WS auth failed (HTTP %d) — stopping reconnect", exc.status)
self._set_fatal_error(
"mattermost_auth_error",
f"Mattermost WebSocket authentication rejected (HTTP {exc.status}). The bot token is "
"invalid, revoked, or lacks permission — check MATTERMOST_TOKEN and the bot account in "
"the System Console.",
retryable=False,
)
await self._notify_fatal_error()
return
logger.warning("Mattermost WS error: %s — reconnecting in %.0fs", exc, delay)
if self._closing:
return
import random
jitter = delay * _RECONNECT_JITTER * random.random()
await asyncio.sleep(delay + jitter)
delay = min(delay * 2, _RECONNECT_MAX_DELAY)
async def _ws_connect_and_listen(self) -> None:
"""Single WebSocket session: connect, authenticate, process events."""
ws_url = re.sub(r"^http", "ws", self._base_url) + "/api/v4/websocket" # https→wss, http→ws
logger.info("Mattermost: connecting to %s", ws_url)
self._ws = await self._session.ws_connect(ws_url, heartbeat=30.0)
await self._ws.send_json({"seq": 1, "action": "authentication_challenge", "data": {"token": self._token}})
logger.info("Mattermost: WebSocket connected and authenticated")
async for raw_msg in self._ws:
if self._closing:
return
if raw_msg.type in {raw_msg.type.TEXT, raw_msg.type.BINARY}:
try:
event = json.loads(raw_msg.data)
except (json.JSONDecodeError, TypeError):
continue
await self._handle_ws_event(event)
elif raw_msg.type in {raw_msg.type.ERROR, raw_msg.type.CLOSE, raw_msg.type.CLOSING, raw_msg.type.CLOSED}:
logger.info("Mattermost: WebSocket closed (%s)", raw_msg.type)
break
def _extra_or_env(self, key: str, env: str, default: str = "") -> Any:
"""config.yaml ``mattermost.<key>`` (PlatformConfig.extra) first, env var fallback."""
raw = self.config.extra.get(key) if self.config.extra else None
if raw is None:
raw = _get_scoped_secret(env, default)
return raw
def _apply_channel_gating(self, channel_id: str, message_text: str) -> Optional[str]:
"""Mention-gate a non-DM post; return the cleaned text, or None to ignore it.
allowed_channels is a whitelist checked first (even @mentions are ignored
elsewhere); require_mention (default true) is bypassed in free_response_channels.
"""
allowed_channels = _channel_id_set(self._extra_or_env("allowed_channels", "MATTERMOST_ALLOWED_CHANNELS"))
if allowed_channels and channel_id not in allowed_channels:
logger.debug("Mattermost: ignoring message in non-allowed channel: %s", channel_id)
return None
require_mention_raw = self._extra_or_env("require_mention", "MATTERMOST_REQUIRE_MENTION", "true")
require_mention = str(require_mention_raw).lower() not in {"false", "0", "no"}
free_channels = _channel_id_set(
self._extra_or_env("free_response_channels", "MATTERMOST_FREE_RESPONSE_CHANNELS")
)
mention_patterns = [f"@{self._bot_username}", f"@{self._bot_user_id}"]
has_mention = any(pattern.lower() in message_text.lower() for pattern in mention_patterns)
if require_mention and channel_id not in free_channels and not has_mention:
logger.debug("Mattermost: skipping non-DM message without @mention (channel=%s)", channel_id)
return None
if has_mention: # strip the @mention so the agent sees clean input
for pattern in mention_patterns:
message_text = re.sub(re.escape(pattern), "", message_text, flags=re.IGNORECASE).strip()
return message_text
async def _download_attachments(self, file_ids: List[str]) -> Tuple[List[str], List[str]]:
"""Download attachments now (URLs need auth headers downstream tools lack) → (paths, mime types)."""
media_urls: List[str] = []
media_types: List[str] = []
for fid in file_ids:
try:
file_info = await self._api_get(f"files/{fid}/info")
fname = file_info.get("name", f"file_{fid}")
ext = Path(fname).suffix or ""
mime = file_info.get("mime_type", "application/octet-stream")
import aiohttp
async with self._session.get(
f"{self._base_url}/api/v4/files/{fid}",
headers={"Authorization": f"Bearer {self._token}"},
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status >= 400:
logger.warning("Mattermost: failed to download file %s: HTTP %s", fid, resp.status)
continue
file_data = await resp.read()
from gateway.platforms.base import (
cache_audio_from_bytes, cache_document_from_bytes, cache_image_from_bytes,
)
if mime.startswith("image/"):
local_path = cache_image_from_bytes(file_data, ext or ".png")
elif mime.startswith("audio/"):
local_path = cache_audio_from_bytes(file_data, ext or ".ogg")
else:
local_path = cache_document_from_bytes(file_data, fname)
media_urls.append(local_path)
media_types.append(mime)
except Exception as exc:
logger.warning("Mattermost: error downloading file %s: %s", fid, exc)
return media_urls, media_types
async def _handle_ws_event(self, event: Dict[str, Any]) -> None:
"""Process a single WebSocket event."""
if event.get("event") != "posted":
return
data = event.get("data", {})
raw_post_str = data.get("post")
if not raw_post_str:
return
try:
post = json.loads(raw_post_str)
except (json.JSONDecodeError, TypeError):
return
# Ignore own messages and system posts.
if post.get("user_id") == self._bot_user_id or post.get("type"):
return
post_id = post.get("id", "")
if self._dedup.is_duplicate(post_id):
return
channel_id = post.get("channel_id", "")
channel_type_raw = data.get("channel_type", "O")
chat_type = _CHANNEL_TYPE_MAP.get(channel_type_raw, "channel")
message_text = post.get("message", "")
# DMs need no gating; channels are mention-gated.
if channel_type_raw != "D":
message_text = self._apply_channel_gating(channel_id, message_text)
if message_text is None:
return
sender_id = post.get("user_id", "")
sender_name = data.get("sender_name", "").lstrip("@") or sender_id
# Thread support: replies use root_id; in thread mode a top-level channel
# post is itself a valid root for progress.
thread_id = post.get("root_id") or None
if not thread_id and self._reply_mode == "thread" and channel_type_raw != "D" and post_id:
thread_id = post_id
msg_type = MessageType.TEXT
if message_text[:1].isspace() and message_text.lstrip().startswith("/"):
message_text = message_text.lstrip()
if message_text.startswith("/"):
msg_type = MessageType.COMMAND
media_urls, media_types = await self._download_attachments(post.get("file_ids") or [])
if media_types and msg_type == MessageType.TEXT:
if any(m.startswith("image/") for m in media_types):
msg_type = MessageType.PHOTO
elif any(m.startswith("audio/") for m in media_types):
msg_type = MessageType.VOICE
else:
msg_type = MessageType.DOCUMENT
source = self.build_source(
chat_id=channel_id, chat_type=chat_type, user_id=sender_id, user_name=sender_name,
thread_id=thread_id, message_id=post_id,
)
from gateway.platforms.base import resolve_channel_prompt
msg_event = MessageEvent(
text=message_text, message_type=msg_type, source=source, raw_message=post, message_id=post_id,
media_urls=media_urls if media_urls else None, media_types=media_types if media_types else None,
channel_prompt=resolve_channel_prompt(self.config.extra, channel_id, None),
)
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 out-of-process (cron). Token/URL come from ``pconfig`` with env fallback.
``media_files`` are uploaded via ``POST /files`` and attached by file_id;
``thread_id`` becomes ``root_id``. ``force_document`` is accepted for
signature parity but unused (Mattermost stores every upload as an attachment).
"""
try:
import aiohttp
except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
base_url = ((getattr(pconfig, "extra", {}) or {}).get("url") or _get_scoped_secret("MATTERMOST_URL", "")).rstrip("/")
token = (getattr(pconfig, "token", None) or _get_scoped_secret("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}"}
try:
# One ClientSession (with proxy) covers the optional uploads + final post.
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(resolve_proxy_url(platform_env_var="MATTERMOST_PROXY"))
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session:
file_ids: List[str] = []
for media in media_files or []:
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()
form.add_field("channel_id", chat_id) # required so the server can attribute the upload
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 ({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"])
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}): {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 (URL + token, allowlist, home channel)."""
from hermes_cli.config import get_env_value, remove_env_value, save_env_value
from hermes_cli.cli_output import prompt, prompt_yes_no, print_header, print_info, print_success
print_header("Mattermost")
if get_env_value("MATTERMOST_TOKEN"):
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)").strip()
if home_channel:
save_env_value("MATTERMOST_HOME_CHANNEL", home_channel)
elif remove_env_value("MATTERMOST_HOME_CHANNEL"):
print_info("Home channel cleared.")
print_info(" Open config in your editor: hermes config edit")
# --- YAML → env config bridge (apply_yaml_config_fn) ---
def _apply_yaml_config(yaml_cfg: dict, mattermost_cfg: dict) -> dict | None:
"""Translate ``config.yaml`` ``mattermost:`` keys into env vars + ``PlatformConfig.extra``.
Env vars win over YAML (each write is guarded by ``not os.getenv``). Under a
multiplexed secondary profile the env write is skipped entirely (it would leak
into every other profile via process-global ``os.environ``); the values are
returned instead so the caller seeds this profile's ``PlatformConfig.extra``,
which the adapter's read sites check first.
"""
skip_env_bridge = _profile_scoped_config_load()
seeded: dict = {}
def bridge(key: str, env: str, value: Any, to_env) -> None:
seeded[key] = value
if not skip_env_bridge and not os.getenv(env):
os.environ[env] = to_env(value)
if "require_mention" in mattermost_cfg:
bridge("require_mention", "MATTERMOST_REQUIRE_MENTION", mattermost_cfg["require_mention"], lambda v: str(v).lower())
for key, env in (
("free_response_channels", "MATTERMOST_FREE_RESPONSE_CHANNELS"),
("allowed_channels", "MATTERMOST_ALLOWED_CHANNELS"), # whitelist: bot ONLY responds in these
):
value = mattermost_cfg.get(key)
if value is not None:
bridge(key, env, value, _csv)
return seeded or None
def _is_connected(config) -> bool:
"""Connected when BOTH MATTERMOST_TOKEN and MATTERMOST_URL are set.
Looks up ``hermes_cli.gateway.get_env_value`` at call time so tests that patch
``gateway_mod.get_env_value`` can suppress ambient env vars.
"""
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,
validate_config=validate_mattermost_config,
is_connected=_is_connected,
required_env=["MATTERMOST_URL", "MATTERMOST_TOKEN"],
install_hint="pip install aiohttp",
setup_fn=interactive_setup,
# YAML→env bridge for require_mention / free_response_channels / allowed_channels.
apply_yaml_config_fn=_apply_yaml_config,
allowed_users_env="MATTERMOST_ALLOWED_USERS",
allow_all_env="MATTERMOST_ALLOW_ALL_USERS",
cron_deliver_env_var="MATTERMOST_HOME_CHANNEL",
# Out-of-process cron delivery; without it `deliver=mattermost` fails with "No live adapter".
standalone_sender_fn=_standalone_send,
max_message_length=MAX_POST_LENGTH,
emoji="💬",
allow_update_command=True,
)