from __future__ import annotations import logging import re from typing import Any from yuxi.channel.extensions.mattermost.client import ( MattermostClient, create_direct_channel_with_retry, ) from yuxi.channel.extensions.mattermost.errors import ( MattermostError, ) from yuxi.channel.extensions.mattermost.format import ( safe_split_markdown, truncate_markdown, ) from yuxi.channel.extensions.mattermost.types import DeliveryTarget, SendContext logger = logging.getLogger(__name__) class MattermostOutboundAdapter: def __init__(self, client: MattermostClient, account_id: str = ""): self.client = client self.account_id = account_id self._dm_cache: dict[str, str] = {} async def send_text( self, channel_id: str, content: str, *, root_id: str | None = None, file_ids: list[str] | None = None, text_chunk_limit: int = 4000, priority: str | None = None, request_ack: bool = False, ) -> dict | None: chunks = safe_split_markdown(content, text_chunk_limit) last_post: dict | None = None for chunk in chunks: payload: dict[str, Any] = { "channel_id": channel_id, "message": chunk, } if root_id: payload["root_id"] = root_id if file_ids and chunk == chunks[-1]: payload["file_ids"] = file_ids if priority: payload["metadata"] = { "priority": { "priority": priority, "requested_ack": request_ack, } } try: last_post = await self.client.create_post(payload) except MattermostError as e: logger.error("Failed to send text to channel %s: %s", channel_id, e) raise return last_post async def edit_message(self, post_id: str, content: str) -> dict | None: payload = {"message": truncate_markdown(content), "props": {}} try: return await self.client.update_post(post_id, payload) except MattermostError as e: logger.error("Failed to edit post %s: %s", post_id, e) return None async def delete_message(self, post_id: str) -> bool: try: await self.client.delete_post(post_id) return True except MattermostError: return False async def send_media( self, channel_id: str, file_data: bytes, filename: str, mime_type: str = "application/octet-stream", *, root_id: str | None = None, caption: str | None = None, ) -> dict | None: try: upload = await self.client.upload_file(channel_id, file_data, filename, mime_type) file_infos = upload.get("file_infos", []) fid = file_infos[0]["id"] if file_infos else None if not fid: return None payload: dict[str, Any] = { "channel_id": channel_id, "message": caption or "", "file_ids": [fid], } if root_id: payload["root_id"] = root_id return await self.client.create_post(payload) except MattermostError as e: logger.error("Failed to send media to channel %s: %s", channel_id, e) return None async def send_reaction(self, user_id: str, post_id: str, emoji_name: str) -> dict | None: try: return await self.client.add_reaction(user_id, post_id, emoji_name) except MattermostError as e: logger.error("Failed to add reaction: %s", e) return None async def remove_reaction(self, user_id: str, post_id: str, emoji_name: str) -> bool: try: await self.client.remove_reaction(user_id, post_id, emoji_name) return True except MattermostError: return False async def send_typing_indicator(self, channel_id: str) -> None: try: await self.client.send_typing(channel_id) except MattermostError: pass async def create_dm_channel(self, user_id: str) -> str | None: if user_id in self._dm_cache: return self._dm_cache[user_id] try: result = await create_direct_channel_with_retry( self.client, [user_id, "me"], ) channel_id = result.get("id") if channel_id: self._dm_cache[user_id] = channel_id return channel_id except MattermostError as e: logger.error("Failed to create DM channel: %s", e) return None async def send_ephemeral( self, user_id: str, channel_id: str, content: str, *, root_id: str = "", ) -> dict | None: truncated = truncate_markdown(content) try: return await self.client.create_ephemeral_post( user_id=user_id, channel_id=channel_id, message=truncated, root_id=root_id, ) except MattermostError as e: logger.error("Failed to send ephemeral to user %s in channel %s: %s", user_id, channel_id, e) return None async def set_bot_status(self, status: str) -> dict | None: try: me = await self.client.fetch_me() return await self.client.update_user_status(me["id"], status) except MattermostError as e: logger.error("Failed to set bot status: %s", e) return None async def pin_message(self, post_id: str) -> dict | None: try: return await self.client.pin_post(post_id) except MattermostError as e: logger.error("Failed to pin post %s: %s", post_id, e) return None async def unpin_message(self, post_id: str) -> dict | None: try: return await self.client.unpin_post(post_id) except MattermostError as e: logger.error("Failed to unpin post %s: %s", post_id, e) return None def parse_mattermost_target(raw: str) -> DeliveryTarget: raw = raw.strip() if re.fullmatch(r"[a-z0-9]{26}", raw, re.IGNORECASE): return DeliveryTarget(kind="channel", id=raw) if raw.startswith("channel:"): inner = raw[len("channel:"):] if inner.startswith("#"): return DeliveryTarget(kind="channel-name", name=inner[1:]) return DeliveryTarget(kind="channel", id=inner) if raw.startswith("user:"): return DeliveryTarget(kind="user", id=raw[len("user:"):]) if raw.startswith("@"): return DeliveryTarget(kind="user", name=raw[1:]) if raw.startswith("#"): return DeliveryTarget(kind="channel-name", name=raw[1:]) return DeliveryTarget(kind="channel", id=raw) async def resolve_send_context( target_str: str, client: MattermostClient, account_id: str, bot_user_id: str, ) -> SendContext: target = parse_mattermost_target(target_str) if target.kind == "channel" and target.id: return SendContext( channel_id=target.id, token=client.bot_token, base_url=client.base_url, account_id=account_id, ) if target.kind == "user" and target.id: channel_id = await _resolve_dm_channel(client, target.id, bot_user_id) return SendContext( channel_id=channel_id or target.id, token=client.bot_token, base_url=client.base_url, account_id=account_id, ) if target.kind == "user" and target.name: try: user = await client.fetch_user_by_username(target.name) uid = user["id"] channel_id = await _resolve_dm_channel(client, uid, bot_user_id) return SendContext( channel_id=channel_id or uid, token=client.bot_token, base_url=client.base_url, account_id=account_id, ) except MattermostError: return SendContext( channel_id="", token=client.bot_token, base_url=client.base_url, account_id=account_id, ) if target.kind == "channel-name" and target.name: try: me = await client.fetch_me() teams = await client.fetch_user_teams(me["id"]) for team_data in teams: tid = team_data["id"] try: ch = await client.fetch_channel_by_name(tid, target.name) return SendContext( channel_id=ch["id"], token=client.bot_token, base_url=client.base_url, account_id=account_id, ) except MattermostError: continue except MattermostError: pass return SendContext( channel_id="", token=client.bot_token, base_url=client.base_url, account_id=account_id, ) async def _resolve_dm_channel( client: MattermostClient, user_id: str, bot_user_id: str, ) -> str | None: try: result = await create_direct_channel_with_retry( client, [user_id, bot_user_id], ) return result.get("id") except MattermostError: return None def map_mattermost_channel_type_to_chat_type(mm_type: str) -> str: if mm_type == "D": return "direct" if mm_type == "G": return "group" return "channel"