latest updates
This commit is contained in:
@@ -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
146
adapters/base.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
264
adapters/telegram_adapter.py
Normal file
264
adapters/telegram_adapter.py
Normal 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
|
||||
Reference in New Issue
Block a user