feat: add Mattermost channel support
This commit is contained in:
@@ -0,0 +1,661 @@
|
||||
"""Mattermost channel implementation using WebSocket + REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config_base import Base
|
||||
from nanobot.utils.helpers import split_message
|
||||
|
||||
MATTERMOST_MAX_MESSAGE_LEN = 16383
|
||||
MATTERMOST_WS_RECONNECT_BASE_DELAY = 1
|
||||
MATTERMOST_WS_RECONNECT_MAX_DELAY = 30
|
||||
|
||||
_CHANNEL_TYPES = {
|
||||
"O": "public",
|
||||
"P": "private",
|
||||
"D": "dm",
|
||||
"G": "group",
|
||||
}
|
||||
|
||||
|
||||
class MattermostDMConfig(Base):
|
||||
"""Mattermost DM policy configuration."""
|
||||
enabled: bool = True
|
||||
policy: str = "open"
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MattermostConfig(Base):
|
||||
"""Mattermost channel configuration."""
|
||||
enabled: bool = False
|
||||
server_url: str = ""
|
||||
token: str = ""
|
||||
team_id: str = ""
|
||||
allow_from_match_mode: str = "id"
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
group_policy: str = "mention"
|
||||
group_allow_from: list[str] = Field(default_factory=list)
|
||||
reply_in_thread: bool = True
|
||||
include_thread_context: bool = True
|
||||
thread_context_limit: int = 20
|
||||
streaming: bool = True
|
||||
streaming_max_chars: int = 16000
|
||||
react_emoji: str = "eyes"
|
||||
done_emoji: str = "white_check_mark"
|
||||
send_progress: bool = True
|
||||
send_tool_hints: bool = False
|
||||
dm: MattermostDMConfig = Field(default_factory=MattermostDMConfig)
|
||||
|
||||
|
||||
def _server_url_to_ws_url(server_url: str) -> str:
|
||||
if server_url.startswith("https://"):
|
||||
return server_url.replace("https://", "wss://", 1) + "/api/v4/websocket"
|
||||
if server_url.startswith("http://"):
|
||||
return server_url.replace("http://", "ws://", 1) + "/api/v4/websocket"
|
||||
return server_url + "/api/v4/websocket"
|
||||
|
||||
|
||||
class MattermostChannel(BaseChannel):
|
||||
"""Mattermost channel using WebSocket + REST API."""
|
||||
|
||||
name = "mattermost"
|
||||
display_name = "Mattermost"
|
||||
|
||||
_BOT_MENTION_RE = re.compile(r"@\S+")
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return MattermostConfig().model_dump(by_alias=True)
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = MattermostConfig.model_validate(config)
|
||||
super().__init__(config, bus)
|
||||
self.config: MattermostConfig = config
|
||||
self._server_url = config.server_url.rstrip("/")
|
||||
self._ws_url = _server_url_to_ws_url(self._server_url)
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
self._ws_task: asyncio.Task | None = None
|
||||
self._self_id: str | None = None
|
||||
self._self_username: str | None = None
|
||||
self._self_email: str | None = None
|
||||
self._usernames: dict[str, str] = {}
|
||||
self._user_emails: dict[str, str] = {}
|
||||
self._channel_types: dict[str, str] = {}
|
||||
self._stream_posts: dict[str, str] = {}
|
||||
self._stream_buffers: dict[str, str] = {}
|
||||
self._stream_last_content: dict[str, str] = {}
|
||||
self._stream_committed: dict[str, str] = {}
|
||||
self._stream_root_ids: dict[str, str] = {}
|
||||
self._thread_context_attempted: set[str] = set()
|
||||
|
||||
# Lifecycle ----------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self.config.server_url or not self.config.token:
|
||||
self.logger.error("serverUrl and token must be configured")
|
||||
return
|
||||
|
||||
if self._http_client is None:
|
||||
self._http_client = httpx.AsyncClient(
|
||||
base_url=self._server_url,
|
||||
headers={"Authorization": f"Bearer {self.config.token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await self._http_client.get("/api/v4/users/me")
|
||||
resp.raise_for_status()
|
||||
me = resp.json()
|
||||
self._self_id = me.get("id")
|
||||
self._self_username = me.get("username")
|
||||
self._self_email = me.get("email", "")
|
||||
self.logger.info("bot @{} connected", self._self_username)
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to identify bot user: {}", e)
|
||||
await self._cleanup_http()
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._ws_task = asyncio.create_task(self._ws_listen_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._ws_task:
|
||||
self._ws_task.cancel()
|
||||
try:
|
||||
await self._ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._ws_task = None
|
||||
await self._cleanup_http()
|
||||
|
||||
async def _cleanup_http(self) -> None:
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
# WebSocket ----------------------------------------------------------------
|
||||
|
||||
async def _ws_listen_loop(self) -> None:
|
||||
import websockets
|
||||
|
||||
delay = MATTERMOST_WS_RECONNECT_BASE_DELAY
|
||||
while self._running:
|
||||
try:
|
||||
async with websockets.connect(
|
||||
self._ws_url,
|
||||
additional_headers={"Authorization": f"Bearer {self.config.token}"},
|
||||
ping_interval=20,
|
||||
ping_timeout=10,
|
||||
) as ws:
|
||||
self.logger.debug("websocket connected")
|
||||
delay = MATTERMOST_WS_RECONNECT_BASE_DELAY
|
||||
async for raw in ws:
|
||||
await self._handle_ws_message(json.loads(raw))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
if not self._running:
|
||||
break
|
||||
self.logger.warning("websocket error: {} (reconnect in {}s)", e, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, MATTERMOST_WS_RECONNECT_MAX_DELAY)
|
||||
|
||||
async def _handle_ws_message(self, msg: dict[str, Any]) -> None:
|
||||
event = msg.get("event", "")
|
||||
if event == "posted":
|
||||
await self._handle_posted_event(msg)
|
||||
elif event == "action":
|
||||
await self._handle_action_event(msg)
|
||||
elif event == "post_deleted":
|
||||
await self._handle_post_deleted_event(msg)
|
||||
|
||||
# Event: posted ------------------------------------------------------------
|
||||
|
||||
async def _handle_posted_event(self, msg: dict[str, Any]) -> None:
|
||||
data = msg.get("data", {})
|
||||
broadcast = msg.get("broadcast", {})
|
||||
|
||||
raw_post = data.get("post", "{}")
|
||||
try:
|
||||
post = json.loads(raw_post) if isinstance(raw_post, str) else raw_post
|
||||
except json.JSONDecodeError:
|
||||
self.logger.warning("failed to parse post json")
|
||||
return
|
||||
|
||||
sender_id = post.get("user_id", "")
|
||||
channel_id = post.get("channel_id", "")
|
||||
message_text = post.get("message", "")
|
||||
root_id = post.get("root_id", "") or ""
|
||||
post_id = post.get("id", "")
|
||||
file_ids: list[str] = post.get("file_ids", [])
|
||||
|
||||
if self._self_id and sender_id == self._self_id:
|
||||
return
|
||||
if not sender_id or not channel_id:
|
||||
return
|
||||
|
||||
channel_type_code = data.get("channel_type", "")
|
||||
channel_type = _CHANNEL_TYPES.get(channel_type_code, "public")
|
||||
is_dm = channel_type == "dm"
|
||||
|
||||
team_id = broadcast.get("team_id", "")
|
||||
if self.config.team_id and team_id and team_id != self.config.team_id:
|
||||
if not is_dm:
|
||||
return
|
||||
|
||||
if not await self._is_allowed(sender_id, channel_id, channel_type):
|
||||
if is_dm and self.config.dm.enabled:
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=channel_id,
|
||||
content="",
|
||||
is_dm=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not is_dm and not self._should_respond_in_channel(message_text, channel_id):
|
||||
return
|
||||
|
||||
message_text = self._strip_bot_mention(message_text)
|
||||
|
||||
thread_ts = root_id if root_id else None
|
||||
if self.config.reply_in_thread and not thread_ts and not is_dm:
|
||||
thread_ts = post_id
|
||||
session_key = (
|
||||
f"mattermost:{channel_id}:{thread_ts}" if thread_ts and root_id else None
|
||||
)
|
||||
|
||||
try:
|
||||
await self._add_reaction(channel_id, post_id, self.config.react_emoji)
|
||||
except Exception:
|
||||
self.logger.debug("add reaction failed")
|
||||
|
||||
media_paths: list[str] = []
|
||||
for fid in file_ids:
|
||||
path = await self._download_file(fid)
|
||||
if path:
|
||||
media_paths.append(path)
|
||||
|
||||
content = message_text
|
||||
if root_id and self.config.include_thread_context:
|
||||
content = await self._with_thread_context(
|
||||
content, channel_id=channel_id, root_id=root_id,
|
||||
)
|
||||
|
||||
mm_meta: dict[str, Any] = {
|
||||
"post_id": post_id,
|
||||
"root_id": root_id,
|
||||
"channel_type": channel_type,
|
||||
}
|
||||
if thread_ts:
|
||||
mm_meta["thread_ts"] = thread_ts
|
||||
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=channel_id,
|
||||
content=content,
|
||||
media=media_paths,
|
||||
metadata={
|
||||
"mattermost": mm_meta,
|
||||
"message_id": post_id,
|
||||
},
|
||||
session_key=session_key,
|
||||
is_dm=is_dm,
|
||||
)
|
||||
|
||||
# Event: action ------------------------------------------------------------
|
||||
|
||||
async def _handle_action_event(self, msg: dict[str, Any]) -> None:
|
||||
data = msg.get("data", {})
|
||||
sender_id = data.get("user_id", "")
|
||||
channel_id = data.get("channel_id", "")
|
||||
context = data.get("context", {}) or {}
|
||||
value = context.get("selected_option", "")
|
||||
|
||||
if not sender_id or not channel_id or not value:
|
||||
return
|
||||
|
||||
channel_type = self._channel_types.get(channel_id, "public")
|
||||
if not await self._is_allowed(sender_id, channel_id, channel_type):
|
||||
return
|
||||
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=channel_id,
|
||||
content=value,
|
||||
metadata={"mattermost": {"channel_type": channel_type, "is_action": True}},
|
||||
)
|
||||
|
||||
# Event: post_deleted ------------------------------------------------------
|
||||
|
||||
async def _handle_post_deleted_event(self, msg: dict[str, Any]) -> None:
|
||||
data = msg.get("data", {})
|
||||
raw_post = data.get("post", "{}")
|
||||
try:
|
||||
post = json.loads(raw_post) if isinstance(raw_post, str) else raw_post
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
post_id = post.get("id", "")
|
||||
if not post_id:
|
||||
return
|
||||
to_remove = [sid for sid, pid in self._stream_posts.items() if pid == post_id]
|
||||
for sid in to_remove:
|
||||
self._stream_posts.pop(sid, None)
|
||||
self._stream_buffers.pop(sid, None)
|
||||
self._stream_last_content.pop(sid, None)
|
||||
self._stream_committed.pop(sid, None)
|
||||
|
||||
# Permission / policy ------------------------------------------------------
|
||||
|
||||
def is_allowed(self, sender_id: str) -> bool:
|
||||
return True
|
||||
|
||||
async def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
|
||||
if channel_type == "dm":
|
||||
if not self.config.dm.enabled:
|
||||
return False
|
||||
if self.config.dm.policy == "allowlist":
|
||||
return await self._match_sender(sender_id, self.config.dm.allow_from)
|
||||
return True
|
||||
|
||||
if self.config.group_policy == "allowlist":
|
||||
return chat_id in self.config.group_allow_from
|
||||
return True
|
||||
|
||||
def _should_respond_in_channel(self, text: str, chat_id: str) -> bool:
|
||||
if self.config.group_policy == "open":
|
||||
return True
|
||||
if self.config.group_policy == "mention":
|
||||
return self._is_mentioned(text)
|
||||
if self.config.group_policy == "allowlist":
|
||||
return chat_id in self.config.group_allow_from
|
||||
return False
|
||||
|
||||
def _is_mentioned(self, text: str) -> bool:
|
||||
if not self._self_username:
|
||||
return False
|
||||
return f"@{self._self_username}" in text
|
||||
|
||||
def _strip_bot_mention(self, text: str) -> str:
|
||||
if not text or not self._self_username:
|
||||
return text
|
||||
return re.sub(rf"@{re.escape(self._self_username)}\s*", "", text).strip()
|
||||
|
||||
async def _match_sender(self, sender_id: str, allow_list: list[str]) -> bool:
|
||||
if not allow_list:
|
||||
return False
|
||||
if "*" in allow_list:
|
||||
return True
|
||||
mode = self.config.allow_from_match_mode
|
||||
if mode == "id":
|
||||
return sender_id in allow_list
|
||||
if mode == "username":
|
||||
username = await self._resolve_username(sender_id)
|
||||
return username in allow_list if username else False
|
||||
if mode == "email":
|
||||
email = await self._resolve_email(sender_id)
|
||||
return email in allow_list if email else False
|
||||
return False
|
||||
|
||||
async def _resolve_username(self, user_id: str) -> str | None:
|
||||
if user_id in self._usernames:
|
||||
return self._usernames[user_id]
|
||||
try:
|
||||
user = await self._api_get(f"/api/v4/users/{user_id}")
|
||||
self._usernames[user_id] = user.get("username", "")
|
||||
return self._usernames[user_id]
|
||||
except Exception as e:
|
||||
self.logger.warning("failed to resolve username for {}: {}", user_id, e)
|
||||
return None
|
||||
|
||||
async def _resolve_email(self, user_id: str) -> str | None:
|
||||
if user_id in self._user_emails:
|
||||
return self._user_emails[user_id]
|
||||
try:
|
||||
user = await self._api_get(f"/api/v4/users/{user_id}")
|
||||
self._user_emails[user_id] = user.get("email", "").lower()
|
||||
return self._user_emails[user_id]
|
||||
except Exception as e:
|
||||
self.logger.warning("failed to resolve email for {}: {}", user_id, e)
|
||||
return None
|
||||
|
||||
# Thread context -----------------------------------------------------------
|
||||
|
||||
async def _with_thread_context(self, text: str, *, channel_id: str, root_id: str) -> str:
|
||||
key = f"{channel_id}:{root_id}"
|
||||
if key in self._thread_context_attempted:
|
||||
return text
|
||||
self._thread_context_attempted.add(key)
|
||||
|
||||
try:
|
||||
data = await self._api_get(
|
||||
f"/api/v4/posts/{root_id}/thread?perPage={max(1, self.config.thread_context_limit)}",
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning("thread context unavailable for {}: {}", key, e)
|
||||
return text
|
||||
|
||||
posts = data.get("posts", {})
|
||||
order = data.get("order", [])
|
||||
if not order:
|
||||
return text
|
||||
|
||||
lines: list[str] = []
|
||||
for pid in order:
|
||||
post = posts.get(pid, {})
|
||||
if post.get("id") == root_id:
|
||||
continue
|
||||
if post.get("user_id") == self._self_id:
|
||||
label = "bot"
|
||||
else:
|
||||
label = f"<{post.get('user_id', 'unknown')}>"
|
||||
msg_text = (post.get("message", "") or "").strip()
|
||||
if not msg_text:
|
||||
continue
|
||||
if len(msg_text) > 500:
|
||||
msg_text = msg_text[:500] + "\u2026"
|
||||
lines.append(f"- {label}: {msg_text}")
|
||||
|
||||
if not lines:
|
||||
return text
|
||||
return "Mattermost thread context before this mention:\n" + "\n".join(lines) + f"\n\nCurrent message:\n{text}"
|
||||
|
||||
# Send ---------------------------------------------------------------------
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
if not self._http_client:
|
||||
self.logger.warning("client not initialized")
|
||||
return
|
||||
|
||||
try:
|
||||
chat_id = msg.chat_id
|
||||
meta = msg.metadata or {}
|
||||
mm_meta = meta.get("mattermost", {}) or {}
|
||||
root_id = mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id")
|
||||
|
||||
file_ids: list[str] = []
|
||||
for media_path in msg.media or []:
|
||||
try:
|
||||
fid = await self._upload_file(chat_id, media_path)
|
||||
if fid:
|
||||
file_ids.append(fid)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to upload file {}", media_path)
|
||||
|
||||
if msg.content or file_ids:
|
||||
text = msg.content or " "
|
||||
chunks = split_message(text, MATTERMOST_MAX_MESSAGE_LEN)
|
||||
for chunk in chunks:
|
||||
await self._create_post(
|
||||
chat_id, chunk,
|
||||
root_id=root_id if self.config.reply_in_thread else None,
|
||||
file_ids=file_ids or None,
|
||||
)
|
||||
|
||||
if not meta.get("_progress") and meta.get("message_id"):
|
||||
try:
|
||||
await self._remove_reaction(meta["message_id"], self.config.react_emoji)
|
||||
except Exception:
|
||||
self.logger.debug("remove reaction failed")
|
||||
if self.config.done_emoji:
|
||||
try:
|
||||
await self._add_reaction(chat_id, meta["message_id"], self.config.done_emoji)
|
||||
except Exception:
|
||||
self.logger.debug("done reaction failed")
|
||||
|
||||
except Exception:
|
||||
self.logger.exception("Error sending message")
|
||||
raise
|
||||
|
||||
# Streaming -----------------------------------------------------------------
|
||||
|
||||
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
if not self._http_client:
|
||||
return
|
||||
|
||||
meta = metadata or {}
|
||||
stream_id = meta.get("_stream_id", chat_id)
|
||||
|
||||
if meta.get("_stream_end"):
|
||||
self._stream_buffers.pop(stream_id, None)
|
||||
self._stream_last_content.pop(stream_id, None)
|
||||
post_id = self._stream_posts.pop(stream_id, None)
|
||||
final = self._stream_committed.pop(stream_id, None)
|
||||
if post_id and final and self.config.done_emoji:
|
||||
try:
|
||||
await self._add_reaction(chat_id, post_id, self.config.done_emoji)
|
||||
except Exception:
|
||||
self.logger.debug("done reaction failed")
|
||||
if not meta.get("_progress") and meta.get("message_id"):
|
||||
try:
|
||||
await self._remove_reaction(meta["message_id"], self.config.react_emoji)
|
||||
except Exception:
|
||||
self.logger.debug("remove reaction failed")
|
||||
return
|
||||
|
||||
if not delta.strip():
|
||||
return
|
||||
|
||||
committed = self._stream_committed.get(stream_id, "")
|
||||
buf = committed + delta
|
||||
self._stream_buffers[stream_id] = buf
|
||||
|
||||
if stream_id not in self._stream_posts:
|
||||
try:
|
||||
mm_meta = (meta.get("mattermost", {}) or {}) if isinstance(meta.get("mattermost"), dict) else {}
|
||||
root_id = mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id")
|
||||
post = await self._create_post(
|
||||
chat_id, buf,
|
||||
root_id=root_id if self.config.reply_in_thread else None,
|
||||
)
|
||||
self._stream_posts[stream_id] = post["id"]
|
||||
self._stream_committed[stream_id] = buf
|
||||
if root_id and self.config.reply_in_thread:
|
||||
self._stream_root_ids[stream_id] = root_id
|
||||
except Exception as e:
|
||||
self.logger.warning("stream initial post failed: {}", e)
|
||||
raise
|
||||
else:
|
||||
post_id = self._stream_posts[stream_id]
|
||||
if buf == self._stream_last_content.get(stream_id):
|
||||
return
|
||||
self._stream_last_content[stream_id] = buf
|
||||
if len(buf) > self.config.streaming_max_chars:
|
||||
try:
|
||||
stream_root = self._stream_root_ids.get(stream_id)
|
||||
post = await self._create_post(
|
||||
chat_id, buf,
|
||||
root_id=stream_root if self.config.reply_in_thread else None,
|
||||
)
|
||||
self._stream_posts[stream_id] = post["id"]
|
||||
self._stream_committed[stream_id] = buf
|
||||
except Exception as e:
|
||||
self.logger.warning("stream overflow post failed: {}", e)
|
||||
raise
|
||||
else:
|
||||
try:
|
||||
await self._edit_post(post_id, buf)
|
||||
self._stream_committed[stream_id] = buf
|
||||
except Exception as e:
|
||||
self.logger.warning("stream edit failed: {}", e)
|
||||
raise
|
||||
|
||||
# API helpers ---------------------------------------------------------------
|
||||
|
||||
async def _api_get(self, path: str) -> dict[str, Any]:
|
||||
resp = await self._http_client.get(path)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def _api_post(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
|
||||
resp = await self._http_client.post(path, json=json_data)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def _api_put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
|
||||
resp = await self._http_client.put(path, json=json_data)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def _create_post(
|
||||
self,
|
||||
channel_id: str,
|
||||
message: str,
|
||||
*,
|
||||
root_id: str | None = None,
|
||||
file_ids: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"channel_id": channel_id,
|
||||
"message": message,
|
||||
}
|
||||
if root_id:
|
||||
body["root_id"] = root_id
|
||||
if file_ids:
|
||||
body["file_ids"] = file_ids
|
||||
return await self._api_post("/api/v4/posts", body)
|
||||
|
||||
async def _edit_post(self, post_id: str, message: str) -> dict[str, Any]:
|
||||
return await self._api_put(f"/api/v4/posts/{post_id}", {"id": post_id, "message": message})
|
||||
|
||||
async def _upload_file(self, channel_id: str, file_path: str) -> str | None:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
self.logger.warning("file not found: {}", file_path)
|
||||
return None
|
||||
|
||||
try:
|
||||
files = {"files": (path.name, path.read_bytes())}
|
||||
resp = await self._http_client.post(
|
||||
"/api/v4/files",
|
||||
data={"channel_id": channel_id},
|
||||
files=files,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
infos = data.get("file_infos", [])
|
||||
if infos:
|
||||
return infos[0].get("id")
|
||||
except Exception as e:
|
||||
self.logger.warning("file upload failed for {}: {}", file_path, e)
|
||||
return None
|
||||
|
||||
async def _download_file(self, file_id: str) -> str | None:
|
||||
try:
|
||||
resp = await self._http_client.get(f"/api/v4/files/{file_id}")
|
||||
resp.raise_for_status()
|
||||
info = resp.json()
|
||||
name = info.get("name", file_id)
|
||||
out = Path(get_media_dir("mattermost")) / f"{file_id}_{name}"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dl = await self._http_client.get(f"/api/v4/files/{file_id}/download")
|
||||
dl.raise_for_status()
|
||||
out.write_bytes(dl.content)
|
||||
return str(out)
|
||||
except Exception as e:
|
||||
self.logger.warning("file download failed for {}: {}", file_id, e)
|
||||
return None
|
||||
|
||||
async def _add_reaction(self, channel_id: str, post_id: str, emoji: str) -> None:
|
||||
if not self._self_id or not emoji:
|
||||
return
|
||||
await self._api_post("/api/v4/reactions", {
|
||||
"user_id": self._self_id,
|
||||
"post_id": post_id,
|
||||
"emoji_name": emoji,
|
||||
})
|
||||
|
||||
async def _remove_reaction(self, post_id: str, emoji: str) -> None:
|
||||
if not self._self_id or not emoji:
|
||||
return
|
||||
resp = await self._http_client.delete(
|
||||
f"/api/v4/users/{self._self_id}/posts/{post_id}/reactions/{emoji}",
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
self.logger.debug("remove reaction failed: {} {}", resp.status_code, resp.text)
|
||||
|
||||
async def resolve_channel_type(self, channel_id: str) -> str:
|
||||
if channel_id in self._channel_types:
|
||||
return self._channel_types[channel_id]
|
||||
try:
|
||||
data = await self._api_get(f"/api/v4/channels/{channel_id}")
|
||||
ctype = _CHANNEL_TYPES.get(data.get("type", ""), "public")
|
||||
self._channel_types[channel_id] = ctype
|
||||
return ctype
|
||||
except Exception:
|
||||
return "public"
|
||||
Reference in New Issue
Block a user