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

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