Coverage for integrations / channels / slack_adapter.py: 30.7%

274 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2Slack Channel Adapter 

3 

4Implements Slack messaging using the Bolt framework with Socket Mode. 

5Supports workspaces, channels, threads, and rich formatting. 

6 

7Features: 

8- Text messages with mrkdwn formatting 

9- File uploads 

10- Threads 

11- Reactions 

12- Slash commands 

13- Interactive components (buttons, modals) 

14- Socket Mode (no public URL needed) 

15""" 

16 

17from __future__ import annotations 

18 

19import asyncio 

20import logging 

21import os 

22from typing import Optional, List, Dict, Any 

23from datetime import datetime 

24 

25try: 

26 from slack_bolt.async_app import AsyncApp 

27 from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler 

28 from slack_sdk.web.async_client import AsyncWebClient 

29 from slack_sdk.errors import SlackApiError 

30 HAS_SLACK = True 

31except ImportError: 

32 HAS_SLACK = False 

33 

34from .base import ( 

35 ChannelAdapter, 

36 ChannelConfig, 

37 ChannelStatus, 

38 Message, 

39 MessageType, 

40 MediaAttachment, 

41 SendResult, 

42 ChannelConnectionError, 

43 ChannelSendError, 

44 ChannelRateLimitError, 

45) 

46from .room_capable import RoomCapableAdapter, UnsupportedRoomError 

47 

48logger = logging.getLogger(__name__) 

49 

50 

51class SlackAdapter(ChannelAdapter, RoomCapableAdapter): 

52 """ 

53 Slack messaging adapter using Bolt framework with Socket Mode. 

54 

55 Usage: 

56 config = ChannelConfig( 

57 token="xoxb-bot-token", 

58 extra={"app_token": "xapp-app-token"} 

59 ) 

60 adapter = SlackAdapter(config) 

61 adapter.on_message(my_handler) 

62 await adapter.start() 

63 """ 

64 

65 def __init__(self, config: ChannelConfig): 

66 if not HAS_SLACK: 

67 raise ImportError( 

68 "slack-bolt not installed. " 

69 "Install with: pip install slack-bolt slack-sdk" 

70 ) 

71 

72 super().__init__(config) 

73 self._app: Optional[AsyncApp] = None 

74 self._handler: Optional[AsyncSocketModeHandler] = None 

75 self._client: Optional[AsyncWebClient] = None 

76 self._bot_user_id: Optional[str] = None 

77 self._bot_name: Optional[str] = None 

78 self._app_token: str = config.extra.get("app_token", "") 

79 

80 @property 

81 def name(self) -> str: 

82 return "slack" 

83 

84 async def connect(self) -> bool: 

85 """Connect to Slack using Socket Mode.""" 

86 if not self.config.token: 

87 logger.error("Slack bot token not provided") 

88 return False 

89 

90 if not self._app_token: 

91 logger.error("Slack app token not provided (required for Socket Mode)") 

92 return False 

93 

94 try: 

95 # Create Bolt app 

96 self._app = AsyncApp(token=self.config.token) 

97 self._client = self._app.client 

98 

99 # Get bot info 

100 auth_response = await self._client.auth_test() 

101 self._bot_user_id = auth_response.get("user_id") 

102 self._bot_name = auth_response.get("user") 

103 logger.info(f"Slack connected as @{self._bot_name}") 

104 

105 # Register event handlers 

106 self._register_handlers() 

107 

108 # Start Socket Mode handler 

109 self._handler = AsyncSocketModeHandler(self._app, self._app_token) 

110 asyncio.create_task(self._handler.start_async()) 

111 

112 self.status = ChannelStatus.CONNECTED 

113 return True 

114 

115 except SlackApiError as e: 

116 logger.error(f"Failed to connect to Slack: {e}") 

117 self.status = ChannelStatus.ERROR 

118 return False 

119 except Exception as e: 

120 logger.error(f"Slack connection error: {e}") 

121 self.status = ChannelStatus.ERROR 

122 return False 

123 

124 async def disconnect(self) -> None: 

125 """Disconnect from Slack.""" 

126 if self._handler: 

127 try: 

128 await self._handler.close_async() 

129 except Exception as e: 

130 logger.error(f"Error disconnecting from Slack: {e}") 

131 finally: 

132 self._handler = None 

133 self._app = None 

134 self._client = None 

135 self.status = ChannelStatus.DISCONNECTED 

136 

137 def _register_handlers(self) -> None: 

138 """Register Slack event handlers.""" 

139 if not self._app: 

140 return 

141 

142 @self._app.event("message") 

143 async def handle_message(event: Dict, say): 

144 # Ignore bot messages 

145 if event.get("bot_id") or event.get("subtype") == "bot_message": 

146 return 

147 

148 # Ignore message edits and deletes 

149 if event.get("subtype") in ("message_changed", "message_deleted"): 

150 return 

151 

152 message = self._convert_message(event) 

153 await self._dispatch_message(message) 

154 

155 @self._app.event("app_mention") 

156 async def handle_mention(event: Dict, say): 

157 # Handle direct mentions separately if needed 

158 message = self._convert_message(event) 

159 message.is_bot_mentioned = True 

160 await self._dispatch_message(message) 

161 

162 @self._app.event("reaction_added") 

163 async def handle_reaction(event: Dict): 

164 # Can be used for reaction-based workflows 

165 logger.debug(f"Reaction added: {event.get('reaction')} by {event.get('user')}") 

166 

167 def _convert_message(self, event: Dict[str, Any]) -> Message: 

168 """Convert Slack event to unified Message format.""" 

169 # Check if bot is mentioned 

170 is_mentioned = False 

171 text = event.get("text", "") 

172 if self._bot_user_id and f"<@{self._bot_user_id}>" in text: 

173 is_mentioned = True 

174 # Remove mention from text 

175 text = text.replace(f"<@{self._bot_user_id}>", "").strip() 

176 

177 # Process attachments/files 

178 media = [] 

179 files = event.get("files", []) 

180 for file in files: 

181 file_type = file.get("filetype", "") 

182 if file_type in ("png", "jpg", "jpeg", "gif", "webp"): 

183 media_type = MessageType.IMAGE 

184 elif file_type in ("mp4", "mov", "avi", "webm"): 

185 media_type = MessageType.VIDEO 

186 elif file_type in ("mp3", "wav", "ogg", "m4a"): 

187 media_type = MessageType.AUDIO 

188 else: 

189 media_type = MessageType.DOCUMENT 

190 

191 media.append(MediaAttachment( 

192 type=media_type, 

193 url=file.get("url_private"), 

194 file_id=file.get("id"), 

195 file_name=file.get("name"), 

196 file_size=file.get("size"), 

197 mime_type=file.get("mimetype"), 

198 )) 

199 

200 # Determine if this is in a channel/group or DM 

201 channel_type = event.get("channel_type", "") 

202 is_group = channel_type in ("channel", "group") 

203 

204 return Message( 

205 id=event.get("ts", ""), 

206 channel=self.name, 

207 sender_id=event.get("user", ""), 

208 sender_name=None, # Would need to fetch from users.info 

209 chat_id=event.get("channel", ""), 

210 text=text, 

211 media=media, 

212 reply_to_id=event.get("thread_ts"), 

213 timestamp=datetime.fromtimestamp(float(event.get("ts", "0").split(".")[0])), 

214 is_group=is_group, 

215 is_bot_mentioned=is_mentioned, 

216 raw=event, 

217 ) 

218 

219 async def send_message( 

220 self, 

221 chat_id: str, 

222 text: str, 

223 reply_to: Optional[str] = None, 

224 media: Optional[List[MediaAttachment]] = None, 

225 buttons: Optional[List[Dict]] = None, 

226 ) -> SendResult: 

227 """Send a message to a Slack channel.""" 

228 if not self._client: 

229 return SendResult(success=False, error="Not connected") 

230 

231 try: 

232 # Build message payload 

233 payload: Dict[str, Any] = { 

234 "channel": chat_id, 

235 "text": text, 

236 } 

237 

238 # Handle thread reply 

239 if reply_to: 

240 payload["thread_ts"] = reply_to 

241 

242 # Handle buttons/blocks 

243 if buttons: 

244 payload["blocks"] = self._build_blocks(text, buttons) 

245 

246 # Handle media (file upload) 

247 if media and len(media) > 0: 

248 return await self._send_with_media(chat_id, text, media, reply_to) 

249 

250 # Send text message 

251 response = await self._client.chat_postMessage(**payload) 

252 

253 return SendResult( 

254 success=True, 

255 message_id=response.get("ts"), 

256 raw=dict(response), 

257 ) 

258 

259 except SlackApiError as e: 

260 if e.response.get("error") == "ratelimited": 

261 retry_after = int(e.response.headers.get("Retry-After", 60)) 

262 raise ChannelRateLimitError(retry_after=retry_after) 

263 logger.error(f"Failed to send Slack message: {e}") 

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

265 except Exception as e: 

266 logger.error(f"Failed to send Slack message: {e}") 

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

268 

269 async def _send_with_media( 

270 self, 

271 chat_id: str, 

272 text: str, 

273 media: List[MediaAttachment], 

274 reply_to: Optional[str], 

275 ) -> SendResult: 

276 """Send message with file attachment.""" 

277 if not self._client: 

278 return SendResult(success=False, error="Not connected") 

279 

280 first_media = media[0] 

281 

282 try: 

283 upload_args: Dict[str, Any] = { 

284 "channels": chat_id, 

285 "initial_comment": text, 

286 } 

287 

288 if reply_to: 

289 upload_args["thread_ts"] = reply_to 

290 

291 if first_media.file_path: 

292 upload_args["file"] = first_media.file_path 

293 elif first_media.url: 

294 # Download and upload 

295 upload_args["content"] = first_media.url 

296 

297 if first_media.file_name: 

298 upload_args["filename"] = first_media.file_name 

299 

300 response = await self._client.files_upload_v2(**upload_args) 

301 

302 file_info = response.get("file", {}) 

303 return SendResult( 

304 success=True, 

305 message_id=file_info.get("id"), 

306 raw=dict(response), 

307 ) 

308 

309 except SlackApiError as e: 

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

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

312 

313 def _build_blocks(self, text: str, buttons: List[Dict]) -> List[Dict]: 

314 """Build Slack blocks with buttons.""" 

315 blocks = [ 

316 { 

317 "type": "section", 

318 "text": {"type": "mrkdwn", "text": text}, 

319 } 

320 ] 

321 

322 # Add button actions 

323 actions = [] 

324 for btn in buttons: 

325 if btn.get("url"): 

326 actions.append({ 

327 "type": "button", 

328 "text": {"type": "plain_text", "text": btn["text"]}, 

329 "url": btn["url"], 

330 }) 

331 else: 

332 actions.append({ 

333 "type": "button", 

334 "text": {"type": "plain_text", "text": btn["text"]}, 

335 "action_id": btn.get("callback_data", btn["text"]), 

336 "value": btn.get("callback_data", btn["text"]), 

337 }) 

338 

339 if actions: 

340 blocks.append({ 

341 "type": "actions", 

342 "elements": actions[:5], # Slack limits to 5 buttons per block 

343 }) 

344 

345 return blocks 

346 

347 async def edit_message( 

348 self, 

349 chat_id: str, 

350 message_id: str, 

351 text: str, 

352 buttons: Optional[List[Dict]] = None, 

353 ) -> SendResult: 

354 """Edit an existing Slack message.""" 

355 if not self._client: 

356 return SendResult(success=False, error="Not connected") 

357 

358 try: 

359 payload: Dict[str, Any] = { 

360 "channel": chat_id, 

361 "ts": message_id, 

362 "text": text, 

363 } 

364 

365 if buttons: 

366 payload["blocks"] = self._build_blocks(text, buttons) 

367 

368 response = await self._client.chat_update(**payload) 

369 

370 return SendResult( 

371 success=True, 

372 message_id=response.get("ts"), 

373 raw=dict(response), 

374 ) 

375 

376 except SlackApiError as e: 

377 logger.error(f"Failed to edit Slack message: {e}") 

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

379 

380 async def delete_message(self, chat_id: str, message_id: str) -> bool: 

381 """Delete a Slack message.""" 

382 if not self._client: 

383 return False 

384 

385 try: 

386 await self._client.chat_delete(channel=chat_id, ts=message_id) 

387 return True 

388 except SlackApiError as e: 

389 logger.error(f"Failed to delete Slack message: {e}") 

390 return False 

391 

392 async def send_typing(self, chat_id: str) -> None: 

393 """Slack doesn't have a typing indicator API for bots.""" 

394 # Slack bots cannot send typing indicators 

395 pass 

396 

397 async def get_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]: 

398 """Get information about a Slack channel.""" 

399 if not self._client: 

400 return None 

401 

402 try: 

403 response = await self._client.conversations_info(channel=chat_id) 

404 channel = response.get("channel", {}) 

405 return { 

406 "id": channel.get("id"), 

407 "name": channel.get("name"), 

408 "is_channel": channel.get("is_channel"), 

409 "is_group": channel.get("is_group"), 

410 "is_im": channel.get("is_im"), 

411 "is_private": channel.get("is_private"), 

412 "topic": channel.get("topic", {}).get("value"), 

413 "purpose": channel.get("purpose", {}).get("value"), 

414 } 

415 except SlackApiError as e: 

416 logger.error(f"Failed to get Slack channel info: {e}") 

417 return None 

418 

419 async def add_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool: 

420 """Add a reaction to a message.""" 

421 if not self._client: 

422 return False 

423 

424 try: 

425 # Remove colons if present 

426 emoji = emoji.strip(":") 

427 await self._client.reactions_add( 

428 channel=chat_id, 

429 timestamp=message_id, 

430 name=emoji, 

431 ) 

432 return True 

433 except SlackApiError as e: 

434 logger.error(f"Failed to add Slack reaction: {e}") 

435 return False 

436 

437 async def get_user_info(self, user_id: str) -> Optional[Dict[str, Any]]: 

438 """Get information about a Slack user.""" 

439 if not self._client: 

440 return None 

441 

442 try: 

443 response = await self._client.users_info(user=user_id) 

444 user = response.get("user", {}) 

445 return { 

446 "id": user.get("id"), 

447 "name": user.get("name"), 

448 "real_name": user.get("real_name"), 

449 "display_name": user.get("profile", {}).get("display_name"), 

450 "email": user.get("profile", {}).get("email"), 

451 "is_bot": user.get("is_bot"), 

452 } 

453 except SlackApiError as e: 

454 logger.error(f"Failed to get Slack user info: {e}") 

455 return None 

456 

457 async def open_dm(self, user_id: str) -> Optional[str]: 

458 """Open a DM channel with a user.""" 

459 if not self._client: 

460 return None 

461 

462 try: 

463 response = await self._client.conversations_open(users=user_id) 

464 return response.get("channel", {}).get("id") 

465 except SlackApiError as e: 

466 logger.error(f"Failed to open Slack DM: {e}") 

467 return None 

468 

469 # ─── UNIF-G2: RoomCapableAdapter implementation ────────────────── 

470 

471 async def join_room(self, room_id: str, 

472 role: str = 'participant') -> bool: 

473 """Join a Slack channel as the bot's presence. 

474 

475 Slack semantics: ``conversations.join`` works on PUBLIC channels 

476 only. For private channels, the bot must be invited by an admin 

477 — there's no programmatic self-invite. We treat 

478 "already a member" as success. 

479 

480 IM (DM) channel ids → ``UnsupportedRoomError`` (DMs aren't 

481 rooms — use ``send_message`` for 1:1). 

482 """ 

483 if not self._client: 

484 return False 

485 if not room_id: 

486 return False 

487 # Slack channel ids: C* = public, G* = private/group, D* = DM, 

488 # MP* = multi-party DM. Reject DMs. 

489 if room_id.startswith('D') or room_id.startswith('MP'): 

490 raise UnsupportedRoomError( 

491 "Slack DMs are not rooms — use send_message for 1:1.") 

492 try: 

493 await self._client.conversations_join(channel=room_id) 

494 logger.info( 

495 "Slack.join_room: channel %s joined (role=%s)", 

496 room_id, role) 

497 return True 

498 except SlackApiError as e: 

499 err = (e.response or {}).get('error', '') \ 

500 if hasattr(e, 'response') else '' 

501 # already_in_channel: idempotent success. 

502 if err == 'already_in_channel': 

503 return True 

504 # method_not_supported_for_channel_type: private channels 

505 # can only be joined via invite; treat as graceful False so 

506 # Join_External_Room can surface "ask an admin to invite the 

507 # bot to this private channel" to the user. 

508 if err in ('method_not_supported_for_channel_type', 

509 'channel_not_found', 

510 'is_archived', 

511 'missing_scope'): 

512 logger.info( 

513 "Slack.join_room: refused for %s (%s)", 

514 room_id, err) 

515 return False 

516 logger.error( 

517 "Slack.join_room: API error for %s: %s", 

518 room_id, e) 

519 return False 

520 except Exception as e: 

521 logger.error( 

522 "Slack.join_room: unexpected error for %s: %s", 

523 room_id, e) 

524 return False 

525 

526 async def leave_room(self, room_id: str) -> bool: 

527 """Leave a Slack channel. Idempotent on already-absent.""" 

528 if not self._client: 

529 return False 

530 if not room_id: 

531 return False 

532 if room_id.startswith('D') or room_id.startswith('MP'): 

533 # Can't "leave" a DM. Caller should treat as no-op. 

534 return False 

535 try: 

536 await self._client.conversations_leave(channel=room_id) 

537 logger.info("Slack.leave_room: channel %s left", room_id) 

538 return True 

539 except SlackApiError as e: 

540 err = (e.response or {}).get('error', '') \ 

541 if hasattr(e, 'response') else '' 

542 if err in ('not_in_channel', 'channel_not_found'): 

543 # Already absent — idempotent semantics. 

544 return True 

545 logger.error( 

546 "Slack.leave_room: API error for %s: %s", room_id, e) 

547 return False 

548 except Exception as e: 

549 logger.error( 

550 "Slack.leave_room: unexpected error for %s: %s", 

551 room_id, e) 

552 return False 

553 

554 async def list_room_members(self, room_id: str) -> List[Dict[str, Any]]: 

555 """List members of a Slack channel. 

556 

557 Uses ``conversations.members`` to get user-id list, then 

558 ``users.info`` for display names. Skips the bot itself. 

559 Truncated to first page (~100) — full pagination is 

560 platform-side overhead the agent doesn't typically need. 

561 """ 

562 if not self._client or not room_id: 

563 return [] 

564 try: 

565 response = await self._client.conversations_members( 

566 channel=room_id) 

567 user_ids = response.get('members', []) or [] 

568 except SlackApiError as e: 

569 logger.error( 

570 "Slack.list_room_members: API error for %s: %s", 

571 room_id, e) 

572 return [] 

573 except Exception as e: 

574 logger.error( 

575 "Slack.list_room_members: unexpected error for %s: %s", 

576 room_id, e) 

577 return [] 

578 

579 result: List[Dict[str, Any]] = [] 

580 for uid in user_ids: 

581 if uid == self._bot_user_id: 

582 continue 

583 try: 

584 info = await self._client.users_info(user=uid) 

585 user = info.get('user', {}) or {} 

586 result.append({ 

587 'id': uid, 

588 'display_name': (user.get('profile', {}) or {}).get( 

589 'display_name') or user.get('real_name') or 

590 user.get('name') or uid, 

591 'is_bot': bool(user.get('is_bot', False)), 

592 }) 

593 except Exception: 

594 # Single-user info failure shouldn't drop the whole 

595 # list — synthesize a minimal entry. 

596 result.append({'id': uid, 'display_name': uid, 

597 'is_bot': False}) 

598 return result 

599 

600 

601def create_slack_adapter( 

602 bot_token: str = None, 

603 app_token: str = None, 

604 **kwargs 

605) -> SlackAdapter: 

606 """ 

607 Factory function to create Slack adapter. 

608 

609 Args: 

610 bot_token: Bot token (xoxb-...) or set SLACK_BOT_TOKEN env var 

611 app_token: App token (xapp-...) or set SLACK_APP_TOKEN env var 

612 **kwargs: Additional config options 

613 

614 Returns: 

615 Configured SlackAdapter 

616 """ 

617 bot_token = bot_token or os.getenv("SLACK_BOT_TOKEN") 

618 app_token = app_token or os.getenv("SLACK_APP_TOKEN") 

619 

620 if not bot_token: 

621 raise ValueError("Slack bot token required") 

622 if not app_token: 

623 raise ValueError("Slack app token required for Socket Mode") 

624 

625 config = ChannelConfig( 

626 token=bot_token, 

627 extra={"app_token": app_token, **kwargs}, 

628 ) 

629 return SlackAdapter(config)