167 lines
5.8 KiB
Python
167 lines
5.8 KiB
Python
"""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)
|