Files
clawrity/channels/slack_handler.py
T

327 lines
12 KiB
Python

"""
Clawrity — Slack Handler (Socket Mode)
Listens for app_mention and message events via Slack Bolt SDK.
Runs in a background thread to not block FastAPI.
=== SETUP REQUIRED ===
Before running, configure these in your .env file:
SLACK_BOT_TOKEN=xoxb-... ← OAuth & Permissions → Install to Workspace
SLACK_APP_TOKEN=xapp-... ← Socket Mode → Generate App-Level Token
SLACK_SIGNING_SECRET=... ← Basic Information → App Credentials
See README.md for detailed Slack app setup instructions.
=======================
"""
import asyncio
import logging
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Optional, Set
from config.settings import get_settings
from config.client_loader import ClientConfig
from channels.protocol_adapter import ProtocolAdapter, NormalisedMessage
logger = logging.getLogger(__name__)
# Thread pool for processing LLM pipeline without blocking event handlers
_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="clawrity-slack")
# Module-level reference to prevent multiple handlers
_active_handler = None
class SlackHandler:
"""Slack Bot using Socket Mode via Bolt SDK."""
def __init__(
self,
protocol_adapter: ProtocolAdapter,
client_configs: Dict[str, ClientConfig],
orchestrator, # agents.orchestrator.Orchestrator
):
self.adapter = protocol_adapter
self.client_configs = client_configs
self.orchestrator = orchestrator
self._thread: Optional[threading.Thread] = None
settings = get_settings()
self.bot_token = settings.slack_bot_token
self.app_token = settings.slack_app_token
self.signing_secret = settings.slack_signing_secret
self.app = None
self.handler = None
# Deduplication: track recently processed message timestamps.
# Slack Socket Mode retries deliver different envelope_ids but
# the underlying message "ts" stays the same.
self._processed_ts: Set[str] = set()
self._processed_lock = threading.Lock()
# Per-user processing lock: prevents duplicate responses when
# Slack delivers the same event multiple times before dedup catches it.
# Only one message per user is processed at a time.
self._busy_users: Set[str] = set()
self._busy_lock = threading.Lock()
def _validate_tokens(self) -> bool:
"""Check that all required Slack tokens are configured."""
if not self.bot_token:
logger.warning(
"SLACK_BOT_TOKEN not set. Slack bot will not start. "
"See README.md → Slack Bot Setup for instructions."
)
return False
if not self.app_token:
logger.warning(
"SLACK_APP_TOKEN not set. Socket Mode requires an app-level token. "
"Go to your Slack app → Socket Mode → Generate Token."
)
return False
return True
def _is_duplicate(self, event: dict) -> bool:
"""
De-duplicate events using the message 'ts' field.
When Slack retries an event via Socket Mode, it delivers a new
envelope with a different envelope_id/event_ts, but the underlying
message timestamp ('ts') is identical. We key on 'ts' to catch retries.
"""
ts = event.get("ts", "")
if not ts:
logger.info(f"DEDUP: No ts in event, skipping dedup check")
return False
with self._processed_lock:
if ts in self._processed_ts:
logger.info(f"DEDUP: Duplicate detected ts={ts}")
return True
self._processed_ts.add(ts)
logger.info(f"DEDUP: New event registered ts={ts}")
# Prune old entries
if len(self._processed_ts) > 500:
self._processed_ts = set(list(self._processed_ts)[-200:])
return False
def _acquire_user(self, user_id: str) -> bool:
"""
Try to acquire the per-user processing lock.
Returns True if acquired (caller should process), False if already busy.
"""
with self._busy_lock:
if user_id in self._busy_users:
logger.info(f"DEDUP: User {user_id} already being processed, skipping")
return False
self._busy_users.add(user_id)
logger.info(f"DEDUP: Acquired user {user_id}")
return True
def _release_user(self, user_id: str):
"""Release the per-user processing lock."""
with self._busy_lock:
self._busy_users.discard(user_id)
logger.info(f"DEDUP: Released user {user_id}")
def _setup_app(self):
"""Initialize Slack Bolt App and register event handlers."""
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
self.app = App(
token=self.bot_token,
signing_secret=self.signing_secret if self.signing_secret else None,
)
# Track bot's own user ID to prevent self-response loops
self._bot_user_id = None
try:
auth = self.app.client.auth_test()
self._bot_user_id = auth.get("user_id", "")
logger.info(f"Bot user ID: {self._bot_user_id}")
except Exception as e:
logger.warning(f"Could not fetch bot user ID: {e}")
# --- Event: Bot mentioned in a channel ---
@self.app.event("app_mention")
def handle_mention(event, say, context):
user_id = event.get("user", "")
ts = event.get("ts", "")
text = event.get("text", "")[:120]
channel = event.get("channel", "")
logger.info(
f"[app_mention] ts={ts} user={user_id} channel={channel} text={text}"
)
if self._is_duplicate(event):
return
if not self._acquire_user(user_id):
return
logger.info(f"[app_mention] Submitting to thread pool for user={user_id}")
_executor.submit(self._handle_event_safe, event, say, context)
# --- Event: Direct message to bot ---
@self.app.event("message")
def handle_message(event, say, context):
# Ignore bot's own messages and subtypes
if event.get("subtype") in (
"bot_message",
"message_changed",
"message_deleted",
):
return
if event.get("bot_id"):
return
if self._bot_user_id and event.get("user") == self._bot_user_id:
return
# Only DMs — channel mentions are handled by app_mention
if event.get("channel_type", "") != "im":
return
user_id = event.get("user", "")
ts = event.get("ts", "")
text = event.get("text", "")[:120]
logger.info(f"[message/DM] ts={ts} user={user_id} text={text}")
if self._is_duplicate(event):
return
if not self._acquire_user(user_id):
return
logger.info(f"[message/DM] Submitting to thread pool for user={user_id}")
_executor.submit(self._handle_event_safe, event, say, context)
self.handler = SocketModeHandler(self.app, self.app_token)
def _handle_event_safe(self, event: dict, say, context):
"""Wrapper that catches all exceptions and releases user lock."""
user_id = event.get("user", "")
event_ts = event.get("ts", "")
text_preview = event.get("text", "")[:80]
logger.info(
f"[handle_event_safe] START user={user_id} ts={event_ts} text={text_preview}"
)
try:
self._handle_event(event, say, context)
logger.info(f"[handle_event_safe] DONE user={user_id} ts={event_ts}")
except Exception as e:
logger.error(
f"[handle_event_safe] UNHANDLED ERROR user={user_id}: {e}",
exc_info=True,
)
try:
say(
"❌ I encountered an error processing your request. Please try again."
)
except Exception as say_err:
logger.error(
f"[handle_event_safe] Failed to send error to Slack: {say_err}"
)
finally:
self._release_user(user_id)
def _handle_event(self, event: dict, say, context):
"""Process an incoming Slack event (runs in background thread)."""
team_id = context.get("team_id", None) if context else None
message = self.adapter.normalise_slack(event, team_id=team_id)
logger.info(
f"[handle_event] normalised: client_id={message.client_id} "
f"text={message.text[:60] if message.text else '(empty)'}"
)
if not message.text:
logger.info("[handle_event] empty text, returning")
return
if message.client_id == "unknown":
say("⚠️ Could not identify your workspace. Please contact support.")
return
client_config = self.client_configs.get(message.client_id)
if not client_config:
say(f"⚠️ No configuration found for client: {message.client_id}")
return
logger.info("[handle_event] calling orchestrator...")
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(
self.orchestrator.process(message, client_config)
)
response_text = result.get("response", "")
if not response_text:
response_text = "I wasn't able to generate a response. Please try rephrasing your question."
logger.info(
f"[handle_event] orchestrator done, response={len(response_text)} chars, "
f"qa_score={result.get('qa_score', 0):.2f}, retries={result.get('retries', 0)}"
)
# Slack has a 4000 char limit for messages; split if needed
if len(response_text) > 3900:
chunks = [
response_text[i : i + 3900]
for i in range(0, len(response_text), 3900)
]
for i, chunk in enumerate(chunks):
say(chunk)
logger.info(f"[handle_event] sent chunk {i + 1}/{len(chunks)}")
else:
say(response_text)
logger.info("[handle_event] say() called successfully")
except Exception as e:
logger.error(f"Slack event handler error: {e}", exc_info=True)
error_msg = (
"❌ I encountered an error processing your request. "
"Please try again or contact support."
)
try:
say(error_msg)
except Exception as say_err:
logger.error(f"Failed to send error message to Slack: {say_err}")
finally:
loop.close()
def start(self):
"""Start the Slack bot in a background thread."""
global _active_handler
if not self._validate_tokens():
logger.info("Slack bot not started — missing tokens")
return
# Stop any existing handler to prevent duplicate Socket Mode connections
if _active_handler is not None:
logger.info("Stopping previous Slack handler before starting new one")
_active_handler.stop()
_active_handler = None
try:
self._setup_app()
def _run():
logger.info("Starting Slack bot (Socket Mode)...")
self.handler.start()
self._thread = threading.Thread(target=_run, daemon=True)
self._thread.start()
_active_handler = self
logger.info("Slack bot started in background thread")
except Exception as e:
logger.error(f"Failed to start Slack bot: {e}")
def stop(self):
"""Stop the Slack bot."""
if self.handler:
try:
self.handler.close()
logger.info("Slack bot stopped")
except Exception as e:
logger.warning(f"Error stopping Slack bot: {e}")