"""Telegram bot interface for Steward.""" import logging from collections import defaultdict from telegram import Update from telegram.constants import ParseMode from telegram.ext import ( Application, CommandHandler, ContextTypes, MessageHandler, filters, ) from steward.config import Settings from steward.llm.client import LLMClient from steward.proposals.generator import Proposal, ProposalGenerator logger = logging.getLogger(__name__) # Per-user conversation history (in-memory for the MVP) _history: dict[int, list[dict[str, str]]] = defaultdict(list) _MAX_HISTORY = 20 # keep the last N turns per user def _is_allowed(user_id: int, settings: Settings) -> bool: """Return True if the user is in the allow-list (or no list is configured).""" if not settings.telegram_allowed_user_ids: return True return user_id in settings.telegram_allowed_user_ids async def _send_long(update: Update, text: str) -> None: """Send text, splitting if it exceeds Telegram's 4096-char limit.""" limit = 4096 for i in range(0, len(text), limit): await update.message.reply_text( # type: ignore[union-attr] text[i : i + limit], parse_mode=ParseMode.MARKDOWN, ) async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start.""" settings: Settings = context.bot_data["settings"] user = update.effective_user if user is None or not _is_allowed(user.id, settings): return await update.message.reply_text( # type: ignore[union-attr] "Hello, I'm *Steward* \U0001f916\n\n" "I'm your AI-assisted personal operations platform.\n" "Talk to me naturally, or use:\n" "/help – show available commands\n" "/clear – reset our conversation history\n" "/analyse – run a manual API analysis right now", parse_mode=ParseMode.MARKDOWN, ) async def help_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /help.""" settings: Settings = context.bot_data["settings"] user = update.effective_user if user is None or not _is_allowed(user.id, settings): return await update.message.reply_text( # type: ignore[union-attr] "*Steward commands*\n\n" "/start – greeting\n" "/help – this message\n" "/clear – reset conversation history\n" "/analyse – trigger an immediate API analysis and proposal", parse_mode=ParseMode.MARKDOWN, ) async def clear_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /clear – wipe conversation history for this user.""" settings: Settings = context.bot_data["settings"] user = update.effective_user if user is None or not _is_allowed(user.id, settings): return _history[user.id].clear() await update.message.reply_text("Conversation history cleared.") # type: ignore[union-attr] async def analyse_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /analyse – run the proposal generator on demand.""" settings: Settings = context.bot_data["settings"] llm: LLMClient = context.bot_data["llm"] user = update.effective_user if user is None or not _is_allowed(user.id, settings): return await update.message.reply_text("Running analysis, please wait...") # type: ignore[union-attr] generator = ProposalGenerator(settings, llm) proposal = await generator.run() if proposal is None: await update.message.reply_text( # type: ignore[union-attr] "Analysis could not be completed. " "Check that `ANALYSIS_TARGET_URL` is configured." ) return await _send_long(update, proposal.format_for_telegram()) async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle plain text messages – forward to LLM and reply.""" settings: Settings = context.bot_data["settings"] llm: LLMClient = context.bot_data["llm"] user = update.effective_user if user is None or not _is_allowed(user.id, settings): return text = update.message.text # type: ignore[union-attr] if not text: return history = _history[user.id] reply = await llm.chat(text, history=history) # Update history history.append({"role": "user", "content": text}) history.append({"role": "assistant", "content": reply}) # Trim to keep only the most recent turns (2 messages per turn) if len(history) > _MAX_HISTORY * 2: _history[user.id] = history[-( _MAX_HISTORY * 2):] await _send_long(update, reply) def build_application(settings: Settings, llm: LLMClient) -> Application: # type: ignore[type-arg] """Build and return the Telegram Application.""" app = ( Application.builder() .token(settings.telegram_bot_token) .build() ) app.bot_data["settings"] = settings app.bot_data["llm"] = llm app.add_handler(CommandHandler("start", start_handler)) app.add_handler(CommandHandler("help", help_handler)) app.add_handler(CommandHandler("clear", clear_handler)) app.add_handler(CommandHandler("analyse", analyse_handler)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, message_handler)) return app async def send_proposal(app: Application, proposal: Proposal, user_ids: list[int]) -> None: # type: ignore[type-arg] """Send a proposal message to all configured user IDs.""" text = proposal.format_for_telegram() for uid in user_ids: try: await app.bot.send_message( chat_id=uid, text=text, parse_mode=ParseMode.MARKDOWN, ) except Exception: logger.exception("Failed to send proposal to user %d", uid)