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

1""" 

2Mattermost Channel Adapter 

3 

4Implements Mattermost messaging integration using WebSocket for real-time 

5and REST API for operations. 

6Based on HevolveBot extension patterns for Mattermost. 

7 

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""" 

19 

20from __future__ import annotations 

21 

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 

35 

36try: 

37 import websockets 

38 from websockets.exceptions import ConnectionClosed 

39 HAS_WEBSOCKETS = True 

40except ImportError: 

41 HAS_WEBSOCKETS = False 

42 

43from ..base import ( 

44 ChannelAdapter, 

45 ChannelConfig, 

46 ChannelStatus, 

47 Message, 

48 MessageType, 

49 MediaAttachment, 

50 SendResult, 

51 ChannelConnectionError, 

52 ChannelSendError, 

53 ChannelRateLimitError, 

54) 

55 

56logger = logging.getLogger(__name__) 

57 

58 

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 

73 

74 

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 

86 

87 

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 

98 

99 

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) 

106 

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 

147 

148 self.attachments.append(attachment) 

149 return self 

150 

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) 

161 

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 } 

171 

172 if "actions" not in self.attachments[-1]: 

173 self.attachments[-1]["actions"] = [] 

174 

175 self.attachments[-1]["actions"].append(action) 

176 return self 

177 

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) 

188 

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 } 

199 

200 if "actions" not in self.attachments[-1]: 

201 self.attachments[-1]["actions"] = [] 

202 

203 self.attachments[-1]["actions"].append(action) 

204 return self 

205 

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 

216 

217 

218@dataclass 

219class SlashCommand: 

220 """Slash command definition.""" 

221 trigger: str 

222 description: str 

223 hint: str = "" 

224 handler: Optional[Callable] = None 

225 

226 

227class MattermostAdapter(ChannelAdapter): 

228 """ 

229 Mattermost messaging adapter with WebSocket and REST API support. 

230 

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 """ 

241 

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 ) 

248 

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 

261 

262 @property 

263 def name(self) -> str: 

264 return "mattermost" 

265 

266 @property 

267 def _api_url(self) -> str: 

268 """Get API base URL.""" 

269 return urljoin(self.mm_config.server_url, "/api/v4/") 

270 

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") 

276 

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 } 

283 

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 

289 

290 if not self.mm_config.personal_access_token: 

291 logger.error("Mattermost personal access token not provided") 

292 return False 

293 

294 try: 

295 # Create HTTP session 

296 self._session = aiohttp.ClientSession(headers=self._get_headers()) 

297 

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 

303 

304 self._user_id = user_info.get("id") 

305 logger.info(f"Mattermost authenticated as: {user_info.get('username')}") 

306 

307 # Start WebSocket connection 

308 self._running = True 

309 self._ws_task = asyncio.create_task(self._websocket_loop()) 

310 

311 self.status = ChannelStatus.CONNECTED 

312 return True 

313 

314 except Exception as e: 

315 logger.error(f"Failed to connect to Mattermost: {e}") 

316 self.status = ChannelStatus.ERROR 

317 return False 

318 

319 async def disconnect(self) -> None: 

320 """Disconnect from Mattermost server.""" 

321 self._running = False 

322 

323 if self._ws_task: 

324 self._ws_task.cancel() 

325 try: 

326 await self._ws_task 

327 except asyncio.CancelledError: 

328 pass 

329 

330 if self._ws: 

331 await self._ws.close() 

332 self._ws = None 

333 

334 if self._session: 

335 await self._session.close() 

336 self._session = None 

337 

338 self._channels.clear() 

339 self._users.clear() 

340 self.status = ChannelStatus.DISCONNECTED 

341 

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 

346 

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 

362 

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 

371 

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 

388 

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 

397 

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 

413 

414 async def _api_delete(self, endpoint: str) -> bool: 

415 """Make DELETE request to Mattermost API.""" 

416 if not self._session: 

417 return False 

418 

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 

426 

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() 

443 

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 } 

449 

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 ) 

456 

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)) 

466 

467 # Wait for auth response 

468 response = await self._ws.recv() 

469 auth_response = json.loads(response) 

470 

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") 

477 

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() 

492 

493 async def _handle_reconnect(self) -> None: 

494 """Handle reconnection with backoff.""" 

495 self._reconnect_attempts += 1 

496 

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 

502 

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) 

510 

511 async def _handle_ws_event(self, data: Dict[str, Any]) -> None: 

512 """Handle WebSocket event.""" 

513 event_type = data.get("event") 

514 

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 

529 

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", "{}")) 

534 

535 # Ignore own messages 

536 if post_data.get("user_id") == self._user_id: 

537 return 

538 

539 # Convert to unified message 

540 message = await self._convert_message(post_data) 

541 await self._dispatch_message(message) 

542 

543 except Exception as e: 

544 logger.error(f"Error handling posted event: {e}") 

545 

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}") 

549 

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}") 

553 

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}") 

562 

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", "") 

567 

568 # Get user info 

569 user = await self._get_user(user_id) 

570 sender_name = user.username if user else user_id 

571 

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 

575 

576 # Check for bot mention 

577 text = post_data.get("message", "") 

578 is_mentioned = f"@{self.mm_config.bot_username}" in text 

579 

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 

595 

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 )) 

603 

604 # Get thread info 

605 reply_to_id = post_data.get("root_id") or None 

606 

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 ) 

627 

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] 

632 

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 

647 

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] 

652 

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 

667 

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 } 

682 

683 # Add thread reply 

684 if reply_to and self.mm_config.enable_threads: 

685 post_data["root_id"] = reply_to 

686 

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()) 

691 

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) 

700 

701 if file_ids: 

702 post_data["file_ids"] = file_ids 

703 

704 result = await self._api_post("posts", post_data) 

705 

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") 

714 

715 except Exception as e: 

716 logger.error(f"Failed to send Mattermost message: {e}") 

717 return SendResult(success=False, error=str(e)) 

718 

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) 

727 

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 ) 

743 

744 return interactive 

745 

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 

754 

755 try: 

756 url = urljoin(self._api_url, "files") 

757 

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 ) 

766 

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 

774 

775 except Exception as e: 

776 logger.error(f"Failed to upload file: {e}") 

777 return None 

778 

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 } 

792 

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()) 

796 

797 result = await self._api_put(f"posts/{message_id}", post_data) 

798 

799 if result: 

800 return SendResult(success=True, message_id=message_id) 

801 else: 

802 return SendResult(success=False, error="Failed to edit message") 

803 

804 except Exception as e: 

805 logger.error(f"Failed to edit Mattermost message: {e}") 

806 return SendResult(success=False, error=str(e)) 

807 

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}") 

811 

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 

827 

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 

842 

843 # Mattermost-specific methods 

844 

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 

855 

856 self._slash_commands[trigger] = SlashCommand( 

857 trigger=trigger, 

858 description=description, 

859 hint=hint, 

860 handler=handler, 

861 ) 

862 

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 

883 

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 

891 

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 

911 

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 } 

924 

925 if reply_to: 

926 post_data["root_id"] = reply_to 

927 

928 result = await self._api_post("posts", post_data) 

929 

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") 

934 

935 except Exception as e: 

936 logger.error(f"Failed to send interactive message: {e}") 

937 return SendResult(success=False, error=str(e)) 

938 

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 

956 

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 

971 

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 ) 

986 

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 [] 

1003 

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 

1014 

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 [] 

1030 

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 

1035 

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 

1045 

1046 

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. 

1055 

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 

1061 

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") 

1068 

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") 

1073 

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)