ForcePilot/backend/package/yuxi/channel/extensions/mattermost/client.py
Kris ebab14660a feat(channel): 添加 Mattermost 渠道扩展
新增 Mattermost 渠道扩展,支持在 Yuxi 平台中集成 Mattermost 团队协作平台。

包含以下功能模块:
- client: Mattermost API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- websocket: WebSocket 实时连接
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedup: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- interactions: 交互处理
- slash_commands: 斜杠指令
- actions: 动作处理
- approval: 审批流程
- delivery: 消息送达确认
- directory: 目录管理
- threading: 线程管理
- gating: 门控管理
- reconnect: 重连机制
- reactions: 表情反应
- media: 媒体资源处理
- model_picker: 模型选择
- types: 类型定义
2026-05-21 11:22:43 +08:00

414 lines
15 KiB
Python

from __future__ import annotations
import asyncio
import logging
from typing import Any
import httpx
from yuxi.channel.extensions.mattermost.config import normalize_mattermost_base_url
from yuxi.channel.extensions.mattermost.errors import is_retryable_error, parse_mattermost_error
from yuxi.channel.extensions.mattermost.reconnect import with_jitter
logger = logging.getLogger(__name__)
class MattermostClient:
def __init__(
self,
base_url: str,
bot_token: str,
allow_private_network: bool = False,
timeout: float = 30.0,
):
self.base_url = normalize_mattermost_base_url(base_url)
self.api_url = f"{self.base_url}/api/v4"
self.bot_token = bot_token
limits = httpx.Limits(max_keepalive_connections=5, max_connections=20)
transport = httpx.AsyncHTTPTransport(
limits=limits,
retries=0,
)
self._client = httpx.AsyncClient(
base_url=self.api_url,
headers={
"Authorization": f"Bearer {bot_token}",
"Content-Type": "application/json",
},
timeout=httpx.Timeout(timeout),
transport=transport,
)
async def close(self) -> None:
await self._client.aclose()
async def _request(
self,
method: str,
path: str,
body: dict | None = None,
params: dict | None = None,
) -> dict:
response = await self._client.request(method, path, json=body, params=params)
if response.status_code >= 400:
raise parse_mattermost_error(response)
return response.json() if response.text else {}
async def _request_raw(
self,
method: str,
path: str,
body: dict | None = None,
params: dict | None = None,
) -> httpx.Response:
response = await self._client.request(method, path, json=body, params=params)
return response
# ── Users ────────────────────────────────────────────
async def fetch_me(self) -> dict:
return await self._request("GET", "/users/me")
async def fetch_user(self, user_id: str) -> dict:
return await self._request("GET", f"/users/{user_id}")
async def fetch_user_by_username(self, username: str) -> dict:
return await self._request("GET", f"/users/username/{username}")
async def fetch_users(self, page: int = 0, per_page: int = 200, in_team: str = "") -> list[dict]:
params: dict[str, Any] = {"page": page, "per_page": per_page}
if in_team:
params["in_team"] = in_team
return await self._request("GET", "/users", params=params)
async def fetch_user_teams(self, user_id: str) -> list[dict]:
return await self._request("GET", f"/users/{user_id}/teams")
async def update_user_status(self, user_id: str, status: str) -> dict:
return await self._request("PUT", f"/users/{user_id}/status", body={"status": status})
async def search_users(
self,
term: str,
*,
team_id: str = "",
not_in_channel: str = "",
not_in_team: str = "",
page: int = 0,
per_page: int = 60,
) -> list[dict]:
body: dict[str, Any] = {"term": term}
if team_id:
body["team_id"] = team_id
if not_in_channel:
body["not_in_channel"] = not_in_channel
if not_in_team:
body["not_in_team"] = not_in_team
params = {"page": page, "per_page": per_page}
return await self._request("POST", "/users/search", body=body, params=params)
async def fetch_user_preferences(self, user_id: str, category: str = "") -> list[dict]:
params = {}
if category:
params["category"] = category
return await self._request("GET", f"/users/{user_id}/preferences", params=params)
async def update_user_preferences(
self,
user_id: str,
preferences: list[dict],
) -> dict:
return await self._request("PUT", f"/users/{user_id}/preferences", body=preferences)
# ── Channels ─────────────────────────────────────────
async def fetch_channel(self, channel_id: str) -> dict:
return await self._request("GET", f"/channels/{channel_id}")
async def fetch_channel_by_name(self, team_id: str, name: str) -> dict:
return await self._request("GET", f"/teams/{team_id}/channels/name/{name}")
async def fetch_team_channels(self, team_id: str, page: int = 0, per_page: int = 200) -> list[dict]:
return await self._request(
"GET",
f"/teams/{team_id}/channels",
params={"page": page, "per_page": per_page},
)
async def create_direct_channel(self, user_ids: list[str]) -> dict:
return await self._request("POST", "/channels/direct", body=user_ids)
async def create_group_channel(self, user_ids: list[str]) -> dict:
return await self._request("POST", "/channels/group", body=user_ids)
async def fetch_channels_for_user(self, user_id: str) -> list[dict]:
return await self._request("GET", f"/users/{user_id}/channels")
async def create_channel(
self,
team_id: str,
name: str,
display_name: str,
*,
channel_type: str = "O",
purpose: str = "",
header: str = "",
) -> dict:
body = {
"team_id": team_id,
"name": name,
"display_name": display_name,
"type": channel_type,
"purpose": purpose,
"header": header,
}
return await self._request("POST", "/channels", body=body)
async def update_channel(self, channel_id: str, payload: dict) -> dict:
return await self._request("PUT", f"/channels/{channel_id}", body=payload)
async def archive_channel(self, channel_id: str) -> dict:
return await self._request("DELETE", f"/channels/{channel_id}")
async def add_channel_member(self, channel_id: str, user_id: str) -> dict:
body = {"user_id": user_id}
return await self._request("POST", f"/channels/{channel_id}/members", body=body)
async def remove_channel_member(self, channel_id: str, user_id: str) -> dict:
return await self._request("DELETE", f"/channels/{channel_id}/members/{user_id}")
async def get_channel_members(
self,
channel_id: str,
page: int = 0,
per_page: int = 60,
) -> list[dict]:
return await self._request(
"GET",
f"/channels/{channel_id}/members",
params={"page": page, "per_page": per_page},
)
async def mark_channel_viewed(self, channel_id: str) -> dict:
body = {"channel_id": channel_id}
return await self._request("POST", "/channels/members/me/view", body=body)
async def set_channel_notify_props(
self,
channel_id: str,
*,
desktop: str = "default",
email: str = "default",
push: str = "default",
mark_unread: str = "all",
ignore_channel_mentions: str = "default",
) -> dict:
body = {
"channel_id": channel_id,
"user_id": "me",
"notify_props": {
"desktop": desktop,
"email": email,
"push": push,
"mark_unread": mark_unread,
"ignore_channel_mentions": ignore_channel_mentions,
},
}
return await self._request("PUT", f"/channels/{channel_id}/members/me/notify_props", body=body)
async def search_channels(self, team_id: str, term: str) -> list[dict]:
body: dict[str, Any] = {"term": term}
return await self._request("POST", f"/teams/{team_id}/channels/search", body=body)
async def search_all_channels(
self,
term: str,
*,
page: int = 0,
per_page: int = 60,
) -> list[dict]:
body: dict[str, Any] = {"term": term}
params = {"page": page, "per_page": per_page}
return await self._request("POST", "/channels/search", body=body, params=params)
# ── Posts ────────────────────────────────────────────
async def create_post(self, payload: dict) -> dict:
return await self._request("POST", "/posts", body=payload)
async def update_post(self, post_id: str, payload: dict) -> dict:
return await self._request("PUT", f"/posts/{post_id}", body=payload)
async def delete_post(self, post_id: str) -> dict:
return await self._request("DELETE", f"/posts/{post_id}")
async def fetch_post(self, post_id: str) -> dict:
return await self._request("GET", f"/posts/{post_id}")
async def fetch_post_thread(self, post_id: str, per_page: int = 60) -> dict:
return await self._request("GET", f"/posts/{post_id}/thread", params={"perPage": per_page})
async def search_posts(
self,
team_id: str,
terms: str,
*,
is_or_search: bool = False,
page: int = 0,
per_page: int = 60,
) -> dict:
body: dict[str, Any] = {
"terms": terms,
"is_or_search": is_or_search,
}
params = {"page": page, "per_page": per_page}
return await self._request("POST", f"/teams/{team_id}/posts/search", body=body, params=params)
async def create_ephemeral_post(
self,
user_id: str,
channel_id: str,
message: str,
*,
root_id: str = "",
props: dict | None = None,
) -> dict:
body: dict[str, Any] = {
"user_id": user_id,
"post": {
"channel_id": channel_id,
"message": message,
},
}
if root_id:
body["post"]["root_id"] = root_id
if props:
body["post"]["props"] = props
return await self._request("POST", "/posts/ephemeral", body=body)
async def pin_post(self, post_id: str) -> dict:
return await self._request("POST", f"/posts/{post_id}/pin")
async def unpin_post(self, post_id: str) -> dict:
return await self._request("POST", f"/posts/{post_id}/unpin")
# ── Files ────────────────────────────────────────────
async def upload_file(
self,
channel_id: str,
file_data: bytes,
filename: str,
mime_type: str = "application/octet-stream",
) -> dict:
files = {"files": (filename, file_data, mime_type)}
data = {"channel_id": channel_id}
response = await self._client.post(
"/files",
data=data,
files=files,
)
if response.status_code >= 400:
raise parse_mattermost_error(response)
return response.json()
async def get_file(self, file_id: str) -> bytes:
response = await self._client.get(f"/files/{file_id}")
if response.status_code >= 400:
raise parse_mattermost_error(response)
return response.content
# ── Reactions ────────────────────────────────────────
async def add_reaction(self, user_id: str, post_id: str, emoji_name: str) -> dict:
return await self._request(
"POST",
"/reactions",
body={
"user_id": user_id,
"post_id": post_id,
"emoji_name": emoji_name,
},
)
async def remove_reaction(self, user_id: str, post_id: str, emoji_name: str) -> dict:
return await self._request(
"DELETE",
f"/users/{user_id}/posts/{post_id}/reactions/{emoji_name}",
)
async def fetch_post_reactions(self, post_id: str) -> list[dict]:
return await self._request("GET", f"/posts/{post_id}/reactions")
# ── Teams ────────────────────────────────────────────
async def fetch_team(self, team_id: str) -> dict:
return await self._request("GET", f"/teams/{team_id}")
# ── Typing ───────────────────────────────────────────
async def send_typing(self, channel_id: str) -> None:
await self._request("POST", "/users/me/typing", body={"channel_id": channel_id})
# ── Commands ─────────────────────────────────────────
async def list_commands(self, team_id: str, custom_only: bool = True) -> list[dict]:
params: dict[str, Any] = {"custom_only": custom_only}
return await self._request("GET", "/commands", params={**params, "team_id": team_id})
async def create_command(self, payload: dict) -> dict:
return await self._request("POST", "/commands", body=payload)
async def update_command(self, command_id: str, payload: dict) -> dict:
return await self._request("PUT", f"/commands/{command_id}", body=payload)
async def delete_command(self, command_id: str) -> dict:
return await self._request("DELETE", f"/commands/{command_id}")
async def execute_command(self, channel_id: str, command: str) -> dict:
return await self._request(
"POST",
"/commands/execute",
body={"channel_id": channel_id, "command": command},
)
async def create_direct_channel_with_retry(
client: MattermostClient,
user_ids: list[str],
max_retries: int = 3,
initial_delay_ms: int = 1000,
max_delay_ms: int = 10000,
) -> dict:
for attempt in range(max_retries + 1):
try:
return await client.create_direct_channel(user_ids)
except Exception as e:
if attempt >= max_retries:
raise
status_code = getattr(e, "status_code", 0)
if not is_retryable_error(status_code):
raise
delay = min(initial_delay_ms * (2 ** attempt), max_delay_ms)
delay = with_jitter(delay, 0.2)
logger.warning(
"DM channel creation retry %d/%d after error: %s. Waiting %d ms",
attempt + 1, max_retries, e, delay,
)
await asyncio.sleep(delay / 1000)
async def send_webhook(hook_url: str, text: str, *, username: str = "", channel: str = "") -> dict:
async with httpx.AsyncClient() as client:
body: dict[str, Any] = {"text": text}
if username:
body["username"] = username
if channel:
body["channel"] = channel
response = await client.post(hook_url, json=body)
if response.status_code >= 400:
raise parse_mattermost_error(response)
return response.json() if response.text else {}