Skip to content

Commit 0349ada

Browse files
feat(platform): add Mattermost bot support (#6009)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent a93568c commit 0349ada

7 files changed

Lines changed: 647 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ Connect AstrBot to your favorite chat platform.
157157
| LINE | Official |
158158
| Satori | Official |
159159
| Misskey | Official |
160+
| Mattermost | Official |
160161
| WhatsApp (Coming Soon) | Official |
161162
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
162163
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |

astrbot/core/config/default.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,14 @@ class ChatProviderTemplate(TypedDict):
519519
"kook_max_heartbeat_failures": 3,
520520
"kook_max_consecutive_failures": 5,
521521
},
522+
"Mattermost": {
523+
"id": "mattermost",
524+
"type": "mattermost",
525+
"enable": False,
526+
"mattermost_url": "https://chat.example.com",
527+
"mattermost_bot_token": "",
528+
"mattermost_reconnect_delay": 5.0,
529+
},
522530
# "WebChat": {
523531
# "id": "webchat",
524532
# "type": "webchat",
@@ -653,6 +661,21 @@ class ChatProviderTemplate(TypedDict):
653661
"type": "string",
654662
"hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。",
655663
},
664+
"mattermost_url": {
665+
"description": "Mattermost URL",
666+
"type": "string",
667+
"hint": "Mattermost 服务地址,例如 https://chat.example.com。",
668+
},
669+
"mattermost_bot_token": {
670+
"description": "Mattermost Bot Token",
671+
"type": "string",
672+
"hint": "在 Mattermost 中创建 Bot 账户后生成的访问令牌。",
673+
},
674+
"mattermost_reconnect_delay": {
675+
"description": "Mattermost 重连延迟",
676+
"type": "float",
677+
"hint": "WebSocket 断开后的重连等待时间,单位为秒。默认 5 秒。",
678+
},
656679
"misskey_instance_url": {
657680
"description": "Misskey 实例 URL",
658681
"type": "string",

astrbot/core/platform/manager.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,10 @@ async def load_platform(self, platform_config: dict) -> None:
188188
from .sources.kook.kook_adapter import (
189189
KookPlatformAdapter, # noqa: F401
190190
)
191+
case "mattermost":
192+
from .sources.mattermost.mattermost_adapter import (
193+
MattermostPlatformAdapter, # noqa: F401
194+
)
191195
except (ImportError, ModuleNotFoundError) as e:
192196
logger.error(
193197
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",

astrbot/core/platform/sources/mattermost/__init__.py

Whitespace-only changes.
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import json
2+
import mimetypes
3+
from pathlib import Path
4+
from typing import Any
5+
6+
import aiohttp
7+
8+
from astrbot.api import logger
9+
from astrbot.api.event import MessageChain
10+
from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video
11+
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
12+
13+
14+
class MattermostClient:
15+
def __init__(self, base_url: str, token: str) -> None:
16+
self.base_url = base_url.rstrip("/")
17+
self.token = token
18+
self._session: aiohttp.ClientSession | None = None
19+
20+
async def ensure_session(self) -> aiohttp.ClientSession:
21+
if self._session is None or self._session.closed:
22+
self._session = aiohttp.ClientSession(
23+
timeout=aiohttp.ClientTimeout(total=30),
24+
)
25+
return self._session
26+
27+
async def close(self) -> None:
28+
if self._session and not self._session.closed:
29+
await self._session.close()
30+
31+
def _headers(self) -> dict[str, str]:
32+
return {
33+
"Authorization": f"Bearer {self.token}",
34+
"Content-Type": "application/json",
35+
}
36+
37+
def _auth_headers(self) -> dict[str, str]:
38+
return {"Authorization": f"Bearer {self.token}"}
39+
40+
async def get_json(self, path: str) -> dict[str, Any]:
41+
session = await self.ensure_session()
42+
url = f"{self.base_url}/api/v4/{path.lstrip('/')}"
43+
async with session.get(url, headers=self._headers()) as resp:
44+
if resp.status >= 400:
45+
body = await resp.text()
46+
raise RuntimeError(
47+
f"Mattermost GET {path} failed: {resp.status} {body}"
48+
)
49+
data = await resp.json()
50+
if not isinstance(data, dict):
51+
raise RuntimeError(f"Mattermost GET {path} returned non-object JSON")
52+
return data
53+
54+
async def post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
55+
session = await self.ensure_session()
56+
url = f"{self.base_url}/api/v4/{path.lstrip('/')}"
57+
async with session.post(url, headers=self._headers(), json=payload) as resp:
58+
if resp.status >= 400:
59+
body = await resp.text()
60+
raise RuntimeError(
61+
f"Mattermost POST {path} failed: {resp.status} {body}"
62+
)
63+
data = await resp.json()
64+
if not isinstance(data, dict):
65+
raise RuntimeError(f"Mattermost POST {path} returned non-object JSON")
66+
return data
67+
68+
async def get_me(self) -> dict[str, Any]:
69+
return await self.get_json("users/me")
70+
71+
async def get_channel(self, channel_id: str) -> dict[str, Any]:
72+
return await self.get_json(f"channels/{channel_id}")
73+
74+
async def get_file_info(self, file_id: str) -> dict[str, Any]:
75+
return await self.get_json(f"files/{file_id}/info")
76+
77+
async def download_file(self, file_id: str) -> bytes:
78+
session = await self.ensure_session()
79+
url = f"{self.base_url}/api/v4/files/{file_id}"
80+
async with session.get(url, headers=self._auth_headers()) as resp:
81+
if resp.status >= 400:
82+
body = await resp.text()
83+
raise RuntimeError(
84+
f"Mattermost download file {file_id} failed: {resp.status} {body}"
85+
)
86+
return await resp.read()
87+
88+
async def upload_file(
89+
self,
90+
channel_id: str,
91+
file_bytes: bytes,
92+
filename: str,
93+
content_type: str,
94+
) -> str:
95+
session = await self.ensure_session()
96+
url = f"{self.base_url}/api/v4/files"
97+
form = aiohttp.FormData()
98+
form.add_field("channel_id", channel_id)
99+
form.add_field(
100+
"files",
101+
file_bytes,
102+
filename=filename,
103+
content_type=content_type,
104+
)
105+
async with session.post(url, headers=self._auth_headers(), data=form) as resp:
106+
if resp.status >= 400:
107+
body = await resp.text()
108+
raise RuntimeError(
109+
f"Mattermost upload file failed: {resp.status} {body}"
110+
)
111+
data = await resp.json()
112+
file_infos = data.get("file_infos", [])
113+
if not file_infos:
114+
raise RuntimeError("Mattermost upload file returned no file_infos")
115+
file_id = file_infos[0].get("id", "")
116+
if not file_id:
117+
raise RuntimeError("Mattermost upload file returned empty file id")
118+
return str(file_id)
119+
120+
async def create_post(
121+
self,
122+
channel_id: str,
123+
message: str,
124+
*,
125+
file_ids: list[str] | None = None,
126+
root_id: str | None = None,
127+
) -> dict[str, Any]:
128+
payload: dict[str, Any] = {
129+
"channel_id": channel_id,
130+
"message": message,
131+
}
132+
if file_ids:
133+
payload["file_ids"] = file_ids
134+
if root_id:
135+
payload["root_id"] = root_id
136+
return await self.post_json("posts", payload)
137+
138+
async def ws_connect(self) -> aiohttp.ClientWebSocketResponse:
139+
session = await self.ensure_session()
140+
ws_url = self.base_url.replace("https://", "wss://", 1).replace(
141+
"http://", "ws://", 1
142+
)
143+
ws_url = f"{ws_url}/api/v4/websocket"
144+
return await session.ws_connect(ws_url, heartbeat=30.0)
145+
146+
async def send_message_chain(
147+
self,
148+
channel_id: str,
149+
message_chain: MessageChain,
150+
) -> dict[str, Any]:
151+
text_parts: list[str] = []
152+
file_ids: list[str] = []
153+
root_id: str | None = None
154+
155+
for segment in message_chain.chain:
156+
if isinstance(segment, Plain):
157+
text_parts.append(segment.text)
158+
elif isinstance(segment, At):
159+
mention_name = str(segment.name or segment.qq or "").strip()
160+
if mention_name:
161+
text_parts.append(f"@{mention_name}")
162+
elif isinstance(segment, Reply):
163+
if segment.id:
164+
root_id = str(segment.id)
165+
elif isinstance(segment, Image):
166+
path = await segment.convert_to_file_path()
167+
file_path = Path(path)
168+
file_ids.append(
169+
await self.upload_file(
170+
channel_id,
171+
file_path.read_bytes(),
172+
file_path.name,
173+
mimetypes.guess_type(file_path.name)[0] or "image/jpeg",
174+
)
175+
)
176+
elif isinstance(segment, (File, Record, Video)):
177+
if isinstance(segment, File):
178+
path = await segment.get_file()
179+
filename = segment.name or Path(path).name
180+
else:
181+
path = await segment.convert_to_file_path()
182+
filename = Path(path).name
183+
file_path = Path(path)
184+
file_ids.append(
185+
await self.upload_file(
186+
channel_id,
187+
file_path.read_bytes(),
188+
filename,
189+
mimetypes.guess_type(filename)[0] or "application/octet-stream",
190+
)
191+
)
192+
else:
193+
logger.debug(
194+
"Mattermost send_message_chain skipped unsupported segment: %s",
195+
segment.type,
196+
)
197+
198+
return await self.create_post(
199+
channel_id,
200+
"".join(text_parts).strip(),
201+
file_ids=file_ids or None,
202+
root_id=root_id,
203+
)
204+
205+
async def parse_post_attachments(
206+
self,
207+
file_ids: list[str],
208+
) -> tuple[list[Any], list[str]]:
209+
components: list[Any] = []
210+
temp_paths: list[str] = []
211+
212+
for file_id in file_ids:
213+
try:
214+
info = await self.get_file_info(file_id)
215+
file_bytes = await self.download_file(file_id)
216+
except Exception as exc:
217+
logger.warning(
218+
"Mattermost fetch attachment failed %s: %s", file_id, exc
219+
)
220+
continue
221+
222+
filename = str(info.get("name") or f"file_{file_id}")
223+
mime_type = str(info.get("mime_type") or "application/octet-stream")
224+
suffix = Path(filename).suffix
225+
file_path = Path(get_astrbot_temp_path()) / f"mattermost_{file_id}{suffix}"
226+
try:
227+
file_path.write_bytes(file_bytes)
228+
except OSError as exc:
229+
logger.warning(
230+
"Mattermost write attachment failed %s -> %s: %s",
231+
file_id,
232+
file_path,
233+
exc,
234+
)
235+
continue
236+
temp_paths.append(str(file_path))
237+
238+
if mime_type.startswith("image/"):
239+
components.append(Image.fromFileSystem(str(file_path)))
240+
else:
241+
components.append(File(name=filename, file=str(file_path)))
242+
243+
return components, temp_paths
244+
245+
@staticmethod
246+
def parse_websocket_post(raw_post: str) -> dict[str, Any] | None:
247+
try:
248+
parsed = json.loads(raw_post)
249+
except json.JSONDecodeError:
250+
return None
251+
if not isinstance(parsed, dict):
252+
return None
253+
return parsed

0 commit comments

Comments
 (0)