Files
Aetheel/adapters/slack_adapter.py
Tanmay Karande 41b2f9a593 latest updates
2026-02-15 15:02:58 -05:00

383 lines
13 KiB
Python

"""
Aetheel Slack Adapter
=====================
Connects to Slack via Socket Mode (no public URL needed).
Inspired by OpenClaw's Slack implementation (src/slack/).
Features:
- Socket Mode connection (no public URL / webhook needed)
- Receives DMs and @mentions in channels
- Each thread = persistent conversation context
- Sends replies back to the same thread
- Extends BaseAdapter for multi-channel support
Usage:
from adapters.slack_adapter import SlackAdapter
adapter = SlackAdapter()
adapter.on_message(my_handler)
adapter.start()
"""
import logging
import os
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from adapters.base import BaseAdapter, IncomingMessage
logger = logging.getLogger("aetheel.slack")
# ---------------------------------------------------------------------------
# Types (Slack-specific, kept for internal use)
# ---------------------------------------------------------------------------
@dataclass
class SlackSendResult:
"""Result of sending a Slack message."""
message_id: str
channel_id: str
thread_ts: str | None = None
# ---------------------------------------------------------------------------
# Token Resolution (inspired by OpenClaw src/slack/token.ts)
# ---------------------------------------------------------------------------
def resolve_bot_token(explicit: str | None = None) -> str:
"""Resolve the Slack bot token."""
token = (explicit or os.environ.get("SLACK_BOT_TOKEN", "")).strip()
if not token:
raise ValueError(
"Slack bot token is required. "
"Set SLACK_BOT_TOKEN environment variable or pass it explicitly."
)
if not token.startswith("xoxb-"):
logger.warning("Bot token doesn't start with 'xoxb-' — double-check the token.")
return token
def resolve_app_token(explicit: str | None = None) -> str:
"""Resolve the Slack app-level token (required for Socket Mode)."""
token = (explicit or os.environ.get("SLACK_APP_TOKEN", "")).strip()
if not token:
raise ValueError(
"Slack app-level token is required for Socket Mode. "
"Set SLACK_APP_TOKEN environment variable or pass it explicitly."
)
if not token.startswith("xapp-"):
logger.warning("App token doesn't start with 'xapp-' — double-check the token.")
return token
# ---------------------------------------------------------------------------
# Slack Adapter
# ---------------------------------------------------------------------------
class SlackAdapter(BaseAdapter):
"""
Slack adapter using Socket Mode, extending BaseAdapter.
Inspired by OpenClaw's monitorSlackProvider() in src/slack/monitor/provider.ts.
Converts Slack events into IncomingMessage objects before dispatching.
"""
def __init__(
self,
bot_token: str | None = None,
app_token: str | None = None,
log_level: str = "INFO",
):
super().__init__()
self._bot_token = resolve_bot_token(bot_token)
self._app_token = resolve_app_token(app_token)
self._bot_user_id: str = ""
self._bot_user_name: str = ""
self._team_id: str = ""
self._user_cache: dict[str, str] = {}
self._channel_cache: dict[str, str] = {}
self._running = False
self._socket_handler: SocketModeHandler | None = None
# Initialize Slack Bolt app with Socket Mode
self._app = App(
token=self._bot_token,
logger=logger,
)
self._client: WebClient = self._app.client
# Register event handlers
self._register_handlers()
# -------------------------------------------------------------------
# BaseAdapter implementation
# -------------------------------------------------------------------
@property
def source_name(self) -> str:
return "slack"
def start(self) -> None:
"""Start the Slack adapter in Socket Mode (blocking)."""
self._resolve_identity()
logger.info("=" * 60)
logger.info(" Aetheel Slack Adapter")
logger.info("=" * 60)
logger.info(f" Bot: @{self._bot_user_name} ({self._bot_user_id})")
logger.info(f" Team: {self._team_id}")
logger.info(f" Mode: Socket Mode")
logger.info(f" Handlers: {len(self._message_handlers)} registered")
logger.info("=" * 60)
self._running = True
self._socket_handler = SocketModeHandler(self._app, self._app_token)
try:
self._socket_handler.start()
except KeyboardInterrupt:
logger.info("Shutting down...")
self.stop()
def start_async(self) -> None:
"""Start the adapter in a background thread (non-blocking)."""
self._resolve_identity()
self._running = True
self._socket_handler = SocketModeHandler(self._app, self._app_token)
self._socket_handler.connect()
logger.info(
f"Slack adapter connected (bot=@{self._bot_user_name}, mode=socket)"
)
def stop(self) -> None:
"""Stop the Slack adapter gracefully."""
self._running = False
if self._socket_handler:
try:
self._socket_handler.close()
except Exception:
pass
logger.info("Slack adapter stopped.")
def send_message(
self,
channel_id: str,
text: str,
thread_id: str | None = None,
) -> None:
"""
Send a message to a Slack channel or DM.
Mirrors OpenClaw's sendMessageSlack() in src/slack/send.ts.
"""
if not text.strip():
return
# If it looks like a user ID, open a DM first
if channel_id.startswith("U") or channel_id.startswith("W"):
try:
dm_response = self._client.conversations_open(users=[channel_id])
channel_id = dm_response["channel"]["id"]
except SlackApiError as e:
raise RuntimeError(f"Failed to open DM with {channel_id}: {e}") from e
# Chunk long messages (Slack's limit = 4000 chars)
SLACK_TEXT_LIMIT = 4000
chunks = self._chunk_text(text, SLACK_TEXT_LIMIT)
for chunk in chunks:
try:
self._client.chat_postMessage(
channel=channel_id,
text=chunk,
thread_ts=thread_id,
)
except SlackApiError as e:
logger.error(f"Failed to send message: {e}")
raise
# -------------------------------------------------------------------
# Internal: Event handlers
# -------------------------------------------------------------------
def _register_handlers(self) -> None:
"""Register Slack event handlers on the Bolt app."""
@self._app.event("message")
def handle_message_event(event: dict, say: Callable, client: WebClient) -> None:
raw_text = event.get("text", "")
if self._bot_user_id and f"<@{self._bot_user_id}>" in raw_text:
return
self._process_incoming(event, say, client)
@self._app.event("app_mention")
def handle_mention_event(event: dict, say: Callable, client: WebClient) -> None:
self._process_incoming(event, say, client, is_mention=True)
def _process_incoming(
self,
event: dict,
say: Callable,
client: WebClient,
is_mention: bool = False,
) -> None:
"""
Process an incoming Slack message.
Converts to IncomingMessage and dispatches to handlers.
"""
# Skip bot messages
if event.get("bot_id") or event.get("subtype") in (
"bot_message",
"message_changed",
"message_deleted",
"channel_join",
"channel_leave",
):
return
user_id = event.get("user", "")
if not user_id or user_id == self._bot_user_id:
return
raw_text = event.get("text", "")
channel_id = event.get("channel", "")
channel_type = event.get("channel_type", "")
thread_ts = event.get("thread_ts")
message_ts = event.get("ts", "")
is_dm = channel_type in ("im", "mpim")
# Strip the bot mention from the text
clean_text = self._strip_mention(raw_text).strip()
if not clean_text:
return
# Resolve user and channel names
user_name = self._resolve_user_name(user_id, client)
channel_name = self._resolve_channel_name(channel_id, client)
# Conversation ID for session isolation (thread-based)
conversation_id = thread_ts or message_ts
# Build channel-agnostic IncomingMessage
msg = IncomingMessage(
text=clean_text,
user_id=user_id,
user_name=user_name,
channel_id=channel_id,
channel_name=channel_name,
conversation_id=conversation_id,
source="slack",
is_dm=is_dm,
timestamp=datetime.fromtimestamp(float(message_ts), tz=timezone.utc)
if message_ts
else datetime.now(timezone.utc),
raw_event={
"thread_id": thread_ts or message_ts, # for BaseAdapter._dispatch
"thread_ts": thread_ts,
"message_ts": message_ts,
"is_mention": is_mention,
"is_thread_reply": thread_ts is not None,
"channel_type": channel_type,
},
)
logger.info(
f"📨 Message from @{user_name} in #{channel_name}: {clean_text[:100]}"
)
# Dispatch to handlers via BaseAdapter
self._dispatch(msg)
# -------------------------------------------------------------------
# Internal: Helpers
# -------------------------------------------------------------------
def _resolve_identity(self) -> None:
"""Resolve bot identity via auth.test."""
try:
auth = self._client.auth_test()
self._bot_user_id = auth.get("user_id", "")
self._bot_user_name = auth.get("user", "unknown")
self._team_id = auth.get("team_id", "")
logger.info(
f"Auth OK: bot={self._bot_user_name} team={self._team_id}"
)
except SlackApiError as e:
logger.warning(f"auth.test failed (non-fatal): {e}")
def _strip_mention(self, text: str) -> str:
"""Remove @bot mentions from message text."""
if self._bot_user_id:
text = re.sub(rf"<@{re.escape(self._bot_user_id)}>", "", text)
return text.strip()
def _resolve_user_name(self, user_id: str, client: WebClient) -> str:
"""Resolve a user ID to a display name (with caching)."""
if user_id in self._user_cache:
return self._user_cache[user_id]
try:
info = client.users_info(user=user_id)
name = (
info["user"].get("real_name")
or info["user"].get("name")
or user_id
)
self._user_cache[user_id] = name
return name
except SlackApiError:
self._user_cache[user_id] = user_id
return user_id
def _resolve_channel_name(self, channel_id: str, client: WebClient) -> str:
"""Resolve a channel ID to a name (with caching)."""
if channel_id in self._channel_cache:
return self._channel_cache[channel_id]
try:
info = client.conversations_info(channel=channel_id)
name = info["channel"].get("name", channel_id)
self._channel_cache[channel_id] = name
return name
except SlackApiError:
self._channel_cache[channel_id] = channel_id
return channel_id
@staticmethod
def _chunk_text(text: str, limit: int = 4000) -> list[str]:
"""
Split text into chunks respecting Slack's character limit.
Tries to split at newlines, then at spaces, then hard-splits.
"""
if len(text) <= limit:
return [text]
chunks = []
remaining = text
while remaining:
if len(remaining) <= limit:
chunks.append(remaining)
break
cut = limit
newline_pos = remaining.rfind("\n", 0, limit)
if newline_pos > limit // 2:
cut = newline_pos + 1
else:
space_pos = remaining.rfind(" ", 0, limit)
if space_pos > limit // 2:
cut = space_pos + 1
chunks.append(remaining[:cut])
remaining = remaining[cut:]
return chunks