latest updates

This commit is contained in:
Tanmay Karande
2026-02-15 15:02:58 -05:00
parent 438bb80416
commit 41b2f9a593
24 changed files with 3883 additions and 388 deletions

View File

@@ -1,2 +1,6 @@
# Aetheel Adapters
# Channel adapters for connecting the AI agent to messaging platforms.
from adapters.base import BaseAdapter, IncomingMessage
__all__ = ["BaseAdapter", "IncomingMessage"]

146
adapters/base.py Normal file
View File

@@ -0,0 +1,146 @@
"""
Aetheel Base Adapter
====================
Abstract base class for all channel adapters (Slack, Telegram, Discord, etc.).
Every adapter converts platform-specific events into a channel-agnostic
IncomingMessage and routes responses back through send_message().
The AI handler only sees IncomingMessage — it never knows which platform
the message came from.
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable
logger = logging.getLogger("aetheel.adapters")
# ---------------------------------------------------------------------------
# Channel-Agnostic Message
# ---------------------------------------------------------------------------
@dataclass
class IncomingMessage:
"""
Channel-agnostic incoming message.
Every adapter converts its platform-specific event into this format
before passing it to the message handler. This is the ONLY type the
AI handler sees.
"""
text: str
user_id: str
user_name: str
channel_id: str # platform-specific channel/chat ID
channel_name: str
conversation_id: str # unique ID for session isolation (thread, chat, etc.)
source: str # "slack", "telegram", "discord", etc.
is_dm: bool
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
raw_event: dict[str, Any] = field(default_factory=dict, repr=False)
# Type alias for the message handler callback
MessageHandler = Callable[[IncomingMessage], str | None]
# ---------------------------------------------------------------------------
# Abstract Base Adapter
# ---------------------------------------------------------------------------
class BaseAdapter(ABC):
"""
Abstract base class for channel adapters.
Each adapter must:
1. Connect to the messaging platform
2. Convert incoming events into IncomingMessage objects
3. Call registered handlers with the IncomingMessage
4. Send responses back to the platform via send_message()
"""
def __init__(self) -> None:
self._message_handlers: list[MessageHandler] = []
def on_message(self, handler: MessageHandler) -> MessageHandler:
"""
Register a message handler (can be used as a decorator).
The handler receives an IncomingMessage and should return a
response string or None.
"""
self._message_handlers.append(handler)
return handler
@abstractmethod
def start(self) -> None:
"""Start the adapter (blocking)."""
...
@abstractmethod
def start_async(self) -> None:
"""Start the adapter in a background thread (non-blocking)."""
...
@abstractmethod
def stop(self) -> None:
"""Stop the adapter gracefully."""
...
@abstractmethod
def send_message(
self,
channel_id: str,
text: str,
thread_id: str | None = None,
) -> None:
"""
Send a message to a channel/chat on this platform.
Args:
channel_id: Platform-specific channel/chat ID
text: Message text
thread_id: Optional thread/reply ID for threading
"""
...
@property
@abstractmethod
def source_name(self) -> str:
"""Short name for this adapter, e.g. 'slack', 'telegram'."""
...
def _dispatch(self, msg: IncomingMessage) -> None:
"""
Dispatch an IncomingMessage to all registered handlers.
Called by subclasses after converting platform events.
"""
for handler in self._message_handlers:
try:
response = handler(msg)
if response:
self.send_message(
channel_id=msg.channel_id,
text=response,
thread_id=msg.raw_event.get("thread_id"),
)
except Exception as e:
logger.error(
f"[{self.source_name}] Handler error: {e}", exc_info=True
)
try:
self.send_message(
channel_id=msg.channel_id,
text="⚠️ Something went wrong processing your message.",
thread_id=msg.raw_event.get("thread_id"),
)
except Exception:
pass

View File

@@ -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

View File

@@ -0,0 +1,264 @@
"""
Aetheel Telegram Adapter
=========================
Connects to Telegram via the Bot API using python-telegram-bot.
Features:
- Receives private messages and group @mentions
- Each chat = persistent conversation context
- Sends replies back to the same chat
- Extends BaseAdapter for multi-channel support
Setup:
1. Create a bot via @BotFather on Telegram
2. Set TELEGRAM_BOT_TOKEN in .env
3. Start with: python main.py --telegram
Usage:
from adapters.telegram_adapter import TelegramAdapter
adapter = TelegramAdapter()
adapter.on_message(my_handler)
adapter.start()
"""
import asyncio
import logging
import os
import threading
from datetime import datetime, timezone
from adapters.base import BaseAdapter, IncomingMessage
logger = logging.getLogger("aetheel.telegram")
def resolve_telegram_token(explicit: str | None = None) -> str:
"""Resolve the Telegram bot token."""
token = (explicit or os.environ.get("TELEGRAM_BOT_TOKEN", "")).strip()
if not token:
raise ValueError(
"Telegram bot token is required. "
"Set TELEGRAM_BOT_TOKEN environment variable or pass it explicitly. "
"Get one from @BotFather on Telegram."
)
return token
class TelegramAdapter(BaseAdapter):
"""
Telegram channel adapter using python-telegram-bot.
Handles:
- Private messages (DMs)
- Group messages where the bot is @mentioned
- Inline replies in groups
"""
def __init__(
self,
bot_token: str | None = None,
):
super().__init__()
self._token = resolve_telegram_token(bot_token)
self._application = None
self._bot_username: str = ""
self._running = False
self._thread: threading.Thread | None = None
# -------------------------------------------------------------------
# BaseAdapter implementation
# -------------------------------------------------------------------
@property
def source_name(self) -> str:
return "telegram"
def start(self) -> None:
"""Start the Telegram adapter (blocking)."""
from telegram.ext import (
Application,
MessageHandler,
filters,
)
self._application = (
Application.builder().token(self._token).build()
)
# Register handler for all text messages
self._application.add_handler(
MessageHandler(
filters.TEXT & ~filters.COMMAND, self._handle_message
)
)
# Resolve bot identity
async def _resolve():
bot = self._application.bot
me = await bot.get_me()
self._bot_username = me.username or ""
logger.info(f"Telegram bot: @{self._bot_username} (id={me.id})")
asyncio.get_event_loop().run_until_complete(_resolve())
logger.info("=" * 60)
logger.info(" Aetheel Telegram Adapter")
logger.info("=" * 60)
logger.info(f" Bot: @{self._bot_username}")
logger.info(f" Mode: Polling")
logger.info(f" Handlers: {len(self._message_handlers)} registered")
logger.info("=" * 60)
self._running = True
self._application.run_polling(drop_pending_updates=True)
def start_async(self) -> None:
"""Start the adapter in a background thread (non-blocking)."""
self._thread = threading.Thread(
target=self.start, daemon=True, name="telegram-adapter"
)
self._thread.start()
logger.info("Telegram adapter started in background thread")
def stop(self) -> None:
"""Stop the Telegram adapter gracefully."""
self._running = False
if self._application:
try:
self._application.stop()
except Exception:
pass
logger.info("Telegram adapter stopped.")
def send_message(
self,
channel_id: str,
text: str,
thread_id: str | None = None,
) -> None:
"""Send a message to a Telegram chat."""
if not text.strip() or not self._application:
return
# Chunk long messages (Telegram limit = 4096 chars)
TELEGRAM_TEXT_LIMIT = 4096
chunks = _chunk_text(text, TELEGRAM_TEXT_LIMIT)
async def _send():
bot = self._application.bot
for chunk in chunks:
kwargs = {
"chat_id": int(channel_id),
"text": chunk,
"parse_mode": "Markdown",
}
if thread_id:
kwargs["reply_to_message_id"] = int(thread_id)
try:
await bot.send_message(**kwargs)
except Exception as e:
# Retry without parse_mode if Markdown fails
logger.debug(f"Markdown send failed, retrying plain: {e}")
kwargs.pop("parse_mode", None)
await bot.send_message(**kwargs)
try:
loop = asyncio.get_running_loop()
loop.create_task(_send())
except RuntimeError:
asyncio.run(_send())
# -------------------------------------------------------------------
# Internal: Message handling
# -------------------------------------------------------------------
async def _handle_message(self, update, context) -> None:
"""Process an incoming Telegram message."""
message = update.effective_message
if not message or not message.text:
return
chat = update.effective_chat
user = update.effective_user
if not chat or not user:
return
is_private = chat.type == "private"
text = message.text
# In groups, only respond to @mentions
if not is_private:
if self._bot_username and f"@{self._bot_username}" in text:
text = text.replace(f"@{self._bot_username}", "").strip()
else:
return # Ignore non-mention messages in groups
if not text.strip():
return
# Build IncomingMessage
user_name = user.full_name or user.username or str(user.id)
channel_name = chat.title or (
f"DM with {user_name}" if is_private else str(chat.id)
)
msg = IncomingMessage(
text=text,
user_id=str(user.id),
user_name=user_name,
channel_id=str(chat.id),
channel_name=channel_name,
conversation_id=str(chat.id), # Telegram: one session per chat
source="telegram",
is_dm=is_private,
timestamp=datetime.fromtimestamp(
message.date.timestamp(), tz=timezone.utc
)
if message.date
else datetime.now(timezone.utc),
raw_event={
"thread_id": str(message.message_id) if not is_private else None,
"message_id": message.message_id,
"chat_type": chat.type,
},
)
logger.info(
f"📨 [TG] Message from {user_name} in {channel_name}: {text[:100]}"
)
# Dispatch to handlers (synchronous — handlers are sync functions)
self._dispatch(msg)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _chunk_text(text: str, limit: int = 4096) -> list[str]:
"""Split text into chunks respecting Telegram's character limit."""
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