Files
nanobot/nanobot/channels/mattermost.py
T
Kenneth ZhaoandXubin Ren cc70a2a79f fix(mattermost): fix file download paths and mobile streaming
- Use Mattermost file metadata/download API paths that work with current servers
- Buffer streamed content and post the final reply once for mobile clients that ignore post edits
- Keep attachment upload behavior on the first split chunk
2026-07-06 12:14:57 +08:00

650 lines
24 KiB
Python

"""Mattermost channel implementation using WebSocket + REST API."""
from __future__ import annotations
import asyncio
import json
import re
from pathlib import Path
from typing import Any
import httpx
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config_base import Base
from nanobot.pairing import PAIRING_CODE_META_KEY, format_pairing_reply, generate_code
from nanobot.utils.helpers import safe_filename, split_message
MATTERMOST_MAX_MESSAGE_LEN = 16383
MATTERMOST_WS_RECONNECT_BASE_DELAY = 1
MATTERMOST_WS_RECONNECT_MAX_DELAY = 30
_CHANNEL_TYPES = {
"O": "public",
"P": "private",
"D": "dm",
"G": "group",
}
class MattermostDMConfig(Base):
"""Mattermost DM policy configuration."""
enabled: bool = True
policy: str = "open"
allow_from: list[str] = Field(default_factory=list)
class MattermostConfig(Base):
"""Mattermost channel configuration."""
enabled: bool = False
server_url: str = ""
token: str = ""
team_id: str = ""
allow_from_match_mode: str = "id"
allow_from: list[str] = Field(default_factory=list)
group_policy: str = "mention"
group_allow_from: list[str] = Field(default_factory=list)
reply_in_thread: bool = True
include_thread_context: bool = True
thread_context_limit: int = 20
streaming: bool = True
streaming_max_chars: int = 16000
react_emoji: str = "eyes"
done_emoji: str = "white_check_mark"
send_progress: bool = True
send_tool_hints: bool = False
dm: MattermostDMConfig = Field(default_factory=MattermostDMConfig)
def _server_url_to_ws_url(server_url: str) -> str:
if server_url.startswith("https://"):
return server_url.replace("https://", "wss://", 1) + "/api/v4/websocket"
if server_url.startswith("http://"):
return server_url.replace("http://", "ws://", 1) + "/api/v4/websocket"
return server_url + "/api/v4/websocket"
class MattermostChannel(BaseChannel):
"""Mattermost channel using WebSocket + REST API."""
name = "mattermost"
display_name = "Mattermost"
@classmethod
def default_config(cls) -> dict[str, Any]:
return MattermostConfig().model_dump(by_alias=True)
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = MattermostConfig.model_validate(config)
super().__init__(config, bus)
self.config: MattermostConfig = config
self._server_url = config.server_url.rstrip("/")
self._ws_url = _server_url_to_ws_url(self._server_url)
self._http_client: httpx.AsyncClient | None = None
self._ws_task: asyncio.Task | None = None
self._self_id: str | None = None
self._self_username: str | None = None
self._self_email: str | None = None
self._usernames: dict[str, str] = {}
self._user_emails: dict[str, str] = {}
self._channel_types: dict[str, str] = {}
self._stream_posts: dict[str, str] = {}
self._stream_buffers: dict[str, str] = {}
self._stream_last_content: dict[str, str] = {}
self._stream_committed: dict[str, str] = {}
self._stream_root_ids: dict[str, str] = {}
self._thread_context_attempted: set[str] = set()
# Lifecycle ----------------------------------------------------------------
async def start(self) -> None:
if not self.config.server_url or not self.config.token:
self.logger.error("serverUrl and token must be configured")
return
if self._http_client is None:
self._http_client = httpx.AsyncClient(
base_url=self._server_url,
headers={"Authorization": f"Bearer {self.config.token}"},
timeout=30.0,
)
try:
resp = await self._http_client.get("/api/v4/users/me")
resp.raise_for_status()
me = resp.json()
self._self_id = me.get("id")
self._self_username = me.get("username")
self._self_email = me.get("email", "")
self.logger.info("bot @{} connected", self._self_username)
except Exception as e:
self.logger.error("Failed to identify bot user: {}", e)
await self._cleanup_http()
return
self._running = True
self._ws_task = asyncio.create_task(self._ws_listen_loop())
async def stop(self) -> None:
self._running = False
if self._ws_task:
self._ws_task.cancel()
try:
await self._ws_task
except asyncio.CancelledError:
pass
self._ws_task = None
await self._cleanup_http()
async def _cleanup_http(self) -> None:
if self._http_client:
await self._http_client.aclose()
self._http_client = None
# WebSocket ----------------------------------------------------------------
async def _ws_listen_loop(self) -> None:
import websockets
delay = MATTERMOST_WS_RECONNECT_BASE_DELAY
while self._running:
try:
async with websockets.connect(
self._ws_url,
additional_headers={"Authorization": f"Bearer {self.config.token}"},
ping_interval=20,
ping_timeout=10,
) as ws:
self.logger.debug("websocket connected")
delay = MATTERMOST_WS_RECONNECT_BASE_DELAY
async for raw in ws:
await self._handle_ws_message(json.loads(raw))
except asyncio.CancelledError:
break
except Exception as e:
if not self._running:
break
self.logger.warning("websocket error: {} (reconnect in {}s)", e, delay)
await asyncio.sleep(delay)
delay = min(delay * 2, MATTERMOST_WS_RECONNECT_MAX_DELAY)
async def _handle_ws_message(self, msg: dict[str, Any]) -> None:
event = msg.get("event", "")
if event == "posted":
await self._handle_posted_event(msg)
elif event == "action":
await self._handle_action_event(msg)
elif event == "post_deleted":
await self._handle_post_deleted_event(msg)
# Event: posted ------------------------------------------------------------
async def _handle_posted_event(self, msg: dict[str, Any]) -> None:
data = msg.get("data", {})
broadcast = msg.get("broadcast", {})
raw_post = data.get("post", "{}")
try:
post = json.loads(raw_post) if isinstance(raw_post, str) else raw_post
except json.JSONDecodeError:
self.logger.warning("failed to parse post json")
return
sender_id = post.get("user_id", "")
channel_id = post.get("channel_id", "")
message_text = post.get("message", "")
root_id = post.get("root_id", "") or ""
post_id = post.get("id", "")
file_ids: list[str] = post.get("file_ids", [])
if self._self_id and sender_id == self._self_id:
return
if not sender_id or not channel_id:
return
channel_type_code = data.get("channel_type", "")
channel_type = _CHANNEL_TYPES.get(channel_type_code, "public")
is_dm = channel_type == "dm"
team_id = broadcast.get("team_id", "")
if self.config.team_id and team_id and team_id != self.config.team_id:
if not is_dm:
return
if not await self._is_allowed(sender_id, channel_id, channel_type):
if is_dm and self.config.dm.enabled:
code = generate_code(self.name, str(sender_id))
await self.send(
OutboundMessage(
channel=self.name,
chat_id=str(channel_id),
content=format_pairing_reply(code),
metadata={PAIRING_CODE_META_KEY: code},
)
)
self.logger.info(
"Sent pairing code {} to sender {} in chat {}",
code, sender_id, channel_id,
)
return
if not is_dm and not self._should_respond_in_channel(message_text, channel_id):
return
message_text = self._strip_bot_mention(message_text)
thread_ts = root_id if root_id else None
if self.config.reply_in_thread and not thread_ts and not is_dm:
thread_ts = post_id
session_key = (
f"mattermost:{channel_id}:{thread_ts}" if thread_ts and root_id else None
)
try:
await self._add_reaction(channel_id, post_id, self.config.react_emoji)
except Exception:
self.logger.debug("add reaction failed")
media_paths: list[str] = []
for fid in file_ids:
path = await self._download_file(fid)
if path:
media_paths.append(path)
content = message_text
if root_id and self.config.include_thread_context:
content = await self._with_thread_context(
content, channel_id=channel_id, root_id=root_id,
)
mm_meta: dict[str, Any] = {
"post_id": post_id,
"root_id": root_id,
"channel_type": channel_type,
}
if thread_ts:
mm_meta["thread_ts"] = thread_ts
await self._handle_message(
sender_id=sender_id,
chat_id=channel_id,
content=content,
media=media_paths,
metadata={
"mattermost": mm_meta,
"message_id": post_id,
},
session_key=session_key,
is_dm=is_dm,
)
# Event: action ------------------------------------------------------------
async def _handle_action_event(self, msg: dict[str, Any]) -> None:
data = msg.get("data", {})
sender_id = data.get("user_id", "")
channel_id = data.get("channel_id", "")
context = data.get("context", {}) or {}
value = context.get("selected_option", "")
if not sender_id or not channel_id or not value:
return
channel_type = await self.resolve_channel_type(channel_id)
if not await self._is_allowed(sender_id, channel_id, channel_type):
return
await self._handle_message(
sender_id=sender_id,
chat_id=channel_id,
content=value,
metadata={"mattermost": {"channel_type": channel_type, "is_action": True}},
)
# Event: post_deleted ------------------------------------------------------
async def _handle_post_deleted_event(self, msg: dict[str, Any]) -> None:
data = msg.get("data", {})
raw_post = data.get("post", "{}")
try:
post = json.loads(raw_post) if isinstance(raw_post, str) else raw_post
except json.JSONDecodeError:
return
post_id = post.get("id", "")
if not post_id:
return
to_remove = [sid for sid, pid in self._stream_posts.items() if pid == post_id]
for sid in to_remove:
self._stream_posts.pop(sid, None)
self._stream_buffers.pop(sid, None)
self._stream_last_content.pop(sid, None)
self._stream_committed.pop(sid, None)
# Permission / policy ------------------------------------------------------
def is_allowed(self, sender_id: str) -> bool:
return True
async def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
if channel_type == "dm":
if not self.config.dm.enabled:
return False
if self.config.dm.policy == "allowlist":
return await self._match_sender(sender_id, self.config.dm.allow_from)
return True
if self.config.group_policy == "allowlist":
return chat_id in self.config.group_allow_from
return True
def _should_respond_in_channel(self, text: str, chat_id: str) -> bool:
if self.config.group_policy == "open":
return True
if self.config.group_policy == "mention":
return self._is_mentioned(text)
if self.config.group_policy == "allowlist":
return chat_id in self.config.group_allow_from
return False
_BOT_MENTION_RE: re.Pattern | None = None
def _is_mentioned(self, text: str) -> bool:
if not self._self_username:
return False
if self._BOT_MENTION_RE is None:
pat = r"(?<![@\w])@" + re.escape(self._self_username) + r"(?![@\w])"
self._BOT_MENTION_RE = re.compile(pat)
return bool(self._BOT_MENTION_RE.search(text))
def _strip_bot_mention(self, text: str) -> str:
if not text or not self._self_username:
return text
return re.sub(rf"@{re.escape(self._self_username)}\s*", "", text).strip()
async def _match_sender(self, sender_id: str, allow_list: list[str]) -> bool:
if not allow_list:
return False
if "*" in allow_list:
return True
mode = self.config.allow_from_match_mode
if mode == "id":
return sender_id in allow_list
if mode == "username":
username = await self._resolve_username(sender_id)
return username in allow_list if username else False
if mode == "email":
email = await self._resolve_email(sender_id)
return email in allow_list if email else False
return False
async def _resolve_username(self, user_id: str) -> str | None:
if user_id in self._usernames:
return self._usernames[user_id]
try:
user = await self._api_get(f"/api/v4/users/{user_id}")
self._usernames[user_id] = user.get("username", "")
return self._usernames[user_id]
except Exception as e:
self.logger.warning("failed to resolve username for {}: {}", user_id, e)
return None
async def _resolve_email(self, user_id: str) -> str | None:
if user_id in self._user_emails:
return self._user_emails[user_id]
try:
user = await self._api_get(f"/api/v4/users/{user_id}")
self._user_emails[user_id] = user.get("email", "").lower()
return self._user_emails[user_id]
except Exception as e:
self.logger.warning("failed to resolve email for {}: {}", user_id, e)
return None
# Thread context -----------------------------------------------------------
async def _with_thread_context(self, text: str, *, channel_id: str, root_id: str) -> str:
key = f"{channel_id}:{root_id}"
if key in self._thread_context_attempted:
return text
self._thread_context_attempted.add(key)
try:
data = await self._api_get(
f"/api/v4/posts/{root_id}/thread?perPage={max(1, self.config.thread_context_limit)}",
)
except Exception as e:
self.logger.warning("thread context unavailable for {}: {}", key, e)
return text
posts = data.get("posts", {})
order = data.get("order", [])
if not order:
return text
lines: list[str] = []
for pid in order:
post = posts.get(pid, {})
if post.get("id") == root_id:
continue
if post.get("user_id") == self._self_id:
label = "bot"
else:
label = f"<{post.get('user_id', 'unknown')}>"
msg_text = (post.get("message", "") or "").strip()
if not msg_text:
continue
if len(msg_text) > 500:
msg_text = msg_text[:500] + "\u2026"
lines.append(f"- {label}: {msg_text}")
if not lines:
return text
return "Mattermost thread context before this mention:\n" + "\n".join(lines) + f"\n\nCurrent message:\n{text}"
# Send ---------------------------------------------------------------------
async def send(self, msg: OutboundMessage) -> None:
if not self._http_client:
self.logger.warning("client not initialized")
return
try:
chat_id = msg.chat_id
meta = msg.metadata or {}
mm_meta = meta.get("mattermost", {}) or {}
root_id = mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id")
file_ids: list[str] = []
for media_path in msg.media or []:
try:
fid = await self._upload_file(chat_id, media_path)
if fid:
file_ids.append(fid)
except Exception:
self.logger.exception("Failed to upload file {}", media_path)
if msg.content or file_ids:
text = msg.content or " "
chunks = split_message(text, MATTERMOST_MAX_MESSAGE_LEN)
for i, chunk in enumerate(chunks):
await self._create_post(
chat_id, chunk,
root_id=root_id if self.config.reply_in_thread else None,
file_ids=(file_ids if i == 0 else None) or None,
)
if not meta.get("_progress") and meta.get("message_id"):
try:
await self._remove_reaction(meta["message_id"], self.config.react_emoji)
except Exception:
self.logger.debug("remove reaction failed")
if self.config.done_emoji:
try:
await self._add_reaction(chat_id, meta["message_id"], self.config.done_emoji)
except Exception:
self.logger.debug("done reaction failed")
except Exception:
self.logger.exception("Error sending message")
raise
# Streaming -----------------------------------------------------------------
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
if not self._http_client:
return
meta = metadata or {}
stream_id = meta.get("_stream_id", chat_id)
if meta.get("_stream_end"):
stream_root = self._stream_root_ids.pop(stream_id, None)
self._stream_posts.pop(stream_id, None)
self._stream_last_content.pop(stream_id, None)
buf = self._stream_buffers.pop(stream_id, None)
final = self._stream_committed.pop(stream_id, None) or buf
if not meta.get("_progress") and meta.get("message_id"):
try:
await self._remove_reaction(meta["message_id"], self.config.react_emoji)
except Exception:
self.logger.debug("remove reaction failed")
if final and not meta.get("_progress") and not meta.get("_resuming"):
try:
mm_meta = (meta.get("mattermost", {}) or {}) if isinstance(meta.get("mattermost"), dict) else {}
root_id = mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id") or stream_root
chunks = split_message(final, MATTERMOST_MAX_MESSAGE_LEN)
for i, chunk in enumerate(chunks):
post = await self._create_post(
chat_id, chunk,
root_id=root_id if self.config.reply_in_thread else None,
)
if i == 0 and self.config.done_emoji:
try:
await self._add_reaction(chat_id, post["id"], self.config.done_emoji)
except Exception:
self.logger.debug("done reaction failed")
except Exception:
self.logger.exception("stream final post failed")
return
if not delta.strip():
return
committed = self._stream_committed.get(stream_id, "")
buf = committed + delta
self._stream_buffers[stream_id] = buf
self._stream_committed[stream_id] = buf
return
# API helpers ---------------------------------------------------------------
async def _api_get(self, path: str) -> dict[str, Any]:
resp = await self._http_client.get(path)
resp.raise_for_status()
return resp.json()
async def _api_post(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
resp = await self._http_client.post(path, json=json_data)
resp.raise_for_status()
return resp.json()
async def _api_put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
resp = await self._http_client.put(path, json=json_data)
resp.raise_for_status()
return resp.json()
async def _create_post(
self,
channel_id: str,
message: str,
*,
root_id: str | None = None,
file_ids: list[str] | None = None,
) -> dict[str, Any]:
body: dict[str, Any] = {
"channel_id": channel_id,
"message": message,
}
if root_id:
body["root_id"] = root_id
if file_ids:
body["file_ids"] = file_ids
return await self._api_post("/api/v4/posts", body)
async def _edit_post(self, post_id: str, message: str) -> dict[str, Any]:
return await self._api_put(f"/api/v4/posts/{post_id}", {"id": post_id, "message": message})
async def _upload_file(self, channel_id: str, file_path: str) -> str | None:
path = Path(file_path)
if not path.exists():
self.logger.warning("file not found: {}", file_path)
return None
try:
files = {"files": (path.name, path.read_bytes())}
resp = await self._http_client.post(
"/api/v4/files",
data={"channel_id": channel_id},
files=files,
)
resp.raise_for_status()
data = resp.json()
infos = data.get("file_infos", [])
if infos:
return infos[0].get("id")
except Exception as e:
self.logger.warning("file upload failed for {}: {}", file_path, e)
return None
async def _download_file(self, file_id: str) -> str | None:
try:
info_resp = await self._http_client.get(f"/api/v4/files/{file_id}/info")
info_resp.raise_for_status()
info = info_resp.json()
name = Path(info.get("name", file_id)).name
out = Path(get_media_dir("mattermost")) / safe_filename(f"{file_id}_{name}")
out.parent.mkdir(parents=True, exist_ok=True)
dl = await self._http_client.get(f"/api/v4/files/{file_id}")
dl.raise_for_status()
out.write_bytes(dl.content)
return str(out)
except Exception as e:
self.logger.warning("file download failed for {}: {}", file_id, e)
return None
async def _add_reaction(self, channel_id: str, post_id: str, emoji: str) -> None:
if not self._self_id or not emoji:
return
await self._api_post("/api/v4/reactions", {
"user_id": self._self_id,
"post_id": post_id,
"emoji_name": emoji,
})
async def _remove_reaction(self, post_id: str, emoji: str) -> None:
if not self._self_id or not emoji:
return
resp = await self._http_client.delete(
f"/api/v4/users/{self._self_id}/posts/{post_id}/reactions/{emoji}",
)
if resp.status_code >= 400:
self.logger.debug("remove reaction failed: {} {}", resp.status_code, resp.text)
async def resolve_channel_type(self, channel_id: str) -> str:
if channel_id in self._channel_types:
return self._channel_types[channel_id]
try:
data = await self._api_get(f"/api/v4/channels/{channel_id}")
ctype = _CHANNEL_TYPES.get(data.get("type", ""), "public")
self._channel_types[channel_id] = ctype
return ctype
except Exception:
return "public"