Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
25 changes: 24 additions & 1 deletion astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ class ChatProviderTemplate(TypedDict):
"satori_heartbeat_interval": 10,
"satori_reconnect_delay": 5,
},
"kook": {
"KOOK": {
"id": "kook",
"type": "kook",
"enable": False,
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions astrbot/core/platform/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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库 中安装依赖库。",
Expand Down
Empty file.
260 changes: 260 additions & 0 deletions astrbot/core/platform/sources/mattermost/client.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading