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
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Slack Channel Adapter
4Implements Slack messaging using the Bolt framework with Socket Mode.
5Supports workspaces, channels, threads, and rich formatting.
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"""
17from __future__ import annotations
19import asyncio
20import logging
21import os
22from typing import Optional, List, Dict, Any
23from datetime import datetime
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
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
48logger = logging.getLogger(__name__)
51class SlackAdapter(ChannelAdapter, RoomCapableAdapter):
52 """
53 Slack messaging adapter using Bolt framework with Socket Mode.
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 """
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 )
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", "")
80 @property
81 def name(self) -> str:
82 return "slack"
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
90 if not self._app_token:
91 logger.error("Slack app token not provided (required for Socket Mode)")
92 return False
94 try:
95 # Create Bolt app
96 self._app = AsyncApp(token=self.config.token)
97 self._client = self._app.client
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}")
105 # Register event handlers
106 self._register_handlers()
108 # Start Socket Mode handler
109 self._handler = AsyncSocketModeHandler(self._app, self._app_token)
110 asyncio.create_task(self._handler.start_async())
112 self.status = ChannelStatus.CONNECTED
113 return True
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
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
137 def _register_handlers(self) -> None:
138 """Register Slack event handlers."""
139 if not self._app:
140 return
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
148 # Ignore message edits and deletes
149 if event.get("subtype") in ("message_changed", "message_deleted"):
150 return
152 message = self._convert_message(event)
153 await self._dispatch_message(message)
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)
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')}")
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()
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
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 ))
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")
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 )
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")
231 try:
232 # Build message payload
233 payload: Dict[str, Any] = {
234 "channel": chat_id,
235 "text": text,
236 }
238 # Handle thread reply
239 if reply_to:
240 payload["thread_ts"] = reply_to
242 # Handle buttons/blocks
243 if buttons:
244 payload["blocks"] = self._build_blocks(text, buttons)
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)
250 # Send text message
251 response = await self._client.chat_postMessage(**payload)
253 return SendResult(
254 success=True,
255 message_id=response.get("ts"),
256 raw=dict(response),
257 )
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))
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")
280 first_media = media[0]
282 try:
283 upload_args: Dict[str, Any] = {
284 "channels": chat_id,
285 "initial_comment": text,
286 }
288 if reply_to:
289 upload_args["thread_ts"] = reply_to
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
297 if first_media.file_name:
298 upload_args["filename"] = first_media.file_name
300 response = await self._client.files_upload_v2(**upload_args)
302 file_info = response.get("file", {})
303 return SendResult(
304 success=True,
305 message_id=file_info.get("id"),
306 raw=dict(response),
307 )
309 except SlackApiError as e:
310 logger.error(f"Failed to upload Slack file: {e}")
311 return SendResult(success=False, error=str(e))
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 ]
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 })
339 if actions:
340 blocks.append({
341 "type": "actions",
342 "elements": actions[:5], # Slack limits to 5 buttons per block
343 })
345 return blocks
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")
358 try:
359 payload: Dict[str, Any] = {
360 "channel": chat_id,
361 "ts": message_id,
362 "text": text,
363 }
365 if buttons:
366 payload["blocks"] = self._build_blocks(text, buttons)
368 response = await self._client.chat_update(**payload)
370 return SendResult(
371 success=True,
372 message_id=response.get("ts"),
373 raw=dict(response),
374 )
376 except SlackApiError as e:
377 logger.error(f"Failed to edit Slack message: {e}")
378 return SendResult(success=False, error=str(e))
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
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
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
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
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
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
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
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
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
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
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
469 # ─── UNIF-G2: RoomCapableAdapter implementation ──────────────────
471 async def join_room(self, room_id: str,
472 role: str = 'participant') -> bool:
473 """Join a Slack channel as the bot's presence.
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.
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
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
554 async def list_room_members(self, room_id: str) -> List[Dict[str, Any]]:
555 """List members of a Slack channel.
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 []
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
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.
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
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")
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")
625 config = ChannelConfig(
626 token=bot_token,
627 extra={"app_token": app_token, **kwargs},
628 )
629 return SlackAdapter(config)