pub mod alerts;
pub mod commands;
pub mod focus;
pub mod formatter;
pub mod supervisor;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use teloxide::prelude::*;
use teloxide::types::ParseMode;
use teloxide::utils::command::BotCommands;
use tokio_util::sync::CancellationToken;
use crate::client::{
ChatMessage, CommandExecutor, CommandResult, FreeTextRoute, ManagedBackend, SessionProxy,
TrustyCommand, route_free_text,
};
use alerts::{AlertConfig, LastSeen};
use commands::TelegramCommand;
use formatter::TelegramFormatter;
type ChatHistories = Arc<Mutex<HashMap<i64, Vec<ChatMessage>>>>;
const LLM_NOT_CONFIGURED: &str =
"LLM chat not configured — set OPENROUTER_API_KEY in .env.local and enable the overseer";
const MAX_CHAT_HISTORY_TURNS: usize = 20;
const BOT_USERNAME_ENV: &str = "TELEGRAM_BOT_USERNAME";
const DEFAULT_BOT_USERNAME: &str = "t_sess_bot";
pub fn bot_username() -> String {
resolve_username(std::env::var(BOT_USERNAME_ENV).ok())
}
fn resolve_username(env_val: Option<String>) -> String {
match env_val {
Some(v) if !v.trim().is_empty() => v.trim().to_string(),
_ => DEFAULT_BOT_USERNAME.to_string(),
}
}
const SESSION_POLL_INTERVAL: Duration = Duration::from_secs(10);
const OVERSEER_POLL_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Default)]
pub struct BotOptions {
pub allowed_user_id: Option<i64>,
pub alert_chat_id: Option<i64>,
}
pub fn resolve_token(var_name: &str) -> Option<String> {
crate::secret_source::resolve_secret(var_name)
}
fn is_authorized(options: &BotOptions, user_id: Option<i64>) -> bool {
match options.allowed_user_id {
None => true,
Some(allowed) => user_id == Some(allowed),
}
}
pub async fn run(
url: String,
token: Option<String>,
check: bool,
options: BotOptions,
) -> anyhow::Result<()> {
let alert_config = AlertConfig::recommended();
if check {
println!("trusty-mpm Telegram bot configuration:");
println!(" daemon url : {url}");
println!(
" token configured : {}",
if token.is_some() { "yes" } else { "no" }
);
println!(" alert categories : {:?}", alert_config.categories);
println!(" memory alerts : {}", alert_config.memory_alerts);
println!(
" alert chat id : {}",
options
.alert_chat_id
.map(|i| i.to_string())
.unwrap_or_else(|| "none".into())
);
println!(
" allowed user id : {}",
options
.allowed_user_id
.map(|i| i.to_string())
.unwrap_or_else(|| "unrestricted".into())
);
println!();
println!("{}", crate::client::command::help_text());
return Ok(());
}
let token = token.ok_or_else(|| {
anyhow::anyhow!("TELEGRAM_BOT_TOKEN is required (or pass --check to validate config)")
})?;
let bot = Bot::new(token);
bot.set_my_commands(TelegramCommand::bot_commands()).await?;
let shutdown = CancellationToken::new();
if let Some(chat_id) = options.alert_chat_id {
let alert_bot = bot.clone();
let alert_url = url.clone();
let alert_cfg = alert_config.clone();
let token = shutdown.clone();
tokio::spawn(async move {
run_alert_loop(alert_bot, ChatId(chat_id), alert_url, alert_cfg, token).await;
});
}
let executor = Arc::new(CommandExecutor::new(url));
let opts = Arc::new(options);
let histories: ChatHistories = Arc::new(Mutex::new(HashMap::new()));
let proxy_backend = Arc::clone(&executor) as Arc<dyn ManagedBackend>;
let proxy = Arc::new(SessionProxy::new(proxy_backend));
let handler = dptree::entry()
.branch(Update::filter_message().endpoint(on_message))
.branch(Update::filter_callback_query().endpoint(on_callback));
Dispatcher::builder(bot, handler)
.dependencies(dptree::deps![executor, opts, histories, proxy])
.enable_ctrlc_handler()
.build()
.dispatch()
.await;
shutdown.cancel();
Ok(())
}
pub async fn run_supervised(
url: String,
token: Option<String>,
options: BotOptions,
shutdown: CancellationToken,
) {
let policy = supervisor::BackoffPolicy::default();
let factory = || {
let url = url.clone();
let token = token.clone();
let options = options.clone();
async move {
match run(url, token, false, options).await {
Ok(()) => supervisor::BotExit::Graceful,
Err(e) => {
let exit = supervisor::classify_error(&e);
tracing::warn!("telegram bot run returned an error: {e:#}");
exit
}
}
}
};
supervisor::supervise(factory, policy, shutdown).await;
}
async fn on_message(
bot: Bot,
msg: Message,
executor: Arc<CommandExecutor>,
options: Arc<BotOptions>,
histories: ChatHistories,
proxy: Arc<SessionProxy>,
) -> ResponseResult<()> {
let Some(text) = msg.text() else {
return Ok(());
};
let user_id = msg.from.as_ref().map(|u| u.id.0 as i64);
if !is_authorized(&options, user_id) {
tracing::warn!(?user_id, "unauthorized Telegram message rejected");
bot.send_message(
msg.chat.id,
"🔒 This bot is restricted to authorized operators.",
)
.await?;
return Ok(());
}
let chat_id = msg.chat.id.0;
match TelegramCommand::parse(text, &bot_username()) {
Ok(TelegramCommand::Focus(arg)) => {
let reply = focus::handle_focus(&proxy, chat_id, &arg).await;
bot.send_message(msg.chat.id, reply)
.parse_mode(ParseMode::Html)
.await?;
}
Ok(TelegramCommand::Unfocus) => {
let reply = focus::handle_unfocus(&proxy, chat_id);
bot.send_message(msg.chat.id, reply)
.parse_mode(ParseMode::Html)
.await?;
}
Ok(TelegramCommand::Summary) => {
let reply = focus::handle_summary(&proxy, chat_id).await;
bot.send_message(msg.chat.id, reply)
.parse_mode(ParseMode::Html)
.await?;
}
Ok(command) => {
let result = dispatch_command(command, &executor, chat_id).await;
let body = TelegramFormatter::format(&result);
let mut send = bot
.send_message(msg.chat.id, body)
.parse_mode(ParseMode::Html);
if let Some(keyboard) = TelegramFormatter::keyboard_for(&result) {
send = send.reply_markup(keyboard);
}
send.await?;
}
Err(_) => {
if text.trim().is_empty() {
return Ok(());
}
let has_focus = proxy.current_focus(&focus::conv(chat_id)).is_some();
let reply = match route_free_text(text, has_focus) {
FreeTextRoute::Inject => focus::inject_reply(&proxy, chat_id, text).await,
FreeTextRoute::Coordinator => {
action_chat_reply(&executor, &histories, chat_id, text).await
}
};
bot.send_message(msg.chat.id, reply)
.parse_mode(ParseMode::Html)
.await?;
}
}
Ok(())
}
async fn dispatch_command(
command: TelegramCommand,
executor: &CommandExecutor,
chat_id: i64,
) -> CommandResult {
match &command {
TelegramCommand::Pair(code) if !code.trim().is_empty() => {
executor.pair_confirm(code.trim(), chat_id).await
}
TelegramCommand::Start(code) if !code.trim().is_empty() => {
executor.pair_confirm(code.trim(), chat_id).await
}
_ => executor.execute(TrustyCommand::from(command)).await,
}
}
async fn action_chat_reply(
executor: &CommandExecutor,
histories: &ChatHistories,
chat_id: i64,
text: &str,
) -> String {
let history = {
let guard = histories.lock().expect("chat history mutex poisoned");
guard.get(&chat_id).cloned().unwrap_or_default()
};
match executor
.client()
.coordinator_chat(text, &history, true)
.await
{
Ok(Some(outcome)) => {
{
let mut guard = histories.lock().expect("chat history mutex poisoned");
let entry = guard.entry(chat_id).or_default();
record_chat_turn(entry, text, &outcome.reply);
}
let mut body = formatter::html_escape(&outcome.reply);
if let Some(output) = outcome.command_output.as_deref()
&& !output.is_empty()
{
body.push('\n');
body.push_str(&formatter::html_escape(output));
}
if let Some(footer) = action_footer(outcome.actions_taken.as_deref()) {
body.push_str(&footer);
}
body
}
Ok(None) => LLM_NOT_CONFIGURED.to_string(),
Err(e) => format!("❌ chat: daemon error: {e}"),
}
}
fn record_chat_turn(entry: &mut Vec<ChatMessage>, text: &str, reply: &str) {
entry.push(ChatMessage::user(text));
if !reply.is_empty() {
entry.push(ChatMessage::assistant(reply));
}
if entry.len() > MAX_CHAT_HISTORY_TURNS {
let overflow = entry.len() - MAX_CHAT_HISTORY_TURNS;
entry.drain(0..overflow);
}
}
fn action_footer(actions: Option<&[String]>) -> Option<String> {
let verbs = actions.filter(|v| !v.is_empty())?;
let joined = verbs
.iter()
.map(|v| formatter::html_escape(v))
.collect::<Vec<_>>()
.join(", ");
Some(format!("\n\n<i>ran: {joined}</i>"))
}
async fn on_callback(
bot: Bot,
query: CallbackQuery,
executor: Arc<CommandExecutor>,
options: Arc<BotOptions>,
proxy: Arc<SessionProxy>,
) -> ResponseResult<()> {
bot.answer_callback_query(query.id.clone()).await?;
let user_id = Some(query.from.id.0 as i64);
if !is_authorized(&options, user_id) {
tracing::warn!(?user_id, "unauthorized Telegram callback rejected");
return Ok(());
}
let Some(data) = query.data.as_deref() else {
return Ok(());
};
let Some(chat_id) = query.message.as_ref().map(|m| m.chat().id) else {
return Ok(());
};
if let Some(("focus", id)) = data.split_once(':') {
let reply = focus::handle_focus(&proxy, chat_id.0, id).await;
bot.send_message(chat_id, reply)
.parse_mode(ParseMode::Html)
.await?;
return Ok(());
}
let result = match data.split_once(':') {
Some(("status", id)) => Some(
executor
.execute(TrustyCommand::Status {
session_id: id.to_string(),
})
.await,
),
Some(("approve", id)) => Some(
executor
.execute(TrustyCommand::Approve {
session_id: id.to_string(),
})
.await,
),
Some(("deny", id)) => Some(
executor
.execute(TrustyCommand::Deny {
session_id: id.to_string(),
})
.await,
),
Some(("adopt", session)) => Some(
executor
.execute(TrustyCommand::Adopt {
session: session.to_string(),
})
.await,
),
Some(("setproj", path)) => Some(executor.register_project(path).await),
_ => None,
};
if let Some(result) = result {
bot.send_message(chat_id, TelegramFormatter::format(&result))
.parse_mode(ParseMode::Html)
.await?;
}
Ok(())
}
pub async fn run_alert_loop(
bot: Bot,
chat_id: ChatId,
daemon_url: String,
config: AlertConfig,
shutdown: CancellationToken,
) {
let client = crate::client::http_client::default_client();
let last_seen = Arc::new(Mutex::new(LastSeen::new()));
let mut session_tick = tokio::time::interval(SESSION_POLL_INTERVAL);
let mut overseer_tick = tokio::time::interval(OVERSEER_POLL_INTERVAL);
loop {
tokio::select! {
_ = shutdown.cancelled() => {
tracing::info!("alert loop shutting down");
return;
}
_ = session_tick.tick() => {
let alerts = poll_session_alerts(&client, &daemon_url, &config, &last_seen).await;
for alert in alerts {
if let Err(e) = bot.send_message(chat_id, &alert.message).await {
tracing::warn!("failed to send alert: {e}");
}
}
}
_ = overseer_tick.tick() => {
if let Some(msg) = poll_overseer_alert(&client, &daemon_url).await
&& let Err(e) = bot.send_message(chat_id, &msg).await {
tracing::warn!("failed to send overseer alert: {e}");
}
}
}
}
}
async fn poll_session_alerts(
client: &reqwest::Client,
daemon_url: &str,
config: &AlertConfig,
last_seen: &Mutex<LastSeen>,
) -> Vec<alerts::PendingAlert> {
let sessions: Vec<serde_json::Value> =
match client.get(format!("{daemon_url}/sessions")).send().await {
Ok(r) => match r.json::<serde_json::Value>().await {
Ok(b) => b["sessions"].as_array().cloned().unwrap_or_default(),
Err(_) => return Vec::new(),
},
Err(_) => return Vec::new(),
};
let mut events_by_session = std::collections::HashMap::new();
for s in &sessions {
let Some(id) = s["id"].as_str() else { continue };
let url = format!("{daemon_url}/sessions/{id}/events/poll");
if let Ok(r) = client.get(&url).send().await
&& let Ok(body) = r.json::<serde_json::Value>().await
{
let events = body["events"].as_array().cloned().unwrap_or_default();
events_by_session.insert(id.to_string(), events);
}
}
let mut guard = last_seen.lock().expect("last_seen mutex poisoned");
alerts::check_and_alert(&sessions, &events_by_session, &mut guard, config)
}
async fn poll_overseer_alert(client: &reqwest::Client, daemon_url: &str) -> Option<String> {
let body: serde_json::Value = client
.get(format!("{daemon_url}/overseer"))
.send()
.await
.ok()?
.json()
.await
.ok()?;
let o = &body["overseer"];
if !o["enabled"].as_bool().unwrap_or(false) {
return None;
}
let blocked = o["blocked_session"].as_str()?;
Some(alerts::format_overseer_block_alert(blocked))
}
#[cfg(test)]
mod tests;