新增 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: 类型定义
120 lines
3.5 KiB
Python
120 lines
3.5 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.mattermost.client import MattermostClient
|
|
from yuxi.channel.extensions.mattermost.errors import MattermostError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MattermostMediaAdapter:
|
|
MAX_UPLOAD_MB = 50
|
|
|
|
def __init__(self, client: MattermostClient):
|
|
self.client = client
|
|
|
|
async def upload(
|
|
self,
|
|
channel_id: str,
|
|
file_path: str | Path,
|
|
mime_type: str | None = None,
|
|
filename: str | None = None,
|
|
) -> dict | None:
|
|
path = Path(file_path)
|
|
if not path.is_file():
|
|
logger.error("File not found: %s", file_path)
|
|
return None
|
|
|
|
if path.stat().st_size > self.MAX_UPLOAD_MB * 1024 * 1024:
|
|
logger.error("File too large: %s", file_path)
|
|
return None
|
|
|
|
data = path.read_bytes()
|
|
fname = filename or path.name
|
|
mt = mime_type or _guess_mime_type(path)
|
|
|
|
try:
|
|
return await self.client.upload_file(channel_id, data, fname, mt)
|
|
except MattermostError as e:
|
|
logger.error("Failed to upload file %s: %s", fname, e)
|
|
return None
|
|
|
|
async def upload_bytes(
|
|
self,
|
|
channel_id: str,
|
|
data: bytes,
|
|
filename: str,
|
|
mime_type: str = "application/octet-stream",
|
|
) -> dict | None:
|
|
if len(data) > self.MAX_UPLOAD_MB * 1024 * 1024:
|
|
logger.error("Data too large (%d bytes)", len(data))
|
|
return None
|
|
|
|
try:
|
|
return await self.client.upload_file(channel_id, data, filename, mime_type)
|
|
except MattermostError as e:
|
|
logger.error("Failed to upload: %s", e)
|
|
return None
|
|
|
|
async def download(self, file_id: str) -> bytes | None:
|
|
try:
|
|
return await self.client.get_file(file_id)
|
|
except MattermostError as e:
|
|
logger.error("Failed to download file %s: %s", file_id, e)
|
|
return None
|
|
|
|
async def download_to_path(self, file_id: str, dest_path: str | Path) -> bool:
|
|
data = await self.download(file_id)
|
|
if data is None:
|
|
return False
|
|
Path(dest_path).write_bytes(data)
|
|
return True
|
|
|
|
async def download_url(
|
|
self,
|
|
media_url: str,
|
|
timeout: float = 30.0,
|
|
) -> bytes | None:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client:
|
|
response = await client.get(media_url)
|
|
response.raise_for_status()
|
|
return response.content
|
|
except Exception as e:
|
|
logger.error("Failed to download URL %s: %s", media_url, e)
|
|
return None
|
|
|
|
|
|
_MIME_MAP = {
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".gif": "image/gif",
|
|
".webp": "image/webp",
|
|
".bmp": "image/bmp",
|
|
".svg": "image/svg+xml",
|
|
".pdf": "application/pdf",
|
|
".doc": "application/msword",
|
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
".xls": "application/vnd.ms-excel",
|
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
".txt": "text/plain",
|
|
".csv": "text/csv",
|
|
".json": "application/json",
|
|
".xml": "application/xml",
|
|
".zip": "application/zip",
|
|
".mp4": "video/mp4",
|
|
".mp3": "audio/mpeg",
|
|
".wav": "audio/wav",
|
|
".ogg": "audio/ogg",
|
|
}
|
|
|
|
|
|
def _guess_mime_type(path: Path) -> str:
|
|
suffix = path.suffix.lower()
|
|
return _MIME_MAP.get(suffix, "application/octet-stream")
|