Files
ajarbot/adapters/telegram/adapter.py
Jordan Ramos 916f86725d feat: RSO observation system, child safety, Discord adapter, Telegram watchdog, email attachments
Core agent improvements:
- RSO (Relevance Scoring & Observation) system: interaction_logger, memory_scorer, signal_detector
- Memory access logging (memory_access_log table) for relevance scoring; high-signal turn detection
- Rich conversation storage for notable turns; compact_conversation truncates long user messages
- Task-type classifier (query/action/analysis/creative) for observation tagging
- Nested sub-agent visibility: deep delegations now register against the main agent's manager

Child safety (Gabriel profile):
- child_safety.py: filtering, audit logging, prompt constants for restricted sessions
- .kiro/specs/child-safety-profile: requirements, design, tasks specs
- GABRIEL_BOT_PROPOSAL.md: initial proposal doc
- Reduced context window (10 msgs) and tutor-mode identity for restricted users

Telegram adapter:
- Polling watchdog: auto-restarts updater if polling drops unexpectedly
- get_me() with exponential-backoff retry on NetworkError at startup
- Correct stop() ordering: signal watchdog before cancelling tasks

Email / Gmail:
- send_email: supports file attachments (attachments list param)
- get_email: surfaces attachment metadata in response

Scheduled tasks / weather:
- Remove OpenWeatherMap API calls from morning-weather task; use wttr.in exclusively
- New scheduled tasks and scheduler state persistence

Discord:
- adapters/discord/__init__.py scaffold
- discord-plugin: MCP plugin for Claude Code Discord integration (server.ts, skills, config)

Infrastructure:
- n8n workflow exports (garvis_webhook, content_pipeline variants)
- memory_workspace: context, homelab-repo-updates, weekly observation summaries, error logs
- UCS C240 migration plan doc
- requirements.txt: new deps
- .claude/settings.json, fix_hooks.py: hook/permission tuning
2026-04-23 07:54:01 -06:00

497 lines
17 KiB
Python

"""
Telegram adapter for ajarbot.
Uses python-telegram-bot library for async Telegram Bot API integration.
"""
import asyncio
import logging
from typing import Any, Dict, List, Optional
from telegram import Bot, Update
from telegram.error import NetworkError, TelegramError
from telegram.ext import (
Application,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
from adapters.base import (
AdapterCapabilities,
AdapterConfig,
BaseAdapter,
InboundMessage,
MessageType,
OutboundMessage,
)
class TelegramAdapter(BaseAdapter):
"""
Telegram adapter using python-telegram-bot.
Configuration required:
- bot_token: Telegram Bot API Token (from @BotFather)
Optional settings:
- allowed_users: List of user IDs allowed to interact (for privacy)
- parse_mode: "HTML" or "Markdown" (default: "Markdown")
"""
_RECONNECT_DELAYS = [5, 15, 30, 60, 120] # seconds between startup retries
def __init__(self, config: AdapterConfig) -> None:
super().__init__(config)
self.application: Optional[Application] = None
self.bot: Optional[Bot] = None
self._polling_task: Optional[asyncio.Task] = None
self._watchdog_task: Optional[asyncio.Task] = None
self._logger = logging.getLogger(__name__)
@property
def platform_name(self) -> str:
return "telegram"
@property
def capabilities(self) -> AdapterCapabilities:
return AdapterCapabilities(
supports_threads=False,
supports_reactions=True,
supports_media=True,
supports_files=True,
supports_markdown=True,
max_message_length=4096,
chunking_strategy="markdown",
)
def validate_config(self) -> bool:
"""Validate Telegram configuration."""
if not self.config.credentials:
return False
bot_token = self.config.credentials.get("bot_token", "")
return bool(bot_token and len(bot_token) > 20)
async def start(self) -> None:
"""Start the Telegram bot with retry on network failures."""
if not self.validate_config():
raise ValueError(
"Invalid Telegram configuration. Need bot_token"
)
bot_token = self.config.credentials["bot_token"]
self.application = (
Application.builder().token(bot_token).build()
)
self.bot = self.application.bot
self._register_handlers()
print("[Telegram] Starting bot...")
await self.application.initialize()
await self.application.start()
# start_polling() starts internal Updater machinery and returns quickly.
# Use updater.running (not task state) to detect if polling is alive.
await self.application.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True,
)
self.is_running = True
# Verify connectivity with retries — get_me() fails on DNS errors at boot.
me = await self._get_me_with_retry()
if me:
print(f"[Telegram] Bot started: @{me.username} ({me.first_name})")
else:
print("[Telegram] Bot started (could not verify identity — network may be degraded)")
# Watchdog restarts polling if the task dies unexpectedly.
self._watchdog_task = asyncio.create_task(self._polling_watchdog())
async def _get_me_with_retry(self):
"""Call get_me() with exponential backoff. Returns None if all attempts fail."""
for attempt, delay in enumerate(self._RECONNECT_DELAYS, 1):
try:
return await self.bot.get_me()
except NetworkError as e:
if attempt <= len(self._RECONNECT_DELAYS):
print(f"[Telegram] Network error on startup (attempt {attempt}): {e} — retrying in {delay}s")
await asyncio.sleep(delay)
# Last attempt with no retry after
try:
return await self.bot.get_me()
except NetworkError as e:
print(f"[Telegram] Could not reach Telegram API after {len(self._RECONNECT_DELAYS)+1} attempts: {e}")
return None
async def _polling_watchdog(self) -> None:
"""Monitor the Updater and restart polling if it stops unexpectedly."""
# Give polling a moment to fully initialise before we start watching.
await asyncio.sleep(15)
while self.is_running:
await asyncio.sleep(30)
if not self.is_running:
break
if not self.application or self.application.updater.running:
continue # All good
self._logger.warning("[Telegram] Updater is no longer running — attempting restart")
print("[Telegram] Polling dropped — restarting...")
for attempt, delay in enumerate(self._RECONNECT_DELAYS, 1):
await asyncio.sleep(delay)
if not self.is_running:
return
try:
await self.application.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=False,
)
print(f"[Telegram] Polling restarted (attempt {attempt})")
break
except Exception as e:
print(f"[Telegram] Restart attempt {attempt} failed: {e}")
async def stop(self) -> None:
"""Stop the Telegram bot."""
self.is_running = False # Signal watchdog to exit before cancelling tasks
if self._watchdog_task and not self._watchdog_task.done():
self._watchdog_task.cancel()
try:
await self._watchdog_task
except (asyncio.CancelledError, Exception):
pass
if self.application:
print("[Telegram] Stopping bot...")
await self.application.updater.stop()
await self.application.stop()
await self.application.shutdown()
print("[Telegram] Bot stopped")
def _register_handlers(self) -> None:
"""Register Telegram message handlers."""
async def handle_message(
update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
"""Handle incoming text messages."""
if not update.message or not update.message.text:
return
if not self._is_user_allowed(update.effective_user.id):
await update.message.reply_text(
"Sorry, you are not authorized to use this bot."
)
return
user = update.effective_user
message = update.message
reply_to_id = None
if message.reply_to_message:
reply_to_id = str(message.reply_to_message.message_id)
# Sanitize username: replace spaces/special chars with underscores
raw_username = user.username or user.first_name or str(user.id)
sanitized_username = "".join(
c if c.isalnum() or c in "-_" else "_" for c in raw_username
)
inbound_msg = InboundMessage(
platform="telegram",
user_id=str(user.id),
username=sanitized_username,
text=message.text,
channel_id=str(message.chat.id),
thread_id=None,
reply_to_id=reply_to_id,
message_type=MessageType.TEXT,
metadata={
"message_id": message.message_id,
"chat_type": message.chat.type,
"date": (
message.date.isoformat()
if message.date
else None
),
"user_full_name": user.full_name,
"is_bot": user.is_bot,
},
raw=update,
)
self._dispatch_message(inbound_msg)
async def handle_start(
update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
"""Handle /start command."""
await update.message.reply_text(
"Hello! I'm an AI assistant bot.\n\n"
"Just send me a message and I'll respond!"
)
async def handle_help(
update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
"""Handle /help command."""
await update.message.reply_text(
"*Ajarbot Help*\n\n"
"I'm an AI assistant. You can:\n"
"- Send me messages and I'll respond\n"
"- Have natural conversations\n"
"- Ask me questions\n\n"
"Commands:\n"
"/start - Start the bot\n"
"/help - Show this help message",
parse_mode="Markdown",
)
self.application.add_handler(
CommandHandler("start", handle_start)
)
self.application.add_handler(
CommandHandler("help", handle_help)
)
self.application.add_handler(
MessageHandler(
filters.TEXT & ~filters.COMMAND, handle_message
)
)
async def send_message(
self, message: OutboundMessage
) -> Dict[str, Any]:
"""Send a message to Telegram."""
if not self.bot:
return {"success": False, "error": "Bot not started"}
try:
chat_id = int(message.channel_id)
parse_mode = "Markdown"
if self.config.settings:
parse_mode = self.config.settings.get(
"parse_mode", "Markdown"
)
chunks = self.chunk_text(message.text)
results: List[Dict[str, Any]] = []
for chunk in chunks:
reply_to_id = (
int(message.reply_to_id)
if message.reply_to_id
else None
)
try:
sent_message = await self.bot.send_message(
chat_id=chat_id,
text=chunk,
parse_mode=parse_mode,
reply_to_message_id=reply_to_id,
)
except TelegramError:
# Markdown parse errors are common with LLM-generated
# text (unbalanced *, _, etc). Fall back to plain text.
sent_message = await self.bot.send_message(
chat_id=chat_id,
text=chunk,
parse_mode=None,
reply_to_message_id=reply_to_id,
)
results.append({
"message_id": sent_message.message_id,
"chat_id": sent_message.chat_id,
"date": (
sent_message.date.isoformat()
if sent_message.date
else None
),
})
return {
"success": True,
"message_id": results[0]["message_id"],
"chunks_sent": len(chunks),
"results": results,
}
except TelegramError as e:
print(f"[Telegram] Error sending message: {e}")
return {"success": False, "error": str(e)}
async def send_reaction(
self, channel_id: str, message_id: str, emoji: str
) -> bool:
"""Send a reaction to a message."""
if not self.bot:
return False
try:
await self.bot.set_message_reaction(
chat_id=int(channel_id),
message_id=int(message_id),
reaction=emoji,
)
return True
except TelegramError as e:
print(f"[Telegram] Error adding reaction: {e}")
return False
async def send_typing_indicator(self, channel_id: str) -> None:
"""Show typing indicator."""
if not self.bot:
return
try:
await self.bot.send_chat_action(
chat_id=int(channel_id), action="typing"
)
except TelegramError as e:
print(f"[Telegram] Error sending typing indicator: {e}")
async def send_file(
self,
channel_id: str,
file_path: str,
caption: Optional[str] = None,
thread_id: Optional[str] = None
) -> Dict[str, Any]:
"""Send a file (image or document) to Telegram."""
if not self.bot:
return {"success": False, "error": "Bot not started"}
try:
from pathlib import Path
path = Path(file_path)
if not path.exists():
return {"success": False, "error": f"File not found: {file_path}"}
chat_id = int(channel_id)
reply_to = int(thread_id) if thread_id else None
ext = path.suffix.lower()
# Send as photo for images, document for others
if ext in [".png", ".jpg", ".jpeg", ".gif", ".webp"]:
with open(path, "rb") as photo:
sent = await self.bot.send_photo(
chat_id=chat_id,
photo=photo,
caption=caption,
reply_to_message_id=reply_to,
)
else:
with open(path, "rb") as document:
sent = await self.bot.send_document(
chat_id=chat_id,
document=document,
caption=caption,
reply_to_message_id=reply_to,
)
return {
"success": True,
"message_id": sent.message_id,
"file_path": file_path,
}
except TelegramError as e:
print(f"[Telegram] Error sending file: {e}")
return {"success": False, "error": str(e)}
except Exception as e:
print(f"[Telegram] Unexpected error sending file: {e}")
return {"success": False, "error": str(e)}
async def health_check(self) -> Dict[str, Any]:
"""Perform health check."""
base_health = await super().health_check()
if not self.bot:
return {**base_health, "details": "Bot not initialized"}
try:
me = await self.bot.get_me()
return {
**base_health,
"bot_id": me.id,
"username": me.username,
"first_name": me.first_name,
"can_join_groups": me.can_join_groups,
"can_read_all_group_messages": (
me.can_read_all_group_messages
),
"connected": True,
}
except TelegramError as e:
return {
**base_health,
"healthy": False,
"error": str(e),
}
def _is_user_allowed(self, user_id: int) -> bool:
"""Check if user is allowed to interact with the bot."""
if not self.config.settings:
return True
allowed_users = self.config.settings.get("allowed_users", [])
if not allowed_users:
return True
return user_id in allowed_users
def chunk_text(self, text: str) -> List[str]:
"""
Override chunk_text for Telegram-specific markdown handling.
Preserves markdown code blocks and formatting.
"""
max_len = self.capabilities.max_message_length
if len(text) <= max_len:
return [text]
chunks: List[str] = []
current_chunk = ""
# Split by code blocks first to preserve them
parts = text.split("```")
in_code_block = False
for part in parts:
if in_code_block:
code_block = f"```{part}```"
if len(current_chunk) + len(code_block) > max_len:
if current_chunk:
chunks.append(current_chunk)
chunks.append(code_block)
current_chunk = ""
else:
current_chunk += code_block
else:
# Regular text - split by paragraphs
paragraphs = part.split("\n\n")
for para in paragraphs:
if len(current_chunk) + len(para) + 2 > max_len:
if current_chunk:
chunks.append(current_chunk.strip())
current_chunk = para + "\n\n"
else:
current_chunk += para + "\n\n"
in_code_block = not in_code_block
if current_chunk.strip():
chunks.append(current_chunk.strip())
return chunks if chunks else [text]