steward_mirror/steward/bot/telegram.py

167 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)