Skip to content

Commit 0ce5fde

Browse files
KagurazakaNyaasisyphus-dev-aiSoulter
authored
feat(platform): add Mattermost bot support (#7369)
* 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> * fix(platform): address Mattermost review feedback Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix(platform): improve Mattermost streaming and file IO Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * perf(platform): optimize Mattermost duplicate detection Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix(platform): preserve Mattermost command prefixes after mentions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * test(platform): cover Mattermost attachment parsing Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * docs: add mattermost docs --------- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Co-authored-by: Soulter <905617992@qq.com>
1 parent 2a3d93a commit 0ce5fde

File tree

16 files changed

+1115
-1
lines changed

16 files changed

+1115
-1
lines changed

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

0 commit comments

Comments
 (0)