feat: add NORTH_STAR.md and MVP core loop (Telegram bot + LLM + proposal generator)
This commit is contained in:
committed by
GitHub
parent
c071fe6bf7
commit
6862c7d0b8
@@ -0,0 +1,166 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user