Coverage for integrations / channels / extensions / mattermost_adapter.py: 35.5%
487 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Mattermost Channel Adapter
4Implements Mattermost messaging integration using WebSocket for real-time
5and REST API for operations.
6Based on HevolveBot extension patterns for Mattermost.
8Features:
9- WebSocket API for real-time messaging
10- REST API for operations
11- Slash commands
12- Interactive messages (buttons, menus)
13- File attachments
14- Thread support
15- Reactions
16- Direct messages and channels
17- Reconnection logic
18"""
20from __future__ import annotations
22import asyncio
23import logging
24import os
25import json
26try:
27 import aiohttp
28 HAS_AIOHTTP = True
29except ImportError:
30 HAS_AIOHTTP = False
31from typing import Optional, List, Dict, Any, Callable
32from datetime import datetime
33from dataclasses import dataclass, field
34from urllib.parse import urljoin
36try:
37 import websockets
38 from websockets.exceptions import ConnectionClosed
39 HAS_WEBSOCKETS = True
40except ImportError:
41 HAS_WEBSOCKETS = False
43from ..base import (
44 ChannelAdapter,
45 ChannelConfig,
46 ChannelStatus,
47 Message,
48 MessageType,
49 MediaAttachment,
50 SendResult,
51 ChannelConnectionError,
52 ChannelSendError,
53 ChannelRateLimitError,
54)
56logger = logging.getLogger(__name__)
59@dataclass
60class MattermostConfig(ChannelConfig):
61 """Mattermost-specific configuration."""
62 server_url: str = ""
63 personal_access_token: str = ""
64 bot_username: str = ""
65 team_id: Optional[str] = None
66 enable_slash_commands: bool = True
67 enable_interactive_messages: bool = True
68 enable_file_attachments: bool = True
69 enable_threads: bool = True
70 reconnect_delay: float = 5.0
71 max_reconnect_attempts: int = 10
72 websocket_timeout: float = 30.0
75@dataclass
76class MattermostChannel:
77 """Mattermost channel information."""
78 id: str
79 name: str
80 display_name: str
81 team_id: str
82 type: str # O=public, P=private, D=direct, G=group
83 header: Optional[str] = None
84 purpose: Optional[str] = None
85 member_count: int = 0
88@dataclass
89class MattermostUser:
90 """Mattermost user information."""
91 id: str
92 username: str
93 email: Optional[str] = None
94 first_name: Optional[str] = None
95 last_name: Optional[str] = None
96 nickname: Optional[str] = None
97 position: Optional[str] = None
100@dataclass
101class InteractiveMessage:
102 """Interactive message builder for Mattermost."""
103 text: str = ""
104 attachments: List[Dict[str, Any]] = field(default_factory=list)
105 props: Dict[str, Any] = field(default_factory=dict)
107 def add_attachment(
108 self,
109 fallback: str,
110 color: str = "#0076B4",
111 pretext: str = "",
112 text: str = "",
113 author_name: str = "",
114 title: str = "",
115 title_link: str = "",
116 fields: Optional[List[Dict[str, Any]]] = None,
117 image_url: str = "",
118 thumb_url: str = "",
119 footer: str = "",
120 actions: Optional[List[Dict[str, Any]]] = None,
121 ) -> 'InteractiveMessage':
122 """Add an attachment to the message."""
123 attachment = {
124 "fallback": fallback,
125 "color": color,
126 }
127 if pretext:
128 attachment["pretext"] = pretext
129 if text:
130 attachment["text"] = text
131 if author_name:
132 attachment["author_name"] = author_name
133 if title:
134 attachment["title"] = title
135 if title_link:
136 attachment["title_link"] = title_link
137 if fields:
138 attachment["fields"] = fields
139 if image_url:
140 attachment["image_url"] = image_url
141 if thumb_url:
142 attachment["thumb_url"] = thumb_url
143 if footer:
144 attachment["footer"] = footer
145 if actions:
146 attachment["actions"] = actions
148 self.attachments.append(attachment)
149 return self
151 def add_button(
152 self,
153 name: str,
154 integration_url: str,
155 context: Dict[str, Any] = None,
156 style: str = "default",
157 ) -> 'InteractiveMessage':
158 """Add a button action to the last attachment."""
159 if not self.attachments:
160 self.add_attachment(fallback=name)
162 action = {
163 "id": name.lower().replace(" ", "_"),
164 "name": name,
165 "integration": {
166 "url": integration_url,
167 "context": context or {},
168 },
169 "style": style, # default, primary, success, danger, warning
170 }
172 if "actions" not in self.attachments[-1]:
173 self.attachments[-1]["actions"] = []
175 self.attachments[-1]["actions"].append(action)
176 return self
178 def add_select_menu(
179 self,
180 name: str,
181 integration_url: str,
182 options: List[Dict[str, str]],
183 context: Dict[str, Any] = None,
184 ) -> 'InteractiveMessage':
185 """Add a select menu to the last attachment."""
186 if not self.attachments:
187 self.add_attachment(fallback=name)
189 action = {
190 "id": name.lower().replace(" ", "_"),
191 "name": name,
192 "type": "select",
193 "integration": {
194 "url": integration_url,
195 "context": context or {},
196 },
197 "options": options, # [{"text": "Option 1", "value": "opt1"}, ...]
198 }
200 if "actions" not in self.attachments[-1]:
201 self.attachments[-1]["actions"] = []
203 self.attachments[-1]["actions"].append(action)
204 return self
206 def to_dict(self) -> Dict[str, Any]:
207 """Convert to Mattermost message format."""
208 result = {}
209 if self.text:
210 result["message"] = self.text
211 if self.attachments:
212 result["props"] = {"attachments": self.attachments}
213 if self.props:
214 result["props"] = {**result.get("props", {}), **self.props}
215 return result
218@dataclass
219class SlashCommand:
220 """Slash command definition."""
221 trigger: str
222 description: str
223 hint: str = ""
224 handler: Optional[Callable] = None
227class MattermostAdapter(ChannelAdapter):
228 """
229 Mattermost messaging adapter with WebSocket and REST API support.
231 Usage:
232 config = MattermostConfig(
233 server_url="https://mattermost.example.com",
234 personal_access_token="your-token",
235 bot_username="mybot",
236 )
237 adapter = MattermostAdapter(config)
238 adapter.on_message(my_handler)
239 await adapter.start()
240 """
242 def __init__(self, config: MattermostConfig):
243 if not HAS_WEBSOCKETS:
244 raise ImportError(
245 "websockets not installed. "
246 "Install with: pip install websockets aiohttp"
247 )
249 super().__init__(config)
250 self.mm_config: MattermostConfig = config
251 self._session: Optional[aiohttp.ClientSession] = None
252 self._ws: Optional[websockets.WebSocketClientProtocol] = None
253 self._ws_task: Optional[asyncio.Task] = None
254 self._user_id: Optional[str] = None
255 self._channels: Dict[str, MattermostChannel] = {}
256 self._users: Dict[str, MattermostUser] = {}
257 self._slash_commands: Dict[str, SlashCommand] = {}
258 self._interactive_handlers: Dict[str, Callable] = {}
259 self._reconnect_attempts: int = 0
260 self._running: bool = False
262 @property
263 def name(self) -> str:
264 return "mattermost"
266 @property
267 def _api_url(self) -> str:
268 """Get API base URL."""
269 return urljoin(self.mm_config.server_url, "/api/v4/")
271 @property
272 def _ws_url(self) -> str:
273 """Get WebSocket URL."""
274 base = self.mm_config.server_url.replace("https://", "wss://").replace("http://", "ws://")
275 return urljoin(base, "/api/v4/websocket")
277 def _get_headers(self) -> Dict[str, str]:
278 """Get API request headers."""
279 return {
280 "Authorization": f"Bearer {self.mm_config.personal_access_token}",
281 "Content-Type": "application/json",
282 }
284 async def connect(self) -> bool:
285 """Connect to Mattermost server."""
286 if not self.mm_config.server_url:
287 logger.error("Mattermost server URL not provided")
288 return False
290 if not self.mm_config.personal_access_token:
291 logger.error("Mattermost personal access token not provided")
292 return False
294 try:
295 # Create HTTP session
296 self._session = aiohttp.ClientSession(headers=self._get_headers())
298 # Verify token and get user info
299 user_info = await self._api_get("users/me")
300 if not user_info:
301 logger.error("Failed to authenticate with Mattermost")
302 return False
304 self._user_id = user_info.get("id")
305 logger.info(f"Mattermost authenticated as: {user_info.get('username')}")
307 # Start WebSocket connection
308 self._running = True
309 self._ws_task = asyncio.create_task(self._websocket_loop())
311 self.status = ChannelStatus.CONNECTED
312 return True
314 except Exception as e:
315 logger.error(f"Failed to connect to Mattermost: {e}")
316 self.status = ChannelStatus.ERROR
317 return False
319 async def disconnect(self) -> None:
320 """Disconnect from Mattermost server."""
321 self._running = False
323 if self._ws_task:
324 self._ws_task.cancel()
325 try:
326 await self._ws_task
327 except asyncio.CancelledError:
328 pass
330 if self._ws:
331 await self._ws.close()
332 self._ws = None
334 if self._session:
335 await self._session.close()
336 self._session = None
338 self._channels.clear()
339 self._users.clear()
340 self.status = ChannelStatus.DISCONNECTED
342 async def _api_get(self, endpoint: str) -> Optional[Dict[str, Any]]:
343 """Make GET request to Mattermost API."""
344 if not self._session:
345 return None
347 try:
348 url = urljoin(self._api_url, endpoint)
349 async with self._session.get(url) as response:
350 if response.status == 200:
351 return await response.json()
352 elif response.status == 429:
353 raise ChannelRateLimitError()
354 else:
355 logger.error(f"API GET {endpoint} failed: {response.status}")
356 return None
357 except ChannelRateLimitError:
358 raise
359 except Exception as e:
360 logger.error(f"API GET {endpoint} error: {e}")
361 return None
363 async def _api_post(
364 self,
365 endpoint: str,
366 data: Dict[str, Any],
367 ) -> Optional[Dict[str, Any]]:
368 """Make POST request to Mattermost API."""
369 if not self._session:
370 return None
372 try:
373 url = urljoin(self._api_url, endpoint)
374 async with self._session.post(url, json=data) as response:
375 if response.status in (200, 201):
376 return await response.json()
377 elif response.status == 429:
378 raise ChannelRateLimitError()
379 else:
380 error_text = await response.text()
381 logger.error(f"API POST {endpoint} failed: {response.status} - {error_text}")
382 return None
383 except ChannelRateLimitError:
384 raise
385 except Exception as e:
386 logger.error(f"API POST {endpoint} error: {e}")
387 return None
389 async def _api_put(
390 self,
391 endpoint: str,
392 data: Dict[str, Any],
393 ) -> Optional[Dict[str, Any]]:
394 """Make PUT request to Mattermost API."""
395 if not self._session:
396 return None
398 try:
399 url = urljoin(self._api_url, endpoint)
400 async with self._session.put(url, json=data) as response:
401 if response.status == 200:
402 return await response.json()
403 elif response.status == 429:
404 raise ChannelRateLimitError()
405 else:
406 logger.error(f"API PUT {endpoint} failed: {response.status}")
407 return None
408 except ChannelRateLimitError:
409 raise
410 except Exception as e:
411 logger.error(f"API PUT {endpoint} error: {e}")
412 return None
414 async def _api_delete(self, endpoint: str) -> bool:
415 """Make DELETE request to Mattermost API."""
416 if not self._session:
417 return False
419 try:
420 url = urljoin(self._api_url, endpoint)
421 async with self._session.delete(url) as response:
422 return response.status in (200, 204)
423 except Exception as e:
424 logger.error(f"API DELETE {endpoint} error: {e}")
425 return False
427 async def _websocket_loop(self) -> None:
428 """WebSocket connection loop with reconnection logic."""
429 while self._running:
430 try:
431 await self._connect_websocket()
432 await self._listen_websocket()
433 except ConnectionClosed as e:
434 logger.warning(f"WebSocket connection closed: {e}")
435 if self._running:
436 await self._handle_reconnect()
437 except asyncio.CancelledError:
438 break
439 except Exception as e:
440 logger.error(f"WebSocket error: {e}")
441 if self._running:
442 await self._handle_reconnect()
444 async def _connect_websocket(self) -> None:
445 """Connect to WebSocket."""
446 extra_headers = {
447 "Authorization": f"Bearer {self.mm_config.personal_access_token}"
448 }
450 self._ws = await websockets.connect(
451 self._ws_url,
452 extra_headers=extra_headers,
453 ping_interval=20,
454 ping_timeout=self.mm_config.websocket_timeout,
455 )
457 # Send authentication challenge response
458 auth_msg = {
459 "seq": 1,
460 "action": "authentication_challenge",
461 "data": {
462 "token": self.mm_config.personal_access_token
463 }
464 }
465 await self._ws.send(json.dumps(auth_msg))
467 # Wait for auth response
468 response = await self._ws.recv()
469 auth_response = json.loads(response)
471 if auth_response.get("status") == "OK":
472 logger.info("Mattermost WebSocket authenticated")
473 self._reconnect_attempts = 0
474 self.status = ChannelStatus.CONNECTED
475 else:
476 raise ChannelConnectionError("WebSocket authentication failed")
478 async def _listen_websocket(self) -> None:
479 """Listen for WebSocket messages."""
480 while self._ws and self._running:
481 try:
482 message = await asyncio.wait_for(
483 self._ws.recv(),
484 timeout=self.mm_config.websocket_timeout
485 )
486 data = json.loads(message)
487 await self._handle_ws_event(data)
488 except asyncio.TimeoutError:
489 # Send ping to keep connection alive
490 if self._ws:
491 await self._ws.ping()
493 async def _handle_reconnect(self) -> None:
494 """Handle reconnection with backoff."""
495 self._reconnect_attempts += 1
497 if self._reconnect_attempts > self.mm_config.max_reconnect_attempts:
498 logger.error("Max reconnection attempts reached")
499 self.status = ChannelStatus.ERROR
500 self._running = False
501 return
503 delay = min(
504 self.mm_config.reconnect_delay * (2 ** self._reconnect_attempts),
505 60.0
506 )
507 logger.info(f"Reconnecting in {delay}s (attempt {self._reconnect_attempts})")
508 self.status = ChannelStatus.CONNECTING
509 await asyncio.sleep(delay)
511 async def _handle_ws_event(self, data: Dict[str, Any]) -> None:
512 """Handle WebSocket event."""
513 event_type = data.get("event")
515 if event_type == "posted":
516 await self._handle_posted_event(data)
517 elif event_type == "post_edited":
518 await self._handle_post_edited_event(data)
519 elif event_type == "post_deleted":
520 await self._handle_post_deleted_event(data)
521 elif event_type == "reaction_added":
522 await self._handle_reaction_event(data, added=True)
523 elif event_type == "reaction_removed":
524 await self._handle_reaction_event(data, added=False)
525 elif event_type == "typing":
526 pass # Ignore typing events
527 elif event_type == "channel_viewed":
528 pass # Ignore channel viewed events
530 async def _handle_posted_event(self, data: Dict[str, Any]) -> None:
531 """Handle new post event."""
532 try:
533 post_data = json.loads(data.get("data", {}).get("post", "{}"))
535 # Ignore own messages
536 if post_data.get("user_id") == self._user_id:
537 return
539 # Convert to unified message
540 message = await self._convert_message(post_data)
541 await self._dispatch_message(message)
543 except Exception as e:
544 logger.error(f"Error handling posted event: {e}")
546 async def _handle_post_edited_event(self, data: Dict[str, Any]) -> None:
547 """Handle post edited event."""
548 logger.debug(f"Post edited: {data}")
550 async def _handle_post_deleted_event(self, data: Dict[str, Any]) -> None:
551 """Handle post deleted event."""
552 logger.debug(f"Post deleted: {data}")
554 async def _handle_reaction_event(
555 self,
556 data: Dict[str, Any],
557 added: bool,
558 ) -> None:
559 """Handle reaction added/removed event."""
560 reaction_data = data.get("data", {})
561 logger.debug(f"Reaction {'added' if added else 'removed'}: {reaction_data}")
563 async def _convert_message(self, post_data: Dict[str, Any]) -> Message:
564 """Convert Mattermost post to unified Message format."""
565 user_id = post_data.get("user_id", "")
566 channel_id = post_data.get("channel_id", "")
568 # Get user info
569 user = await self._get_user(user_id)
570 sender_name = user.username if user else user_id
572 # Get channel info
573 channel = await self._get_channel(channel_id)
574 is_group = channel.type in ("O", "P", "G") if channel else True
576 # Check for bot mention
577 text = post_data.get("message", "")
578 is_mentioned = f"@{self.mm_config.bot_username}" in text
580 # Process file attachments
581 media = []
582 file_ids = post_data.get("file_ids", [])
583 if file_ids:
584 for file_id in file_ids:
585 file_info = await self._api_get(f"files/{file_id}/info")
586 if file_info:
587 media_type = MessageType.DOCUMENT
588 mime_type = file_info.get("mime_type", "")
589 if mime_type.startswith("image/"):
590 media_type = MessageType.IMAGE
591 elif mime_type.startswith("video/"):
592 media_type = MessageType.VIDEO
593 elif mime_type.startswith("audio/"):
594 media_type = MessageType.AUDIO
596 media.append(MediaAttachment(
597 type=media_type,
598 file_id=file_id,
599 file_name=file_info.get("name"),
600 mime_type=mime_type,
601 file_size=file_info.get("size"),
602 ))
604 # Get thread info
605 reply_to_id = post_data.get("root_id") or None
607 return Message(
608 id=post_data.get("id", ""),
609 channel=self.name,
610 sender_id=user_id,
611 sender_name=sender_name,
612 chat_id=channel_id,
613 text=text,
614 media=media,
615 reply_to_id=reply_to_id,
616 timestamp=datetime.fromtimestamp(post_data.get("create_at", 0) / 1000),
617 is_group=is_group,
618 is_bot_mentioned=is_mentioned,
619 raw={
620 "team_id": channel.team_id if channel else None,
621 "channel_name": channel.name if channel else None,
622 "channel_display_name": channel.display_name if channel else None,
623 "props": post_data.get("props", {}),
624 "metadata": post_data.get("metadata", {}),
625 },
626 )
628 async def _get_user(self, user_id: str) -> Optional[MattermostUser]:
629 """Get user information (cached)."""
630 if user_id in self._users:
631 return self._users[user_id]
633 user_data = await self._api_get(f"users/{user_id}")
634 if user_data:
635 user = MattermostUser(
636 id=user_data.get("id"),
637 username=user_data.get("username"),
638 email=user_data.get("email"),
639 first_name=user_data.get("first_name"),
640 last_name=user_data.get("last_name"),
641 nickname=user_data.get("nickname"),
642 position=user_data.get("position"),
643 )
644 self._users[user_id] = user
645 return user
646 return None
648 async def _get_channel(self, channel_id: str) -> Optional[MattermostChannel]:
649 """Get channel information (cached)."""
650 if channel_id in self._channels:
651 return self._channels[channel_id]
653 channel_data = await self._api_get(f"channels/{channel_id}")
654 if channel_data:
655 channel = MattermostChannel(
656 id=channel_data.get("id"),
657 name=channel_data.get("name"),
658 display_name=channel_data.get("display_name"),
659 team_id=channel_data.get("team_id"),
660 type=channel_data.get("type"),
661 header=channel_data.get("header"),
662 purpose=channel_data.get("purpose"),
663 )
664 self._channels[channel_id] = channel
665 return channel
666 return None
668 async def send_message(
669 self,
670 chat_id: str,
671 text: str,
672 reply_to: Optional[str] = None,
673 media: Optional[List[MediaAttachment]] = None,
674 buttons: Optional[List[Dict]] = None,
675 ) -> SendResult:
676 """Send a message to a Mattermost channel."""
677 try:
678 post_data = {
679 "channel_id": chat_id,
680 "message": text,
681 }
683 # Add thread reply
684 if reply_to and self.mm_config.enable_threads:
685 post_data["root_id"] = reply_to
687 # Build interactive message if buttons provided
688 if buttons and self.mm_config.enable_interactive_messages:
689 interactive = self._build_interactive_message(text, buttons)
690 post_data.update(interactive.to_dict())
692 # Handle file attachments
693 file_ids = []
694 if media and self.mm_config.enable_file_attachments:
695 for m in media:
696 if m.file_path:
697 file_id = await self._upload_file(chat_id, m.file_path)
698 if file_id:
699 file_ids.append(file_id)
701 if file_ids:
702 post_data["file_ids"] = file_ids
704 result = await self._api_post("posts", post_data)
706 if result:
707 return SendResult(
708 success=True,
709 message_id=result.get("id"),
710 raw=result,
711 )
712 else:
713 return SendResult(success=False, error="Failed to send message")
715 except Exception as e:
716 logger.error(f"Failed to send Mattermost message: {e}")
717 return SendResult(success=False, error=str(e))
719 def _build_interactive_message(
720 self,
721 text: str,
722 buttons: List[Dict],
723 ) -> InteractiveMessage:
724 """Build interactive message with buttons."""
725 interactive = InteractiveMessage(text=text)
726 interactive.add_attachment(fallback=text)
728 for btn in buttons:
729 if btn.get("url"):
730 # URL button - use markdown link in text
731 interactive.text += f"\n[{btn['text']}]({btn['url']})"
732 else:
733 # Action button
734 callback_data = btn.get("callback_data", btn["text"])
735 webhook_url = btn.get("webhook_url", "")
736 if webhook_url:
737 interactive.add_button(
738 name=btn["text"],
739 integration_url=webhook_url,
740 context={"action": callback_data},
741 style=btn.get("style", "default"),
742 )
744 return interactive
746 async def _upload_file(
747 self,
748 channel_id: str,
749 file_path: str,
750 ) -> Optional[str]:
751 """Upload a file to Mattermost."""
752 if not self._session:
753 return None
755 try:
756 url = urljoin(self._api_url, "files")
758 with open(file_path, "rb") as f:
759 data = aiohttp.FormData()
760 data.add_field("channel_id", channel_id)
761 data.add_field(
762 "files",
763 f,
764 filename=os.path.basename(file_path),
765 )
767 async with self._session.post(url, data=data) as response:
768 if response.status == 201:
769 result = await response.json()
770 file_infos = result.get("file_infos", [])
771 if file_infos:
772 return file_infos[0].get("id")
773 return None
775 except Exception as e:
776 logger.error(f"Failed to upload file: {e}")
777 return None
779 async def edit_message(
780 self,
781 chat_id: str,
782 message_id: str,
783 text: str,
784 buttons: Optional[List[Dict]] = None,
785 ) -> SendResult:
786 """Edit an existing Mattermost message."""
787 try:
788 post_data = {
789 "id": message_id,
790 "message": text,
791 }
793 if buttons and self.mm_config.enable_interactive_messages:
794 interactive = self._build_interactive_message(text, buttons)
795 post_data.update(interactive.to_dict())
797 result = await self._api_put(f"posts/{message_id}", post_data)
799 if result:
800 return SendResult(success=True, message_id=message_id)
801 else:
802 return SendResult(success=False, error="Failed to edit message")
804 except Exception as e:
805 logger.error(f"Failed to edit Mattermost message: {e}")
806 return SendResult(success=False, error=str(e))
808 async def delete_message(self, chat_id: str, message_id: str) -> bool:
809 """Delete a Mattermost message."""
810 return await self._api_delete(f"posts/{message_id}")
812 async def send_typing(self, chat_id: str) -> None:
813 """Send typing indicator via WebSocket."""
814 if self._ws and self._user_id:
815 try:
816 typing_msg = {
817 "action": "user_typing",
818 "seq": 2,
819 "data": {
820 "channel_id": chat_id,
821 "parent_id": "",
822 }
823 }
824 await self._ws.send(json.dumps(typing_msg))
825 except Exception:
826 pass
828 async def get_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]:
829 """Get information about a Mattermost channel."""
830 channel = await self._get_channel(chat_id)
831 if channel:
832 return {
833 "id": channel.id,
834 "name": channel.name,
835 "display_name": channel.display_name,
836 "team_id": channel.team_id,
837 "type": channel.type,
838 "header": channel.header,
839 "purpose": channel.purpose,
840 }
841 return None
843 # Mattermost-specific methods
845 def register_slash_command(
846 self,
847 trigger: str,
848 description: str,
849 handler: Callable,
850 hint: str = "",
851 ) -> None:
852 """Register a slash command handler."""
853 if not self.mm_config.enable_slash_commands:
854 return
856 self._slash_commands[trigger] = SlashCommand(
857 trigger=trigger,
858 description=description,
859 hint=hint,
860 handler=handler,
861 )
863 async def handle_slash_command(
864 self,
865 command: str,
866 text: str,
867 user_id: str,
868 channel_id: str,
869 trigger_id: str,
870 ) -> Optional[Dict[str, Any]]:
871 """Handle incoming slash command from webhook."""
872 if command in self._slash_commands:
873 cmd = self._slash_commands[command]
874 if cmd.handler:
875 return await cmd.handler(
876 command=command,
877 text=text,
878 user_id=user_id,
879 channel_id=channel_id,
880 trigger_id=trigger_id,
881 )
882 return None
884 def register_interactive_handler(
885 self,
886 action_id: str,
887 handler: Callable,
888 ) -> None:
889 """Register an interactive message action handler."""
890 self._interactive_handlers[action_id] = handler
892 async def handle_interactive_action(
893 self,
894 action_id: str,
895 context: Dict[str, Any],
896 user_id: str,
897 channel_id: str,
898 post_id: str,
899 ) -> Optional[Dict[str, Any]]:
900 """Handle interactive message action from webhook."""
901 if action_id in self._interactive_handlers:
902 handler = self._interactive_handlers[action_id]
903 return await handler(
904 action_id=action_id,
905 context=context,
906 user_id=user_id,
907 channel_id=channel_id,
908 post_id=post_id,
909 )
910 return None
912 async def send_interactive_message(
913 self,
914 chat_id: str,
915 interactive: InteractiveMessage,
916 reply_to: Optional[str] = None,
917 ) -> SendResult:
918 """Send an interactive message with attachments."""
919 try:
920 post_data = {
921 "channel_id": chat_id,
922 **interactive.to_dict(),
923 }
925 if reply_to:
926 post_data["root_id"] = reply_to
928 result = await self._api_post("posts", post_data)
930 if result:
931 return SendResult(success=True, message_id=result.get("id"))
932 else:
933 return SendResult(success=False, error="Failed to send message")
935 except Exception as e:
936 logger.error(f"Failed to send interactive message: {e}")
937 return SendResult(success=False, error=str(e))
939 async def add_reaction(
940 self,
941 chat_id: str,
942 message_id: str,
943 emoji_name: str,
944 ) -> bool:
945 """Add a reaction to a message."""
946 try:
947 result = await self._api_post("reactions", {
948 "user_id": self._user_id,
949 "post_id": message_id,
950 "emoji_name": emoji_name,
951 })
952 return result is not None
953 except Exception as e:
954 logger.error(f"Failed to add reaction: {e}")
955 return False
957 async def remove_reaction(
958 self,
959 chat_id: str,
960 message_id: str,
961 emoji_name: str,
962 ) -> bool:
963 """Remove a reaction from a message."""
964 try:
965 return await self._api_delete(
966 f"users/{self._user_id}/posts/{message_id}/reactions/{emoji_name}"
967 )
968 except Exception as e:
969 logger.error(f"Failed to remove reaction: {e}")
970 return False
972 async def send_thread_reply(
973 self,
974 chat_id: str,
975 root_id: str,
976 text: str,
977 media: Optional[List[MediaAttachment]] = None,
978 ) -> SendResult:
979 """Send a reply in a thread."""
980 return await self.send_message(
981 chat_id=chat_id,
982 text=text,
983 reply_to=root_id,
984 media=media,
985 )
987 async def get_thread_posts(
988 self,
989 chat_id: str,
990 root_id: str,
991 ) -> List[Dict[str, Any]]:
992 """Get all posts in a thread."""
993 try:
994 result = await self._api_get(f"posts/{root_id}/thread")
995 if result:
996 posts = result.get("posts", {})
997 order = result.get("order", [])
998 return [posts[post_id] for post_id in order if post_id in posts]
999 return []
1000 except Exception as e:
1001 logger.error(f"Failed to get thread posts: {e}")
1002 return []
1004 async def create_direct_channel(self, user_id: str) -> Optional[str]:
1005 """Create or get direct message channel with a user."""
1006 try:
1007 result = await self._api_post("channels/direct", [self._user_id, user_id])
1008 if result:
1009 return result.get("id")
1010 return None
1011 except Exception as e:
1012 logger.error(f"Failed to create direct channel: {e}")
1013 return None
1015 async def get_channel_members(self, channel_id: str) -> List[MattermostUser]:
1016 """Get members of a channel."""
1017 try:
1018 result = await self._api_get(f"channels/{channel_id}/members")
1019 if result:
1020 users = []
1021 for member in result:
1022 user = await self._get_user(member.get("user_id"))
1023 if user:
1024 users.append(user)
1025 return users
1026 return []
1027 except Exception as e:
1028 logger.error(f"Failed to get channel members: {e}")
1029 return []
1031 async def download_file(self, file_id: str) -> Optional[bytes]:
1032 """Download a file by ID."""
1033 if not self._session:
1034 return None
1036 try:
1037 url = urljoin(self._api_url, f"files/{file_id}")
1038 async with self._session.get(url) as response:
1039 if response.status == 200:
1040 return await response.read()
1041 return None
1042 except Exception as e:
1043 logger.error(f"Failed to download file: {e}")
1044 return None
1047def create_mattermost_adapter(
1048 server_url: str = None,
1049 personal_access_token: str = None,
1050 bot_username: str = None,
1051 **kwargs
1052) -> MattermostAdapter:
1053 """
1054 Factory function to create Mattermost adapter.
1056 Args:
1057 server_url: Mattermost server URL (or set MATTERMOST_SERVER_URL env var)
1058 personal_access_token: Personal access token (or set MATTERMOST_TOKEN env var)
1059 bot_username: Bot username (or set MATTERMOST_BOT_USERNAME env var)
1060 **kwargs: Additional config options
1062 Returns:
1063 Configured MattermostAdapter
1064 """
1065 server_url = server_url or os.getenv("MATTERMOST_SERVER_URL")
1066 personal_access_token = personal_access_token or os.getenv("MATTERMOST_TOKEN")
1067 bot_username = bot_username or os.getenv("MATTERMOST_BOT_USERNAME", "bot")
1069 if not server_url:
1070 raise ValueError("Mattermost server URL required")
1071 if not personal_access_token:
1072 raise ValueError("Mattermost personal access token required")
1074 config = MattermostConfig(
1075 server_url=server_url,
1076 personal_access_token=personal_access_token,
1077 bot_username=bot_username,
1078 **kwargs
1079 )
1080 return MattermostAdapter(config)