diff --git a/README.md b/README.md index d3c30ccdaa..34b75b4683 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ Connect AstrBot to your favorite chat platform. | LINE | Official | | Satori | Official | | Misskey | Official | +| Mattermost | Official | | WhatsApp (Coming Soon) | Official | | [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community | | [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community | diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 91f9a09fba..d9eb21a991 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -506,7 +506,7 @@ class ChatProviderTemplate(TypedDict): "satori_heartbeat_interval": 10, "satori_reconnect_delay": 5, }, - "kook": { + "KOOK": { "id": "kook", "type": "kook", "enable": False, @@ -519,6 +519,14 @@ class ChatProviderTemplate(TypedDict): "kook_max_heartbeat_failures": 3, "kook_max_consecutive_failures": 5, }, + "Mattermost": { + "id": "mattermost", + "type": "mattermost", + "enable": False, + "mattermost_url": "https://chat.example.com", + "mattermost_bot_token": "", + "mattermost_reconnect_delay": 5.0, + }, # "WebChat": { # "id": "webchat", # "type": "webchat", @@ -653,6 +661,21 @@ class ChatProviderTemplate(TypedDict): "type": "string", "hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。", }, + "mattermost_url": { + "description": "Mattermost URL", + "type": "string", + "hint": "Mattermost 服务地址,例如 https://chat.example.com。", + }, + "mattermost_bot_token": { + "description": "Mattermost Bot Token", + "type": "string", + "hint": "在 Mattermost 中创建 Bot 账户后生成的访问令牌。", + }, + "mattermost_reconnect_delay": { + "description": "Mattermost 重连延迟", + "type": "float", + "hint": "WebSocket 断开后的重连等待时间,单位为秒。默认 5 秒。", + }, "misskey_instance_url": { "description": "Misskey 实例 URL", "type": "string", diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 15c04166dc..d592eb2fbf 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -188,6 +188,10 @@ async def load_platform(self, platform_config: dict) -> None: from .sources.kook.kook_adapter import ( KookPlatformAdapter, # noqa: F401 ) + case "mattermost": + from .sources.mattermost.mattermost_adapter import ( + MattermostPlatformAdapter, # noqa: F401 + ) except (ImportError, ModuleNotFoundError) as e: logger.error( f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。", diff --git a/astrbot/core/platform/sources/mattermost/__init__.py b/astrbot/core/platform/sources/mattermost/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/astrbot/core/platform/sources/mattermost/client.py b/astrbot/core/platform/sources/mattermost/client.py new file mode 100644 index 0000000000..88619e2157 --- /dev/null +++ b/astrbot/core/platform/sources/mattermost/client.py @@ -0,0 +1,260 @@ +import asyncio +import json +import mimetypes +from pathlib import Path +from typing import Any + +import aiohttp + +from astrbot.api import logger +from astrbot.api.event import MessageChain +from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + + +class MattermostClient: + def __init__(self, base_url: str, token: str) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self._session: aiohttp.ClientSession | None = None + + async def ensure_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + ) + return self._session + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + + def _auth_headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.token}"} + + async def get_json(self, path: str) -> dict[str, Any]: + session = await self.ensure_session() + url = f"{self.base_url}/api/v4/{path.lstrip('/')}" + async with session.get(url, headers=self._headers()) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError( + f"Mattermost GET {path} failed: {resp.status} {body}" + ) + data = await resp.json() + if not isinstance(data, dict): + raise RuntimeError(f"Mattermost GET {path} returned non-object JSON") + return data + + async def post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + session = await self.ensure_session() + url = f"{self.base_url}/api/v4/{path.lstrip('/')}" + async with session.post(url, headers=self._headers(), json=payload) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError( + f"Mattermost POST {path} failed: {resp.status} {body}" + ) + data = await resp.json() + if not isinstance(data, dict): + raise RuntimeError(f"Mattermost POST {path} returned non-object JSON") + return data + + async def get_me(self) -> dict[str, Any]: + return await self.get_json("users/me") + + async def get_channel(self, channel_id: str) -> dict[str, Any]: + return await self.get_json(f"channels/{channel_id}") + + async def get_file_info(self, file_id: str) -> dict[str, Any]: + return await self.get_json(f"files/{file_id}/info") + + async def download_file(self, file_id: str) -> bytes: + session = await self.ensure_session() + url = f"{self.base_url}/api/v4/files/{file_id}" + async with session.get(url, headers=self._auth_headers()) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError( + f"Mattermost download file {file_id} failed: {resp.status} {body}" + ) + return await resp.read() + + async def upload_file( + self, + channel_id: str, + file_bytes: bytes, + filename: str, + content_type: str, + ) -> str: + session = await self.ensure_session() + url = f"{self.base_url}/api/v4/files" + form = aiohttp.FormData() + form.add_field("channel_id", channel_id) + form.add_field( + "files", + file_bytes, + filename=filename, + content_type=content_type, + ) + async with session.post(url, headers=self._auth_headers(), data=form) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError( + f"Mattermost upload file failed: {resp.status} {body}" + ) + data = await resp.json() + file_infos = data.get("file_infos", []) + if not file_infos: + raise RuntimeError("Mattermost upload file returned no file_infos") + file_id = file_infos[0].get("id", "") + if not file_id: + raise RuntimeError("Mattermost upload file returned empty file id") + return str(file_id) + + async def create_post( + self, + channel_id: str, + message: str, + *, + file_ids: list[str] | None = None, + root_id: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "channel_id": channel_id, + "message": message, + } + if file_ids: + payload["file_ids"] = file_ids + if root_id: + payload["root_id"] = root_id + return await self.post_json("posts", payload) + + async def ws_connect(self) -> aiohttp.ClientWebSocketResponse: + session = await self.ensure_session() + ws_url = self.base_url.replace("https://", "wss://", 1).replace( + "http://", "ws://", 1 + ) + ws_url = f"{ws_url}/api/v4/websocket" + return await session.ws_connect(ws_url, heartbeat=30.0) + + async def send_message_chain( + self, + channel_id: str, + message_chain: MessageChain, + ) -> dict[str, Any]: + text_parts: list[str] = [] + file_ids: list[str] = [] + root_id: str | None = None + + for segment in message_chain.chain: + if isinstance(segment, Plain): + text_parts.append(segment.text) + elif isinstance(segment, At): + mention_name = str(segment.name or segment.qq or "").strip() + if mention_name: + text_parts.append(f"@{mention_name}") + elif isinstance(segment, Reply): + if segment.id: + root_id = str(segment.id) + elif isinstance(segment, Image): + path = await segment.convert_to_file_path() + file_path = Path(path) + file_bytes = await asyncio.to_thread(file_path.read_bytes) + file_ids.append( + await self.upload_file( + channel_id, + file_bytes, + file_path.name, + mimetypes.guess_type(file_path.name)[0] or "image/jpeg", + ) + ) + elif isinstance(segment, (File, Record, Video)): + if isinstance(segment, File): + path = await segment.get_file() + filename = segment.name or Path(path).name + else: + path = await segment.convert_to_file_path() + filename = Path(path).name + file_path = Path(path) + file_bytes = await asyncio.to_thread(file_path.read_bytes) + file_ids.append( + await self.upload_file( + channel_id, + file_bytes, + filename, + mimetypes.guess_type(filename)[0] or "application/octet-stream", + ) + ) + else: + logger.debug( + "Mattermost send_message_chain skipped unsupported segment: %s", + segment.type, + ) + + return await self.create_post( + channel_id, + "".join(text_parts).strip(), + file_ids=file_ids or None, + root_id=root_id, + ) + + async def parse_post_attachments( + self, + file_ids: list[str], + ) -> tuple[list[Any], list[str]]: + components: list[Any] = [] + temp_paths: list[str] = [] + + for file_id in file_ids: + try: + info = await self.get_file_info(file_id) + file_bytes = await self.download_file(file_id) + except Exception as exc: + logger.warning( + "Mattermost fetch attachment failed %s: %s", file_id, exc + ) + continue + + filename = str(info.get("name") or f"file_{file_id}") + mime_type = str(info.get("mime_type") or "application/octet-stream") + suffix = Path(filename).suffix + file_path = Path(get_astrbot_temp_path()) / f"mattermost_{file_id}{suffix}" + try: + await asyncio.to_thread(file_path.write_bytes, file_bytes) + except OSError as exc: + logger.warning( + "Mattermost write attachment failed %s -> %s: %s", + file_id, + file_path, + exc, + ) + continue + temp_paths.append(str(file_path)) + + if mime_type.startswith("image/"): + components.append(Image.fromFileSystem(str(file_path))) + elif mime_type.startswith("audio/"): + components.append(Record.fromFileSystem(str(file_path))) + elif mime_type.startswith("video/"): + components.append(Video.fromFileSystem(str(file_path))) + else: + components.append(File(name=filename, file=str(file_path))) + + return components, temp_paths + + @staticmethod + def parse_websocket_post(raw_post: str) -> dict[str, Any] | None: + try: + parsed = json.loads(raw_post) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return None + return parsed diff --git a/astrbot/core/platform/sources/mattermost/mattermost_adapter.py b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py new file mode 100644 index 0000000000..583e0d0af0 --- /dev/null +++ b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py @@ -0,0 +1,323 @@ +import asyncio +import json +import re +import time +from collections import deque +from typing import Any, cast + +import aiohttp + +from astrbot.api import logger +from astrbot.api.event import MessageChain +from astrbot.api.message_components import At, Plain +from astrbot.api.platform import ( + AstrBotMessage, + MessageMember, + MessageType, + Platform, + PlatformMetadata, +) +from astrbot.core.platform.astr_message_event import MessageSesion + +from ...register import register_platform_adapter +from .client import MattermostClient +from .mattermost_event import MattermostMessageEvent + + +@register_platform_adapter( + "mattermost", + "Mattermost 平台适配器", + support_streaming_message=False, +) +class MattermostPlatformAdapter(Platform): + def __init__( + self, + platform_config: dict, + platform_settings: dict, + event_queue: asyncio.Queue, + ) -> None: + super().__init__(platform_config, event_queue) + self.settings = platform_settings + self.base_url = str(platform_config.get("mattermost_url", "")).rstrip("/") + self.bot_token = str(platform_config.get("mattermost_bot_token", "")).strip() + self.reconnect_delay = float( + platform_config.get("mattermost_reconnect_delay", 5.0) + ) + + if not self.base_url: + raise ValueError("Mattermost URL 是必需的") + if not self.bot_token: + raise ValueError("Mattermost bot token 是必需的") + + self.client = MattermostClient(self.base_url, self.bot_token) + self.metadata = PlatformMetadata( + name="mattermost", + description="Mattermost 平台适配器", + id=cast(str, self.config.get("id", "mattermost")), + support_streaming_message=False, + ) + self.bot_self_id = "" + self.bot_username = "" + self._mention_pattern: re.Pattern[str] | None = None + self._running = True + self._seen_post_ids: dict[str, float] = {} + self._seen_post_queue: deque[tuple[str, float]] = deque() + self._dedup_ttl = 300.0 + + async def send_by_session( + self, + session: MessageSesion, + message_chain: MessageChain, + ) -> None: + await self.client.send_message_chain(session.session_id, message_chain) + await super().send_by_session(session, message_chain) + + def meta(self) -> PlatformMetadata: + return self.metadata + + async def run(self) -> None: + me = await self.client.get_me() + self.bot_self_id = str(me.get("id", "")) + self.bot_username = str(me.get("username", "")) + self._mention_pattern = self._build_mention_pattern(self.bot_username) + if not self.bot_self_id: + raise RuntimeError("Mattermost auth succeeded but returned empty user id") + + logger.info( + "Mattermost auth test OK. Bot: @%s (%s)", + self.bot_username, + self.bot_self_id, + ) + + while self._running: + try: + await self._ws_connect_and_listen() + except asyncio.CancelledError: + raise + except Exception as exc: + if not self._running: + break + logger.warning( + "Mattermost websocket disconnected: %s. Retrying in %.1fs.", + exc, + self.reconnect_delay, + ) + await asyncio.sleep(self.reconnect_delay) + + async def _ws_connect_and_listen(self) -> None: + ws = await self.client.ws_connect() + try: + await ws.send_json( + { + "seq": 1, + "action": "authentication_challenge", + "data": {"token": self.bot_token}, + } + ) + + async for message in ws: + if message.type != aiohttp.WSMsgType.TEXT: + if message.type in { + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.ERROR, + }: + break + continue + + try: + payload = json.loads(message.data) + except json.JSONDecodeError: + logger.debug( + "Mattermost websocket received non-JSON text frame: %r", + message.data, + ) + continue + if isinstance(payload, dict): + await self._handle_ws_event(payload) + finally: + await ws.close() + + async def _handle_ws_event(self, payload: dict[str, Any]) -> None: + if payload.get("event") != "posted": + return + + data = payload.get("data") + if not isinstance(data, dict): + return + + raw_post = data.get("post") + if not isinstance(raw_post, str): + return + + post = self.client.parse_websocket_post(raw_post) + if not post: + return + + user_id = str(post.get("user_id", "")) + if not user_id or user_id == self.bot_self_id: + return + if post.get("type"): + return + + post_id = str(post.get("id", "")) + if post_id and self._is_duplicate_post(post_id): + return + + abm = await self.convert_message(post=post, data=data) + if abm is not None: + await self.handle_msg(abm) + + def _is_duplicate_post(self, post_id: str) -> bool: + now = time.monotonic() + self._prune_seen_posts(now) + if post_id in self._seen_post_ids: + return True + self._seen_post_ids[post_id] = now + self._seen_post_queue.append((post_id, now)) + return False + + def _prune_seen_posts(self, now: float) -> None: + while self._seen_post_queue: + queued_post_id, seen_at = self._seen_post_queue[0] + if now - seen_at <= self._dedup_ttl: + break + self._seen_post_queue.popleft() + current_seen_at = self._seen_post_ids.get(queued_post_id) + if current_seen_at == seen_at: + del self._seen_post_ids[queued_post_id] + + async def convert_message( + self, + *, + post: dict[str, Any], + data: dict[str, Any], + ) -> AstrBotMessage | None: + channel_id = str(post.get("channel_id", "") or "") + if not channel_id: + return None + + channel_type = str(data.get("channel_type", "O") or "O") + sender_id = str(post.get("user_id", "") or "") + sender_name = str(data.get("sender_name", "") or sender_id).lstrip("@") + message_text = str(post.get("message", "") or "") + file_ids = [ + str(file_id) + for file_id in (post.get("file_ids") or []) + if str(file_id).strip() + ] + + abm = AstrBotMessage() + abm.self_id = self.bot_self_id + abm.sender = MessageMember(user_id=sender_id, nickname=sender_name) + abm.session_id = channel_id + abm.message_id = str(post.get("id") or channel_id) + abm.raw_message = post + abm.timestamp = self._parse_timestamp(post.get("create_at")) + abm.message = self._parse_text_components(message_text) + + if channel_type == "D": + abm.type = MessageType.FRIEND_MESSAGE + else: + abm.type = MessageType.GROUP_MESSAGE + abm.group_id = channel_id + + if file_ids: + ( + attachment_components, + temp_paths, + ) = await self.client.parse_post_attachments(file_ids) + abm.message.extend(attachment_components) + setattr(abm, "temporary_file_paths", temp_paths) + + abm.message_str = self._build_message_str( + abm.message, + message_text, + self.bot_self_id, + ) + return abm + + def _parse_text_components(self, message_text: str) -> list[Any]: + if not message_text: + return [] + + components: list[Any] = [] + if not self.bot_username: + return [Plain(message_text)] + + mention_pattern = self._mention_pattern + if mention_pattern is None: + mention_pattern = self._build_mention_pattern(self.bot_username) + if mention_pattern is None: + return [Plain(message_text)] + last_end = 0 + + for match in mention_pattern.finditer(message_text): + if match.start() > last_end: + components.append(Plain(message_text[last_end : match.start()])) + components.append(At(qq=self.bot_self_id, name=self.bot_username)) + last_end = match.end() + + if last_end < len(message_text): + components.append(Plain(message_text[last_end:])) + + if not components: + components.append(Plain(message_text)) + return components + + @staticmethod + def _build_mention_pattern(bot_username: str) -> re.Pattern[str] | None: + if not bot_username: + return None + return re.compile( + rf"(? str: + text_parts: list[str] = [] + leading_self_mention_skipped = False + + for component in components: + if isinstance(component, Plain): + text_parts.append(component.text) + elif isinstance(component, At): + is_self_mention = str(component.qq) == self_id + if not leading_self_mention_skipped and is_self_mention: + leading_self_mention_skipped = True + if not text_parts or not "".join(text_parts).strip(): + continue + mention_name = str(component.name or component.qq or "").strip() + if mention_name: + text_parts.append(f"@{mention_name}") + message_str = "".join(text_parts).strip() + return message_str or fallback.strip() + + @staticmethod + def _parse_timestamp(raw_value: Any) -> int: + if isinstance(raw_value, int): + return raw_value // 1000 if raw_value > 1_000_000_000_000 else raw_value + return int(time.time()) + + async def handle_msg(self, message: AstrBotMessage) -> None: + message_event = MattermostMessageEvent( + message_str=message.message_str, + message_obj=message, + platform_meta=self.meta(), + session_id=message.session_id, + client=self.client, + ) + self.commit_event(message_event) + + async def terminate(self) -> None: + self._running = False + await self.client.close() + + def get_client(self) -> MattermostClient: + return self.client diff --git a/astrbot/core/platform/sources/mattermost/mattermost_event.py b/astrbot/core/platform/sources/mattermost/mattermost_event.py new file mode 100644 index 0000000000..5faaf71345 --- /dev/null +++ b/astrbot/core/platform/sources/mattermost/mattermost_event.py @@ -0,0 +1,88 @@ +import asyncio +import re +from collections.abc import AsyncGenerator + +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.message_components import Plain +from astrbot.api.platform import Group, MessageMember + +from .client import MattermostClient + + +class MattermostMessageEvent(AstrMessageEvent): + _FALLBACK_SENTENCE_PATTERN = re.compile(r"[^。?!~…]+[。?!~…]+") + + def __init__( + self, + message_str, + message_obj, + platform_meta, + session_id, + client: MattermostClient, + ) -> None: + super().__init__(message_str, message_obj, platform_meta, session_id) + self.client = client + for path in getattr(message_obj, "temporary_file_paths", []): + self.track_temporary_local_file(path) + + async def send(self, message: MessageChain) -> None: + await self.client.send_message_chain(self.get_session_id(), message) + await super().send(message) + + async def send_streaming( + self, + generator: AsyncGenerator, + use_fallback: bool = False, + ) -> None: + await super().send_streaming(generator, use_fallback) + + if not use_fallback: + message_buffer: MessageChain | None = None + async for chain in generator: + if not message_buffer: + message_buffer = chain + else: + message_buffer.chain.extend(chain.chain) + if not message_buffer: + return None + message_buffer.squash_plain() + await self.send(message_buffer) + return None + + text_buffer = "" + + async for chain in generator: + if isinstance(chain, MessageChain): + for comp in chain.chain: + if isinstance(comp, Plain): + text_buffer += comp.text + if any(p in text_buffer for p in "。?!~…"): + text_buffer = await self.process_buffer( + text_buffer, + self._FALLBACK_SENTENCE_PATTERN, + ) + else: + await self.send(MessageChain(chain=[comp])) + await asyncio.sleep(1.5) + + if text_buffer.strip(): + await self.send(MessageChain([Plain(text_buffer)])) + return None + + async def get_group(self, group_id=None, **kwargs): + channel_id = group_id or self.get_group_id() + if not channel_id: + return None + channel = await self.client.get_channel(channel_id) + return Group( + group_id=channel_id, + group_name=channel.get("display_name") or channel.get("name") or channel_id, + group_owner="", + group_admins=[], + members=[ + MessageMember( + user_id=self.get_sender_id(), + nickname=self.get_sender_name(), + ) + ], + ) diff --git a/dashboard/src/assets/images/platform_logos/mattermost.svg b/dashboard/src/assets/images/platform_logos/mattermost.svg new file mode 100644 index 0000000000..9b055325ae --- /dev/null +++ b/dashboard/src/assets/images/platform_logos/mattermost.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 92927cebd7..2c90471dee 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -556,6 +556,18 @@ "description": "Bot Token", "hint": "If you are in mainland China, set a proxy or change api_base in Other Settings." }, + "mattermost_url": { + "description": "Mattermost URL", + "hint": "Mattermost service URL, for example https://chat.example.com." + }, + "mattermost_bot_token": { + "description": "Mattermost Bot Token", + "hint": "The access token generated after creating a bot account in Mattermost." + }, + "mattermost_reconnect_delay": { + "description": "Mattermost Reconnect Delay", + "hint": "Delay in seconds before reconnecting after the WebSocket disconnects. Defaults to 5 seconds." + }, "type": { "description": "Adapter Type" }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index afb608b4ab..bca16bfbc3 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -552,6 +552,18 @@ "description": "Токен бота", "hint": "Если вы находитесь в материковом Китае, установите прокси или измените api_base в разделе «Другие настройки»." }, + "mattermost_url": { + "description": "URL Mattermost", + "hint": "Адрес сервиса Mattermost, например https://chat.example.com." + }, + "mattermost_bot_token": { + "description": "Bot Token Mattermost", + "hint": "Токен доступа, созданный после добавления bot-аккаунта в Mattermost." + }, + "mattermost_reconnect_delay": { + "description": "Задержка переподключения Mattermost", + "hint": "Сколько секунд ждать перед переподключением после разрыва WebSocket. По умолчанию 5 секунд." + }, "type": { "description": "Тип адаптера" }, diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 8ce6d575f6..52a9fe4276 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -558,6 +558,18 @@ "description": "Bot Token", "hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。" }, + "mattermost_url": { + "description": "Mattermost URL", + "hint": "Mattermost 服务地址,例如 https://chat.example.com。" + }, + "mattermost_bot_token": { + "description": "Mattermost Bot Token", + "hint": "在 Mattermost 中创建 Bot 账户后生成的访问令牌。" + }, + "mattermost_reconnect_delay": { + "description": "Mattermost 重连延迟", + "hint": "WebSocket 断开后的重连等待时间,单位为秒。默认 5 秒。" + }, "type": { "description": "适配器类型" }, diff --git a/dashboard/src/utils/platformUtils.js b/dashboard/src/utils/platformUtils.js index ae39396d77..d69af9237e 100644 --- a/dashboard/src/utils/platformUtils.js +++ b/dashboard/src/utils/platformUtils.js @@ -40,6 +40,8 @@ export function getPlatformIcon(name) { return new URL('@/assets/images/platform_logos/line.png', import.meta.url).href } else if (name === 'matrix') { return new URL('@/assets/images/platform_logos/matrix.svg', import.meta.url).href + } else if (name === 'mattermost') { + return new URL('@/assets/images/platform_logos/mattermost.svg', import.meta.url).href } } diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index a4f104de08..5f6e1d2424 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -98,6 +98,7 @@ export default defineConfig({ { text: "Telegram", link: "/telegram" }, { text: "LINE", link: "/line" }, { text: "Slack", link: "/slack" }, + { text: "Mattermost", link: "/mattermost" }, { text: "Misskey", link: "/misskey" }, { text: "Discord", link: "/discord" }, { text: "KOOK", link: "/kook" }, @@ -336,6 +337,7 @@ export default defineConfig({ { text: "Telegram", link: "/telegram" }, { text: "LINE", link: "/line" }, { text: "Slack", link: "/slack" }, + { text: "Mattermost", link: "/mattermost" }, { text: "Misskey", link: "/misskey" }, { text: "Discord", link: "/discord" }, { diff --git a/docs/en/platform/mattermost.md b/docs/en/platform/mattermost.md new file mode 100644 index 0000000000..feb0716845 --- /dev/null +++ b/docs/en/platform/mattermost.md @@ -0,0 +1,139 @@ +# Connecting to Mattermost + +The Mattermost adapter connects to your Mattermost server through a Bot Token and WebSocket. After finishing the two parts below, AstrBot can send and receive messages in Mattermost channels and direct messages. + +## Create the AstrBot Mattermost Platform Adapter + +Go to the `Bots` page, click `+ Create Bot`, and choose `Mattermost`. + +On the configuration page, enable it first, then fill in: + +- `Mattermost URL`: your Mattermost server URL, for example `https://chat.example.com` +- `Mattermost Bot Token`: the access token generated after creating a bot account in Mattermost +- `Mattermost Reconnect Delay`: how long AstrBot waits before reconnecting after a WebSocket disconnect, default `5` + +Then click save. + +## Deploy Mattermost + +If you do not have a Mattermost server yet, use the official Mattermost Docker Compose repository: + +- Official docs: https://docs.mattermost.com/deployment-guide/server/containers/install-docker.html +- Official repository: https://github.com/mattermost/docker + +The current quick-start flow recommended by Mattermost is: + +```bash +git clone https://github.com/mattermost/docker +cd docker +cp env.example .env +``` + +Then update at least these values in `.env`: + +- `DOMAIN` +- `MATTERMOST_IMAGE_TAG` +- It is also recommended to set `MM_SUPPORTSETTINGS_SUPPORTEMAIL` + +Create the data directories and set ownership: + +```bash +mkdir -p ./volumes/app/mattermost/{config,data,logs,plugins,client/plugins,bleve-indexes} +sudo chown -R 2000:2000 ./volumes/app/mattermost +``` + +Choose one startup mode: + +Without the bundled NGINX: + +```bash +docker compose -f docker-compose.yml -f docker-compose.without-nginx.yml up -d +``` + +With the bundled NGINX: + +```bash +docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d +``` + +Access URLs: + +- Without NGINX: `http://your-domain:8065` +- With NGINX: `https://your-domain` + +> [!TIP] +> Mattermost currently states that production Docker support is Linux-only. macOS and Windows are better suited for development or testing. + +## Create a Bot in Mattermost + +### 1. Enable Bot Account Creation + +Open the Mattermost system console: + +`System Console > Integrations > Bot Accounts` + +Enable `Enable Bot Account Creation`. + +### 2. Create the Bot Account + +Go to: + +`Product menu > Integrations > Bot Accounts` + +Click `Add Bot Account` and fill in: + +- `Username` +- `Display Name` +- `Description` + +After creation, copy the generated Bot Token. It is shown only once. Paste it into AstrBot's `Mattermost Bot Token` field. + +### 3. Add the Bot to a Channel + +Add the bot to the channel where AstrBot should work. Otherwise the bot will not be able to properly receive and send messages in that channel. + +## How to Fill in Mattermost URL + +`Mattermost URL` should be the external URL of your Mattermost server, without a trailing slash. For example: + +```text +https://chat.example.com +``` + +If you are only testing locally, you can also use: + +```text +http://127.0.0.1:8065 +``` + +If both AstrBot and Mattermost run in containers, prefer an address reachable from the AstrBot container, such as the Mattermost service name on the same Docker network. + +## Start and Verify + +After saving the AstrBot platform adapter configuration: + +1. Make sure the AstrBot logs do not show Mattermost authentication or WebSocket connection errors. +2. Send a message in a channel that includes the bot, or send the bot a direct message. +3. If AstrBot replies normally, the integration is working. + +## Common Issues + +### Invalid Token Errors + +Usually one of these: + +- You copied a user token instead of the bot token +- The token contains extra spaces +- The bot account was deleted or the token was regenerated + +### Connected but No Channel Messages Arrive + +Check these first: + +- The bot has been added to the target channel +- `Mattermost URL` points to an address AstrBot can actually reach +- Your Mattermost reverse proxy forwards WebSocket traffic correctly + +### Mattermost Opens in Browser but AstrBot Still Cannot Connect + +If AstrBot runs in a container while `Mattermost URL` is set to `localhost` or `127.0.0.1`, AstrBot will connect to itself instead of the Mattermost service. In that case, switch to an address reachable inside the Docker network. diff --git a/docs/zh/platform/mattermost.md b/docs/zh/platform/mattermost.md new file mode 100644 index 0000000000..be67494e04 --- /dev/null +++ b/docs/zh/platform/mattermost.md @@ -0,0 +1,139 @@ +# 接入 Mattermost + +Mattermost 适配器通过 Bot Token 和 WebSocket 连接到 Mattermost 服务器。完成下面两部分配置后,AstrBot 就可以在 Mattermost 频道和私聊中收发消息。 + +## 创建 AstrBot Mattermost 平台适配器 + +进入 `机器人` 页面,点击 `+ 创建机器人`,选择 `Mattermost`。 + +在配置页中先打开 `启用`,然后填写以下字段: + +- `Mattermost URL`:你的 Mattermost 服务地址,例如 `https://chat.example.com` +- `Mattermost Bot Token`:在 Mattermost 中创建 Bot 账户后生成的访问令牌 +- `Mattermost 重连延迟`:WebSocket 断开后的重连等待时间,默认 `5` + +填写完成后点击保存。 + +## 部署 Mattermost + +如果你还没有 Mattermost 服务,建议直接使用 Mattermost 官方提供的 Docker Compose 仓库: + +- 官方文档:https://docs.mattermost.com/deployment-guide/server/containers/install-docker.html +- 官方仓库:https://github.com/mattermost/docker + +官方当前推荐的快速部署步骤如下: + +```bash +git clone https://github.com/mattermost/docker +cd docker +cp env.example .env +``` + +然后至少修改 `.env` 中的: + +- `DOMAIN` +- `MATTERMOST_IMAGE_TAG` +- 建议补充 `MM_SUPPORTSETTINGS_SUPPORTEMAIL` + +接着创建数据目录并设置权限: + +```bash +mkdir -p ./volumes/app/mattermost/{config,data,logs,plugins,client/plugins,bleve-indexes} +sudo chown -R 2000:2000 ./volumes/app/mattermost +``` + +启动方式二选一: + +不使用内置 NGINX: + +```bash +docker compose -f docker-compose.yml -f docker-compose.without-nginx.yml up -d +``` + +使用内置 NGINX: + +```bash +docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d +``` + +访问地址: + +- 不使用 NGINX:`http://你的域名:8065` +- 使用 NGINX:`https://你的域名` + +> [!TIP] +> Mattermost 官方当前说明中,Docker 生产支持仅限 Linux。macOS 和 Windows 更适合开发或测试用途。 + +## 在 Mattermost 中创建 Bot + +### 1. 开启 Bot 账户创建 + +进入 Mattermost 的系统控制台: + +`System Console > Integrations > Bot Accounts` + +开启 `Enable Bot Account Creation`。 + +### 2. 创建 Bot 账户 + +进入: + +`Product menu(左上角的图标) > Integrations > Bot Accounts` + +点击 `Add Bot Account`,填写: + +- `Username` +- `Display Name` +- `Description` + +创建完成后复制生成的 Bot Token。这个 Token 只会展示一次,随后填写到 AstrBot 的 `Mattermost Bot Token` 中。 + +### 3. 将 Bot 加入频道 + +把刚创建的 Bot 添加到你准备让 AstrBot 工作的频道中,否则机器人无法在该频道正常收发消息。 + +## Mattermost URL 如何填写 + +`Mattermost URL` 填 Mattermost 的外部访问地址,不要带结尾斜杠。例如: + +```text +https://chat.example.com +``` + +如果你当前只是在本机测试,也可以填写: + +```text +http://127.0.0.1:8065 +``` + +如果 AstrBot 和 Mattermost 都在 Docker 中运行,请优先填写 AstrBot 容器可访问到的地址,例如同一 Docker 网络中的服务名地址。 + +## 启动并验证 + +保存 AstrBot 平台适配器配置后: + +1. 确保 AstrBot 日志中没有出现 Mattermost 认证失败或 WebSocket 连接失败。 +2. 在 Mattermost 中向 Bot 所在频道发送消息,或直接给 Bot 发私聊。 +3. 如果 AstrBot 正常回复,说明接入成功。 + +## 常见问题 + +### 提示 Token 无效 + +通常是以下原因: + +- 复制的不是 Bot Token +- Token 复制时带了空格 +- Bot 账户被删除或重新生成了 Token + +### 连接成功但收不到频道消息 + +优先检查: + +- Bot 是否已经加入目标频道 +- Mattermost URL 是否填写为 AstrBot 实际可访问的地址 +- Mattermost 反向代理是否正确转发了 WebSocket 请求 + +### 本机部署能打开页面,但 AstrBot 连接不到 + +如果 AstrBot 运行在容器里,而 Mattermost URL 填的是 `localhost` 或 `127.0.0.1`,那么 AstrBot 实际连接到的是它自己的容器,而不是 Mattermost。此时应改为 Docker 网络内可访问的地址。 diff --git a/tests/test_mattermost_adapter.py b/tests/test_mattermost_adapter.py new file mode 100644 index 0000000000..7fd13d35db --- /dev/null +++ b/tests/test_mattermost_adapter.py @@ -0,0 +1,95 @@ +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import astrbot.api.message_components as Comp +from astrbot.core.platform.sources.mattermost.client import MattermostClient +from astrbot.core.platform.sources.mattermost.mattermost_adapter import ( + MattermostPlatformAdapter, +) +from tests.fixtures.helpers import make_platform_config + + +def _build_adapter() -> MattermostPlatformAdapter: + adapter = MattermostPlatformAdapter( + make_platform_config( + "mattermost", + id="test_mattermost", + mattermost_url="https://chat.example.com", + mattermost_bot_token="test_token", + mattermost_reconnect_delay=5.0, + ), + {}, + asyncio.Queue(), + ) + adapter.bot_self_id = "bot-id" + adapter.bot_username = "bot" + adapter._mention_pattern = adapter._build_mention_pattern(adapter.bot_username) + return adapter + + +@pytest.mark.asyncio +async def test_mattermost_convert_message_strips_leading_self_mention(): + adapter = _build_adapter() + + result = await adapter.convert_message( + post={ + "id": "post-1", + "channel_id": "channel-1", + "user_id": "user-1", + "message": "@bot /help now", + "create_at": 1_700_000_000_000, + "file_ids": [], + }, + data={ + "channel_type": "O", + "sender_name": "alice", + }, + ) + + assert result is not None + assert result.message_str == "/help now" + assert isinstance(result.message[0], Comp.At) + assert result.message[0].qq == "bot-id" + assert any( + isinstance(component, Comp.Plain) and component.text.strip() == "/help now" + for component in result.message + ) + + +@pytest.mark.asyncio +async def test_mattermost_parse_post_attachments_maps_media_types(tmp_path): + client = MattermostClient("https://chat.example.com", "test_token") + + file_infos = { + "img": {"name": "image.png", "mime_type": "image/png"}, + "audio": {"name": "voice.ogg", "mime_type": "audio/ogg"}, + "video": {"name": "clip.mp4", "mime_type": "video/mp4"}, + "doc": {"name": "report.pdf", "mime_type": "application/pdf"}, + } + + client.get_file_info = AsyncMock(side_effect=lambda file_id: file_infos[file_id]) + client.download_file = AsyncMock(return_value=b"payload") + + with patch( + "astrbot.core.platform.sources.mattermost.client.get_astrbot_temp_path", + MagicMock(return_value=str(tmp_path)), + ): + components, temp_paths = await client.parse_post_attachments( + ["img", "audio", "video", "doc"] + ) + + assert len(components) == 4 + assert isinstance(components[0], Comp.Image) + assert isinstance(components[1], Comp.Record) + assert isinstance(components[2], Comp.Video) + assert isinstance(components[3], Comp.File) + assert len(temp_paths) == 4 + + expected_names = ["image.png", "voice.ogg", "clip.mp4", "report.pdf"] + for temp_path, expected_name in zip(temp_paths, expected_names): + path = Path(temp_path) + assert path.exists() + assert path.name.endswith(Path(expected_name).suffix)