mirror of
https://github.com/Manoj-HV30/clawrity.git
synced 2026-05-16 19:35:21 +00:00
prototype
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Clawrity — Protocol Adapter (OpenClaw Pattern)
|
||||
|
||||
Normalises messages from any channel into a unified NormalisedMessage.
|
||||
Maps workspace/team IDs → client_id. Strips bot mentions.
|
||||
Interface: any channel handler produces NormalisedMessage — adding Teams,
|
||||
WhatsApp, etc. requires zero pipeline changes.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
from config.client_loader import ClientConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalisedMessage:
|
||||
"""Unified message format — channel-agnostic."""
|
||||
text: str
|
||||
channel: str # Channel/conversation ID
|
||||
user_id: str
|
||||
client_id: str
|
||||
timestamp: datetime = field(default_factory=datetime.utcnow)
|
||||
source: str = "unknown" # "slack", "teams", "api"
|
||||
raw_event: Optional[Dict] = None
|
||||
|
||||
|
||||
# Pattern to match Slack bot mentions like <@U1234567890>
|
||||
SLACK_MENTION_PATTERN = re.compile(r"<@[A-Z0-9]+>\s*")
|
||||
|
||||
|
||||
class ProtocolAdapter:
|
||||
"""Normalises raw channel events into NormalisedMessages."""
|
||||
|
||||
def __init__(self, client_configs: Dict[str, ClientConfig]):
|
||||
"""
|
||||
Args:
|
||||
client_configs: Dict of client_id → ClientConfig
|
||||
"""
|
||||
self.client_configs = client_configs
|
||||
# Build workspace → client_id lookup
|
||||
self._workspace_map: Dict[str, str] = {}
|
||||
for cid, config in client_configs.items():
|
||||
for ws_id in config.slack_workspace_ids:
|
||||
self._workspace_map[ws_id] = cid
|
||||
# If only one client, use it as default
|
||||
self._default_client_id = (
|
||||
list(client_configs.keys())[0] if len(client_configs) == 1 else None
|
||||
)
|
||||
|
||||
def normalise_slack(self, event: dict, team_id: Optional[str] = None) -> NormalisedMessage:
|
||||
"""
|
||||
Normalise a Slack event into a NormalisedMessage.
|
||||
|
||||
Args:
|
||||
event: Raw Slack event dict (from Bolt SDK)
|
||||
team_id: Slack workspace/team ID
|
||||
|
||||
Returns:
|
||||
NormalisedMessage
|
||||
"""
|
||||
text = event.get("text", "")
|
||||
# Strip bot mention tags
|
||||
text = SLACK_MENTION_PATTERN.sub("", text).strip()
|
||||
|
||||
channel = event.get("channel", "")
|
||||
user_id = event.get("user", "")
|
||||
|
||||
# Map workspace to client
|
||||
client_id = self._resolve_client_id(team_id)
|
||||
|
||||
return NormalisedMessage(
|
||||
text=text,
|
||||
channel=channel,
|
||||
user_id=user_id,
|
||||
client_id=client_id,
|
||||
source="slack",
|
||||
raw_event=event,
|
||||
)
|
||||
|
||||
def normalise_api(self, client_id: str, message: str) -> NormalisedMessage:
|
||||
"""Normalise a direct API call (POST /chat)."""
|
||||
return NormalisedMessage(
|
||||
text=message,
|
||||
channel="api",
|
||||
user_id="api_user",
|
||||
client_id=client_id,
|
||||
source="api",
|
||||
)
|
||||
|
||||
def normalise_teams(self, activity: dict) -> NormalisedMessage:
|
||||
"""
|
||||
Normalise a Microsoft Teams Bot Framework activity.
|
||||
# TODO: Implement full Teams normalisation when Teams handler is wired up.
|
||||
"""
|
||||
text = activity.get("text", "")
|
||||
# Strip Teams bot mention (usually <at>BotName</at>)
|
||||
text = re.sub(r"<at>.*?</at>\s*", "", text).strip()
|
||||
|
||||
return NormalisedMessage(
|
||||
text=text,
|
||||
channel=activity.get("channelId", "teams"),
|
||||
user_id=activity.get("from", {}).get("id", ""),
|
||||
client_id=self._default_client_id or "unknown",
|
||||
source="teams",
|
||||
raw_event=activity,
|
||||
)
|
||||
|
||||
def _resolve_client_id(self, workspace_id: Optional[str]) -> str:
|
||||
"""Resolve workspace/team ID to client_id."""
|
||||
if workspace_id and workspace_id in self._workspace_map:
|
||||
return self._workspace_map[workspace_id]
|
||||
if self._default_client_id:
|
||||
return self._default_client_id
|
||||
logger.warning(f"Could not resolve client for workspace: {workspace_id}")
|
||||
return "unknown"
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
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 guard: only one SlackHandler should be active at a time
|
||||
_active_handler: Optional["SlackHandler"] = 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()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Bot Token (xoxb-...) — from .env SLACK_BOT_TOKEN
|
||||
# This is the OAuth token installed to your workspace.
|
||||
# ---------------------------------------------------------------
|
||||
self.bot_token = settings.slack_bot_token
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# App-Level Token (xapp-...) — from .env SLACK_APP_TOKEN
|
||||
# Required for Socket Mode. Generated in Slack app settings.
|
||||
# ---------------------------------------------------------------
|
||||
self.app_token = settings.slack_app_token
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Signing Secret — from .env SLACK_SIGNING_SECRET
|
||||
# Used to verify incoming requests from Slack.
|
||||
# ---------------------------------------------------------------
|
||||
self.signing_secret = settings.slack_signing_secret
|
||||
|
||||
self.app = None
|
||||
self.handler = None
|
||||
|
||||
# Deduplication: track recently processed event timestamps
|
||||
# Slack retries events if handler is slow — this prevents duplicates
|
||||
self._processed_events: Set[str] = set()
|
||||
self._processed_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_event(self, event: dict) -> bool:
|
||||
"""Check if we've already processed this event (Slack retry dedup)."""
|
||||
# Use multiple fields to build a robust dedup key.
|
||||
# client_msg_id is unique per user message (present on message events,
|
||||
# but NOT on app_mention events). event_ts is present on both.
|
||||
# We store keys for all strategies so cross-event-type dedup works.
|
||||
msg_id = event.get("client_msg_id")
|
||||
event_ts = event.get("event_ts") or event.get("ts", "")
|
||||
user = event.get("user", "")
|
||||
|
||||
# Build candidate keys
|
||||
keys = set()
|
||||
if msg_id:
|
||||
keys.add(f"msg:{msg_id}")
|
||||
if event_ts:
|
||||
keys.add(f"ts:{event_ts}")
|
||||
# Fallback: combine event type + ts + user for events without client_msg_id
|
||||
event_type = event.get("type", "")
|
||||
if event_ts and user:
|
||||
keys.add(f"evt:{event_type}:{event_ts}:{user}")
|
||||
|
||||
if not keys:
|
||||
return False
|
||||
|
||||
with self._processed_lock:
|
||||
# Check ALL keys — if any match, it's a duplicate
|
||||
for key in keys:
|
||||
if key in self._processed_events:
|
||||
logger.debug(f"Skipping duplicate event (matched key: {key})")
|
||||
return True
|
||||
|
||||
# Register ALL keys so cross-event-type dedup works
|
||||
# (app_mention and message for the same user message share event_ts)
|
||||
self._processed_events.update(keys)
|
||||
|
||||
# Prune old entries (keep set from growing indefinitely)
|
||||
if len(self._processed_events) > 500:
|
||||
self._processed_events = set(list(self._processed_events)[-200:])
|
||||
|
||||
return False
|
||||
|
||||
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):
|
||||
# Return IMMEDIATELY so Slack gets ack — process in background
|
||||
if self._is_duplicate_event(event):
|
||||
return
|
||||
_executor.submit(self._handle_event, event, say, context)
|
||||
|
||||
# --- Event: Direct message to bot ---
|
||||
@self.app.event("message")
|
||||
def handle_message(event, say, context):
|
||||
# Ignore bot's own messages and message_changed events
|
||||
if event.get("subtype") in (
|
||||
"bot_message",
|
||||
"message_changed",
|
||||
"message_deleted",
|
||||
):
|
||||
return
|
||||
if event.get("bot_id"):
|
||||
return
|
||||
# Ignore if this is from the bot itself
|
||||
if self._bot_user_id and event.get("user") == self._bot_user_id:
|
||||
return
|
||||
# Skip channel messages that contain a bot mention —
|
||||
# those are handled by the app_mention handler above.
|
||||
# Only process DMs here (channel_type == "im").
|
||||
channel_type = event.get("channel_type", "")
|
||||
if channel_type != "im":
|
||||
return
|
||||
if self._is_duplicate_event(event):
|
||||
return
|
||||
# Return IMMEDIATELY — process in background
|
||||
_executor.submit(self._handle_event, event, say, context)
|
||||
|
||||
self.handler = SocketModeHandler(self.app, self.app_token)
|
||||
|
||||
def _handle_event(self, event: dict, say, context):
|
||||
"""Process an incoming Slack event (runs in background thread)."""
|
||||
try:
|
||||
team_id = context.get("team_id", None) if context else None
|
||||
message = self.adapter.normalise_slack(event, team_id=team_id)
|
||||
|
||||
if not message.text:
|
||||
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
|
||||
|
||||
# Run the orchestrator pipeline (async in sync context)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
result = loop.run_until_complete(
|
||||
self.orchestrator.process(message, client_config)
|
||||
)
|
||||
say(result["response"])
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Slack event handler error: {e}", exc_info=True)
|
||||
say(
|
||||
"❌ I encountered an error processing your request. "
|
||||
"Please try again or contact support."
|
||||
)
|
||||
|
||||
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}")
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Clawrity — Microsoft Teams Handler (STUB)
|
||||
|
||||
Skeleton implementation of the Bot Framework adapter for Microsoft Teams.
|
||||
Proves the multi-channel architecture is real — any channel handler produces
|
||||
NormalisedMessage via ProtocolAdapter, so the entire pipeline works unchanged.
|
||||
|
||||
# TODO: Wire up Azure Bot credentials when ready for Teams demo.
|
||||
# Required: MICROSOFT_APP_ID, MICROSOFT_APP_PASSWORD
|
||||
# Package: botbuilder-core, botbuilder-schema
|
||||
|
||||
Status: NOT IMPLEMENTED — Slack is the priority for development.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
from channels.protocol_adapter import ProtocolAdapter, NormalisedMessage
|
||||
from config.client_loader import ClientConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TeamsHandler:
|
||||
"""
|
||||
Microsoft Teams bot handler stub.
|
||||
|
||||
Architecture:
|
||||
Teams Activity → ProtocolAdapter.normalise_teams() → Orchestrator → Response
|
||||
|
||||
The same pipeline used by Slack — zero business logic in this layer.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# TODO: Wire up Azure Bot credentials from .env
|
||||
# self.app_id = settings.microsoft_app_id
|
||||
# self.app_password = settings.microsoft_app_password
|
||||
|
||||
async def handle_activity(self, activity: dict) -> str:
|
||||
"""
|
||||
Process an incoming Teams Bot Framework activity.
|
||||
|
||||
# TODO: Implement when ready for Teams demo.
|
||||
|
||||
Expected flow:
|
||||
1. Receive activity from Bot Framework webhook
|
||||
2. Normalise via ProtocolAdapter.normalise_teams(activity)
|
||||
3. Route to Orchestrator.process(message, client_config)
|
||||
4. Return response via Bot Framework turn context
|
||||
|
||||
Args:
|
||||
activity: Raw Bot Framework activity dict
|
||||
|
||||
Returns:
|
||||
Response text to send back to Teams
|
||||
"""
|
||||
# --- Stub implementation ---
|
||||
message = self.adapter.normalise_teams(activity)
|
||||
|
||||
client_config = self.client_configs.get(message.client_id)
|
||||
if not client_config:
|
||||
return f"No configuration found for client: {message.client_id}"
|
||||
|
||||
result = await self.orchestrator.process(message, client_config)
|
||||
return result["response"]
|
||||
|
||||
def setup_routes(self, app):
|
||||
"""
|
||||
Register Teams webhook endpoint with FastAPI.
|
||||
|
||||
# TODO: Implement Bot Framework adapter integration.
|
||||
|
||||
Expected endpoint:
|
||||
POST /api/teams/messages → Bot Framework webhook
|
||||
|
||||
Requires:
|
||||
- botbuilder-core package
|
||||
- BotFrameworkAdapter with app_id + app_password
|
||||
- CloudAdapter or BotFrameworkHttpClient
|
||||
"""
|
||||
logger.info(
|
||||
"Teams handler stub loaded. "
|
||||
"To enable Teams: install botbuilder-core, set Azure Bot credentials."
|
||||
)
|
||||
|
||||
# TODO: Uncomment and implement when ready
|
||||
#
|
||||
# from botbuilder.core import (
|
||||
# BotFrameworkAdapter,
|
||||
# BotFrameworkAdapterSettings,
|
||||
# TurnContext,
|
||||
# )
|
||||
#
|
||||
# settings = BotFrameworkAdapterSettings(
|
||||
# app_id=self.app_id,
|
||||
# app_password=self.app_password,
|
||||
# )
|
||||
# adapter = BotFrameworkAdapter(settings)
|
||||
#
|
||||
# @app.post("/api/teams/messages")
|
||||
# async def teams_webhook(request: Request):
|
||||
# body = await request.json()
|
||||
# activity = Activity().deserialize(body)
|
||||
# auth_header = request.headers.get("Authorization", "")
|
||||
# response = await adapter.process_activity(
|
||||
# activity, auth_header, self._on_turn
|
||||
# )
|
||||
# return response
|
||||
#
|
||||
# async def _on_turn(turn_context: TurnContext):
|
||||
# activity = turn_context.activity
|
||||
# response = await self.handle_activity(activity.__dict__)
|
||||
# await turn_context.send_activity(response)
|
||||
|
||||
pass
|
||||
Reference in New Issue
Block a user