latest updates
This commit is contained in:
@@ -9,12 +9,7 @@ Features:
|
||||
- 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
|
||||
- Extends BaseAdapter for multi-channel support
|
||||
|
||||
Usage:
|
||||
from adapters.slack_adapter import SlackAdapter
|
||||
@@ -27,8 +22,6 @@ Usage:
|
||||
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
|
||||
@@ -38,69 +31,32 @@ 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
|
||||
# Types (Slack-specific, kept for internal use)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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)."""
|
||||
"""Result of sending a Slack message."""
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Resolve the Slack bot token."""
|
||||
token = (explicit or os.environ.get("SLACK_BOT_TOKEN", "")).strip()
|
||||
if not token:
|
||||
raise ValueError(
|
||||
@@ -113,10 +69,7 @@ def resolve_bot_token(explicit: str | None = None) -> str:
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
"""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(
|
||||
@@ -133,24 +86,12 @@ def resolve_app_token(explicit: str | None = None) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SlackAdapter:
|
||||
class SlackAdapter(BaseAdapter):
|
||||
"""
|
||||
Slack adapter using Socket Mode.
|
||||
Slack adapter using Socket Mode, extending BaseAdapter.
|
||||
|
||||
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()
|
||||
Inspired by OpenClaw's monitorSlackProvider() in src/slack/monitor/provider.ts.
|
||||
Converts Slack events into IncomingMessage objects before dispatching.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -159,9 +100,9 @@ class SlackAdapter:
|
||||
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._message_handlers: list[MessageHandler] = []
|
||||
self._bot_user_id: str = ""
|
||||
self._bot_user_name: str = ""
|
||||
self._team_id: str = ""
|
||||
@@ -170,15 +111,7 @@ class SlackAdapter:
|
||||
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,
|
||||
@@ -189,23 +122,15 @@ class SlackAdapter:
|
||||
self._register_handlers()
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Public API
|
||||
# BaseAdapter implementation
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
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
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
return "slack"
|
||||
|
||||
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)
|
||||
"""Start the Slack adapter in Socket Mode (blocking)."""
|
||||
self._resolve_identity()
|
||||
|
||||
logger.info("=" * 60)
|
||||
@@ -247,57 +172,40 @@ class SlackAdapter:
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
channel: str,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
thread_ts: str | None = None,
|
||||
) -> SlackSendResult:
|
||||
thread_id: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
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.")
|
||||
return
|
||||
|
||||
# If it looks like a user ID, open a DM first
|
||||
# (mirrors OpenClaw's resolveChannelId)
|
||||
if channel.startswith("U") or channel.startswith("W"):
|
||||
if channel_id.startswith("U") or channel_id.startswith("W"):
|
||||
try:
|
||||
dm_response = self._client.conversations_open(users=[channel])
|
||||
channel = dm_response["channel"]["id"]
|
||||
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}: {e}") from e
|
||||
raise RuntimeError(f"Failed to open DM with {channel_id}: {e}") from e
|
||||
|
||||
# Chunk long messages (OpenClaw's SLACK_TEXT_LIMIT = 4000)
|
||||
# Chunk long messages (Slack's limit = 4000 chars)
|
||||
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,
|
||||
self._client.chat_postMessage(
|
||||
channel=channel_id,
|
||||
text=chunk,
|
||||
thread_ts=thread_ts,
|
||||
thread_ts=thread_id,
|
||||
)
|
||||
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
|
||||
# -------------------------------------------------------------------
|
||||
@@ -305,21 +213,13 @@ class SlackAdapter:
|
||||
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)
|
||||
@@ -333,9 +233,9 @@ class SlackAdapter:
|
||||
) -> None:
|
||||
"""
|
||||
Process an incoming Slack message.
|
||||
This is the core handler, inspired by OpenClaw's createSlackMessageHandler().
|
||||
Converts to IncomingMessage and dispatches to handlers.
|
||||
"""
|
||||
# Skip bot messages (including our own)
|
||||
# Skip bot messages
|
||||
if event.get("bot_id") or event.get("subtype") in (
|
||||
"bot_message",
|
||||
"message_changed",
|
||||
@@ -356,7 +256,7 @@ class SlackAdapter:
|
||||
message_ts = event.get("ts", "")
|
||||
is_dm = channel_type in ("im", "mpim")
|
||||
|
||||
# Strip the bot mention from the text (like OpenClaw does)
|
||||
# Strip the bot mention from the text
|
||||
clean_text = self._strip_mention(raw_text).strip()
|
||||
if not clean_text:
|
||||
return
|
||||
@@ -365,56 +265,45 @@ class SlackAdapter:
|
||||
user_name = self._resolve_user_name(user_id, client)
|
||||
channel_name = self._resolve_channel_name(channel_id, client)
|
||||
|
||||
# Build the SlackMessage object
|
||||
msg = SlackMessage(
|
||||
# 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,
|
||||
thread_ts=thread_ts,
|
||||
message_ts=message_ts,
|
||||
conversation_id=conversation_id,
|
||||
source="slack",
|
||||
is_dm=is_dm,
|
||||
is_mention=is_mention,
|
||||
is_thread_reply=thread_ts is not None,
|
||||
raw_event=event,
|
||||
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]}"
|
||||
)
|
||||
|
||||
# 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
|
||||
# Dispatch to handlers via BaseAdapter
|
||||
self._dispatch(msg)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Internal: Helpers
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _resolve_identity(self) -> None:
|
||||
"""
|
||||
Resolve bot identity via auth.test.
|
||||
Mirrors OpenClaw's auth.test call in monitorSlackProvider().
|
||||
"""
|
||||
"""Resolve bot identity via auth.test."""
|
||||
try:
|
||||
auth = self._client.auth_test()
|
||||
self._bot_user_id = auth.get("user_id", "")
|
||||
@@ -466,7 +355,6 @@ class SlackAdapter:
|
||||
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:
|
||||
@@ -479,14 +367,11 @@ class SlackAdapter:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user