498 lines
17 KiB
Python
498 lines
17 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
|
|
- Message handler callback for plugging in AI logic
|
|
|
|
Architecture (from OpenClaw):
|
|
- OpenClaw uses @slack/bolt (Node.js) with socketMode: true
|
|
- We replicate this with slack_bolt (Python) which is the official Python SDK
|
|
- Like OpenClaw, we separate: token resolution, message handling, and sending
|
|
|
|
Usage:
|
|
from adapters.slack_adapter import SlackAdapter
|
|
|
|
adapter = SlackAdapter()
|
|
adapter.on_message(my_handler)
|
|
adapter.start()
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
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
|
|
|
|
logger = logging.getLogger("aetheel.slack")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class SlackMessage:
|
|
"""Represents an incoming Slack message (mirrors OpenClaw's SlackMessageEvent)."""
|
|
|
|
text: str
|
|
user_id: str
|
|
user_name: str
|
|
channel_id: str
|
|
channel_name: str
|
|
thread_ts: str | None
|
|
message_ts: str
|
|
is_dm: bool
|
|
is_mention: bool
|
|
is_thread_reply: bool
|
|
raw_event: dict = field(default_factory=dict, repr=False)
|
|
|
|
@property
|
|
def conversation_id(self) -> str:
|
|
"""
|
|
Unique conversation identifier.
|
|
Uses thread_ts if in a thread, otherwise the message_ts.
|
|
This mirrors OpenClaw's session isolation per thread.
|
|
"""
|
|
return self.thread_ts or self.message_ts
|
|
|
|
@property
|
|
def timestamp(self) -> datetime:
|
|
"""Parse Slack ts into a datetime."""
|
|
ts_float = float(self.message_ts)
|
|
return datetime.fromtimestamp(ts_float, tz=timezone.utc)
|
|
|
|
|
|
@dataclass
|
|
class SlackSendResult:
|
|
"""Result of sending a Slack message (mirrors OpenClaw's SlackSendResult)."""
|
|
|
|
message_id: str
|
|
channel_id: str
|
|
thread_ts: str | None = None
|
|
|
|
|
|
# Type alias for the message handler callback
|
|
MessageHandler = Callable[[SlackMessage], str | None]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Token Resolution (inspired by OpenClaw src/slack/token.ts)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def resolve_bot_token(explicit: str | None = None) -> str:
|
|
"""
|
|
Resolve the Slack bot token.
|
|
Priority: explicit param > SLACK_BOT_TOKEN env var.
|
|
"""
|
|
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).
|
|
Priority: explicit param > SLACK_APP_TOKEN env var.
|
|
"""
|
|
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:
|
|
"""
|
|
Slack adapter using Socket Mode.
|
|
|
|
Inspired by OpenClaw's monitorSlackProvider() in src/slack/monitor/provider.ts:
|
|
- Connects via Socket Mode (no public URL needed)
|
|
- Handles DMs and @mentions
|
|
- Thread-based conversation isolation
|
|
- Configurable message handler callback
|
|
|
|
Example:
|
|
adapter = SlackAdapter()
|
|
|
|
@adapter.on_message
|
|
def handle(msg: SlackMessage) -> str:
|
|
return f"Echo: {msg.text}"
|
|
|
|
adapter.start()
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
bot_token: str | None = None,
|
|
app_token: str | None = None,
|
|
log_level: str = "INFO",
|
|
):
|
|
self._bot_token = resolve_bot_token(bot_token)
|
|
self._app_token = resolve_app_token(app_token)
|
|
self._message_handlers: list[MessageHandler] = []
|
|
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
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=getattr(logging, log_level.upper(), logging.INFO),
|
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
|
|
# Initialize Slack Bolt app with Socket Mode
|
|
# This mirrors OpenClaw's: new App({ token: botToken, appToken, socketMode: true })
|
|
self._app = App(
|
|
token=self._bot_token,
|
|
logger=logger,
|
|
)
|
|
self._client: WebClient = self._app.client
|
|
|
|
# Register event handlers
|
|
self._register_handlers()
|
|
|
|
# -------------------------------------------------------------------
|
|
# Public API
|
|
# -------------------------------------------------------------------
|
|
|
|
def on_message(self, handler: MessageHandler) -> MessageHandler:
|
|
"""
|
|
Register a message handler (can be used as a decorator).
|
|
The handler receives a SlackMessage and should return a response string or None.
|
|
"""
|
|
self._message_handlers.append(handler)
|
|
return handler
|
|
|
|
def start(self) -> None:
|
|
"""
|
|
Start the Slack adapter in Socket Mode.
|
|
This is a blocking call (like OpenClaw's `await app.start()`).
|
|
"""
|
|
# Resolve bot identity (like OpenClaw's auth.test call)
|
|
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: str,
|
|
text: str,
|
|
thread_ts: str | None = None,
|
|
) -> SlackSendResult:
|
|
"""
|
|
Send a message to a Slack channel or DM.
|
|
Mirrors OpenClaw's sendMessageSlack() in src/slack/send.ts.
|
|
|
|
Args:
|
|
channel: Channel ID (C...), user ID (U...), or DM channel (D...)
|
|
text: Message text (supports Slack mrkdwn formatting)
|
|
thread_ts: Optional thread timestamp to reply in a thread
|
|
|
|
Returns:
|
|
SlackSendResult with message_id and channel_id
|
|
"""
|
|
if not text.strip():
|
|
raise ValueError("Cannot send an empty message.")
|
|
|
|
# If it looks like a user ID, open a DM first
|
|
# (mirrors OpenClaw's resolveChannelId)
|
|
if channel.startswith("U") or channel.startswith("W"):
|
|
try:
|
|
dm_response = self._client.conversations_open(users=[channel])
|
|
channel = dm_response["channel"]["id"]
|
|
except SlackApiError as e:
|
|
raise RuntimeError(f"Failed to open DM with {channel}: {e}") from e
|
|
|
|
# Chunk long messages (OpenClaw's SLACK_TEXT_LIMIT = 4000)
|
|
SLACK_TEXT_LIMIT = 4000
|
|
chunks = self._chunk_text(text, SLACK_TEXT_LIMIT)
|
|
|
|
last_ts = ""
|
|
for chunk in chunks:
|
|
try:
|
|
response = self._client.chat_postMessage(
|
|
channel=channel,
|
|
text=chunk,
|
|
thread_ts=thread_ts,
|
|
)
|
|
last_ts = response.get("ts", "")
|
|
except SlackApiError as e:
|
|
logger.error(f"Failed to send message: {e}")
|
|
raise
|
|
|
|
return SlackSendResult(
|
|
message_id=last_ts or "unknown",
|
|
channel_id=channel,
|
|
thread_ts=thread_ts,
|
|
)
|
|
|
|
# -------------------------------------------------------------------
|
|
# Internal: Event handlers
|
|
# -------------------------------------------------------------------
|
|
|
|
def _register_handlers(self) -> None:
|
|
"""Register Slack event handlers on the Bolt app."""
|
|
|
|
# Handle direct messages and channel messages WITHOUT @mention
|
|
# Note: When someone @mentions the bot, Slack fires BOTH a "message"
|
|
# event and an "app_mention" event. We only want to respond once,
|
|
# so the message handler skips messages containing the bot @mention.
|
|
# Those are handled exclusively by handle_mention_event below.
|
|
@self._app.event("message")
|
|
def handle_message_event(event: dict, say: Callable, client: WebClient) -> None:
|
|
# Skip if this message contains an @mention of our bot
|
|
# (it will be handled by app_mention instead)
|
|
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)
|
|
|
|
# Handle @mentions in channels
|
|
@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.
|
|
This is the core handler, inspired by OpenClaw's createSlackMessageHandler().
|
|
"""
|
|
# Skip bot messages (including our own)
|
|
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 (like OpenClaw does)
|
|
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)
|
|
|
|
# Build the SlackMessage object
|
|
msg = SlackMessage(
|
|
text=clean_text,
|
|
user_id=user_id,
|
|
user_name=user_name,
|
|
channel_id=channel_id,
|
|
channel_name=channel_name,
|
|
thread_ts=thread_ts,
|
|
message_ts=message_ts,
|
|
is_dm=is_dm,
|
|
is_mention=is_mention,
|
|
is_thread_reply=thread_ts is not None,
|
|
raw_event=event,
|
|
)
|
|
|
|
logger.info(
|
|
f"📨 Message from @{user_name} in #{channel_name}: {clean_text[:100]}"
|
|
)
|
|
|
|
# Call all registered handlers
|
|
for handler in self._message_handlers:
|
|
try:
|
|
response = handler(msg)
|
|
if response:
|
|
# Reply in the same thread
|
|
# (OpenClaw uses thread_ts for thread isolation)
|
|
reply_thread = thread_ts or message_ts
|
|
say(text=response, thread_ts=reply_thread)
|
|
logger.info(
|
|
f"📤 Reply sent to #{channel_name} (thread={reply_thread[:10]}...)"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Handler error: {e}", exc_info=True)
|
|
try:
|
|
say(
|
|
text=f"⚠️ Something went wrong processing your message.",
|
|
thread_ts=thread_ts or message_ts,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# -------------------------------------------------------------------
|
|
# Internal: Helpers
|
|
# -------------------------------------------------------------------
|
|
|
|
def _resolve_identity(self) -> None:
|
|
"""
|
|
Resolve bot identity via auth.test.
|
|
Mirrors OpenClaw's auth.test call in monitorSlackProvider().
|
|
"""
|
|
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.
|
|
Mirrors OpenClaw's chunkMarkdownTextWithMode().
|
|
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
|
|
|
|
# Try to find a good break point
|
|
cut = limit
|
|
# Prefer breaking at a newline
|
|
newline_pos = remaining.rfind("\n", 0, limit)
|
|
if newline_pos > limit // 2:
|
|
cut = newline_pos + 1
|
|
else:
|
|
# Fall back to space
|
|
space_pos = remaining.rfind(" ", 0, limit)
|
|
if space_pos > limit // 2:
|
|
cut = space_pos + 1
|
|
|
|
chunks.append(remaining[:cut])
|
|
remaining = remaining[cut:]
|
|
|
|
return chunks
|