use std::sync::Arc; use teloxide::prelude::*; use teloxide::types::{ChatAction, ParseMode}; use tracing::{error, info}; use core_api::chat_hub::{ModelCommandOutcome, SendMessageOptions}; use core_api::command::expand_template; use core_api::location::GpsCoord; use core_api::message_meta::{CommandRef, MessageMetadata}; use core_api::user_channel::UserChannelHandle; use super::TELEGRAM_FORMAT_CONTEXT; use super::TgShared; use super::attachments::TelegramAttachment; use super::auth::handle_pairing; use super::events::ensure_forwarder; // ── Available commands help text (shared by /help and unknown-command replies) ── const HELP_TEXT: &str = "Available commands\n\n\ /clear — start a new conversation\n\ /new — alias for /clear\n\ /stop — interrupt the agent mid-turn\n\ /models — list available LLM models, ordered by priority\n\ /model <N|name|auto> — select the model for this chat\n\ /context — show last turn's token usage\n\ /cost — show total spend for this session (USD)\n\ /compact — force context compaction\n\ /resettools — remove all activated tool groups (MCP + config) from the session\n\ /sethome — receive agent notifications here\n\ /help — this message"; fn help_text(command: &dyn core_api::command::CommandApi) -> String { let mut out = String::from(HELP_TEXT); let cmds = command.list_enabled(); if !cmds.is_empty() { out.push_str("\n\nCustom commands"); for c in cmds { out.push_str(&format!( "\n/{} — {}", c.name, super::helpers::escape_html(&c.description) )); } } out } // ── Incoming message classification ─────────────────────────────────────────── pub(crate) enum IncomingEvent { Text(String), Command { name: String, args: Vec }, Voice { file_id: String }, Attachment(TelegramAttachment), } pub(crate) fn classify_message(msg: &Message) -> Option { if let Some(voice) = msg.voice() { return Some(IncomingEvent::Voice { file_id: voice.file.id.to_string() }); } if let Some(doc) = msg.document() { return Some(IncomingEvent::Attachment(TelegramAttachment::Document { file_id: doc.file.id.to_string(), file_name: doc.file_name.clone().unwrap_or_else(|| "attachment".to_string()), mime_type: doc.mime_type.as_ref().map(|m| m.to_string()), caption: msg.caption().map(str::to_string), })); } if let Some(photos) = msg.photo() { if let Some(largest) = photos.last() { return Some(IncomingEvent::Attachment(TelegramAttachment::Photo { file_id: largest.file.id.to_string(), caption: msg.caption().map(str::to_string), })); } } if let Some(loc) = msg.location() { return Some(IncomingEvent::Attachment(TelegramAttachment::Location { latitude: loc.latitude, longitude: loc.longitude, accuracy: loc.horizontal_accuracy, is_live: loc.live_period.is_some(), })); } let text = msg.text()?; if text.starts_with('/') { let full = text.trim_start_matches('/'); let mut parts = full.splitn(2, ' '); let name = parts.next().unwrap_or("").to_ascii_lowercase(); let name = name.split('@').next().unwrap_or(&name).to_string(); let rest = parts.next().unwrap_or("").trim().to_string(); let args: Vec = if rest.is_empty() { vec![] } else { rest.split_whitespace().map(str::to_string).collect() }; return Some(IncomingEvent::Command { name, args }); } Some(IncomingEvent::Text(text.to_string())) } // ── Message handler ─────────────────────────────────────────────────────────── pub(crate) async fn message_handler( bot: Bot, msg: Message, shared: Arc, ) -> ResponseResult<()> { let chat_id = msg.chat.id; // Resolve chat_id → user_id from bindings. let user_id = match shared.user_for_chat(chat_id.0).await { Some(uid) => uid, None => { handle_pairing(&bot, chat_id, &shared).await; return Ok(()); } }; // The chat is bound, but access is a separate admin-revocable grant. Gate // here so a revoked user is refused immediately, without touching the // binding (a re-grant restores service with no re-pairing). if !shared.user_authorized(&user_id).await { bot.send_message( chat_id, "⛔ Your access to this bot has been withdrawn by an administrator.", ) .await .ok(); return Ok(()); } // Resolve the user's per-user context (must be unlocked, §9). let handle = match shared.user_channel.resolve_user(&user_id).await { Some(h) => h, None => { bot.send_message( chat_id, "🔒 Your account is locked. Please log in via the web app first, \ then send another message.", ) .await .ok(); return Ok(()); } }; // Ensure a per-user forwarder is running so the response events reach // this Telegram chat. The forwarder will exit on broadcast-close (user // locked) or plugin stop; a fresh CancellationToken is fine here since // the global plugin cancel drops the dispatcher (and thus the shared Arc). ensure_forwarder( bot.clone(), Arc::clone(&shared), &user_id, chat_id.0, Arc::clone(&handle), tokio_util::sync::CancellationToken::new(), ).await; let Some(incoming) = classify_message(&msg) else { bot.send_message(chat_id, "Unsupported message format.").await.ok(); return Ok(()); }; let hub = handle.chat_hub(); match incoming { IncomingEvent::Command { ref name, .. } if name == "clear" || name == "new" => { handle_clear(&bot, chat_id, &hub).await; } IncomingEvent::Command { ref name, .. } if name == "sethome" => { match hub.set_home("telegram").await { Ok(_) => { info!("telegram: set as home source for user {}", user_id); bot.send_message(chat_id, "🏠 Telegram set as home. Agent notifications will be delivered here.") .parse_mode(ParseMode::Html) .await .ok(); } Err(e) => { bot.send_message(chat_id, format!("⚠️ Error: {e}")).await.ok(); } } } IncomingEvent::Command { ref name, .. } if name == "help" => { bot.send_message(chat_id, help_text(&*shared.command)) .parse_mode(ParseMode::Html) .await .ok(); } IncomingEvent::Command { ref name, .. } if name == "stop" => { hub.cancel("telegram").await; info!("telegram: agent cancelled via /stop"); bot.send_message(chat_id, "⏹ Agent stopped.").await.ok(); } IncomingEvent::Command { ref name, .. } if name == "context" => { handle_context(&bot, chat_id, &hub).await; } IncomingEvent::Command { ref name, .. } if name == "cost" => { handle_cost(&bot, chat_id, &hub).await; } IncomingEvent::Command { ref name, .. } if name == "compact" => { handle_compact(&bot, chat_id, &hub).await; } IncomingEvent::Command { ref name, .. } if name == "resettools" => { handle_reset_mcp(&bot, chat_id, &hub).await; } IncomingEvent::Command { ref name, .. } if name == "models" => { handle_list_models(&bot, chat_id, &hub).await; } IncomingEvent::Command { ref name, ref args, .. } if name == "model" => { handle_set_model(&bot, chat_id, args, &hub).await; } IncomingEvent::Command { ref name, ref args, .. } => { if let Some(resolved) = shared.command.resolve(name) { let args_str = args.join(" "); let display = if args_str.is_empty() { format!("/{name}") } else { format!("/{name} {args_str}") }; let content = expand_template(&resolved.template, &args_str); let metadata = MessageMetadata { command: Some(CommandRef { name: resolved.name, display, }), ..Default::default() }; handle_llm_message(bot, chat_id, content, Some(metadata), shared, &handle).await; } else { bot.send_message( chat_id, format!("Unknown command: /{name}\n\n{}", help_text(&*shared.command)), ) .parse_mode(ParseMode::Html) .await .ok(); } } IncomingEvent::Voice { file_id } => { handle_voice(&bot, chat_id, file_id, &shared, &handle).await; } IncomingEvent::Attachment(attachment) => { handle_attachment(bot, chat_id, attachment, shared, &handle).await; } _ => { let text = match &incoming { IncomingEvent::Text(t) => t.clone(), IncomingEvent::Command { .. } | IncomingEvent::Voice { .. } | IncomingEvent::Attachment(_) => unreachable!(), }; // If a clarification question is pending for this chat, treat any // text as the answer. { let mut pq_map = shared.pending_questions.lock().await; if let Some(pq) = pq_map.remove(&chat_id.0) { let request_id = pq.request_id; let question_msg_id = pq.message_id; drop(pq_map); hub.resolve_question("telegram", request_id, text.clone()).await; tracing::info!(request_id, %text, "telegram: clarification answered via text"); bot.edit_message_reply_markup(chat_id, question_msg_id) .reply_markup(teloxide::types::InlineKeyboardMarkup::new(vec![vec![ teloxide::types::InlineKeyboardButton::callback( format!("✅ {}", super::helpers::escape_html(&text)), "noop", ), ]])) .await .ok(); return Ok(()); } } handle_llm_message(bot, chat_id, text, None, shared, &handle).await; } } Ok(()) } // ── Command handlers ────────────────────────────────────────────────────────── async fn handle_clear(bot: &Bot, chat_id: ChatId, hub: &Arc) { match hub.clear("telegram").await { Ok(_) => { info!("telegram: session cleared via /clear"); bot.send_message(chat_id, "🆕 New conversation started.").await.ok(); } Err(e) => { error!(error = %e, "telegram: failed to clear session"); bot.send_message(chat_id, format!("⚠️ Error: {e}")).await.ok(); } } } async fn handle_context(bot: &Bot, chat_id: ChatId, hub: &Arc) { match hub.context_info("telegram").await { Ok((input, output)) => { let input_str = input.map_or("?".to_string(), |t| t.to_string()); let output_str = output.map_or("?".to_string(), |t| t.to_string()); bot.send_message( chat_id, format!("↑{input_str} tok · ↓{output_str} tok"), ) .parse_mode(ParseMode::Html) .await .ok(); } Err(e) => { bot.send_message(chat_id, format!("⚠️ Error: {e}")).await.ok(); } } } async fn handle_cost(bot: &Bot, chat_id: ChatId, hub: &Arc) { match hub.cost_info("telegram").await { Ok(Some(c)) => { bot.send_message(chat_id, format!("💰 Session cost: ${c:.4}")).await.ok(); } Ok(None) => { bot.send_message(chat_id, "💰 No cost recorded for this session.").await.ok(); } Err(e) => { bot.send_message(chat_id, format!("⚠️ Error: {e}")).await.ok(); } } } async fn handle_compact(bot: &Bot, chat_id: ChatId, hub: &Arc) { match hub.force_compact("telegram").await { Ok(true) => { info!("telegram: manual compaction succeeded"); bot.send_message(chat_id, "✅ Context compacted.").await.ok(); } Ok(false) => { bot.send_message(chat_id, "⏩ Compaction skipped (nothing to summarise).").await.ok(); } Err(e) => { error!(error = %e, "telegram: manual compaction failed"); bot.send_message(chat_id, format!("⚠️ Compaction failed: {e}")).await.ok(); } } } async fn handle_reset_mcp(bot: &Bot, chat_id: ChatId, hub: &Arc) { match hub.reset_mcp("telegram").await { Ok(()) => { info!("telegram: tool-group grants reset via /resettools"); bot.send_message(chat_id, "✅ Activated tool groups removed from the session.").await.ok(); } Err(e) => { error!(error = %e, "telegram: /resettools failed"); bot.send_message(chat_id, format!("⚠️ Error: {e}")).await.ok(); } } } async fn handle_list_models(bot: &Bot, chat_id: ChatId, hub: &Arc) { let items = hub.list_clients_marked("telegram").await; let mut text = String::from("Available models\n\n"); for (i, name, is_current) in &items { let marker = if *is_current { "●" } else { "○" }; text.push_str(&format!( "{} {:2} {}\n", marker, i, super::helpers::escape_html(name) )); } text.push_str("\nUse /model N, /model name, or /model auto."); bot.send_message(chat_id, text) .parse_mode(ParseMode::Html) .await .ok(); } async fn handle_set_model(bot: &Bot, chat_id: ChatId, args: &[String], hub: &Arc) { let arg = args.first().cloned().unwrap_or_default(); let outcome = hub.apply_model_command("telegram", &arg).await; let text = match outcome { ModelCommandOutcome::Set(name) => format!("✅ Model set: {}", super::helpers::escape_html(&name)), ModelCommandOutcome::Cleared => "✅ Model reset to auto.".to_string(), ModelCommandOutcome::Error(msg) => format!("⚠️ {}", super::helpers::escape_html(&msg)), }; bot.send_message(chat_id, text) .parse_mode(ParseMode::Html) .await .ok(); } // ── LLM dispatch ───────────────────────────────────────────────────────────── async fn handle_llm_message( bot: Bot, chat_id: ChatId, text: String, metadata: Option, shared: Arc, handle: &Arc, ) { bot.send_chat_action(chat_id, ChatAction::Typing).await.ok(); let hub = handle.chat_hub(); let client_name = hub.get_selected_client("telegram").await; let opts = SendMessageOptions { client_name, extra_system_context: Some(TELEGRAM_FORMAT_CONTEXT.to_string()), tail_reminder: Some(super::TELEGRAM_FORMAT_REMINDER.to_string()), interface_tools: super::tools::interface_tools( bot.clone(), chat_id, &*shared.tts, handle.files(), ).await, metadata, ..Default::default() }; if let Err(e) = hub.send_message("telegram", &text, opts).await { error!(error = %e, "telegram: enqueue error"); } } // ── Voice message → transcribe → LLM ───────────────────────────────────────── async fn handle_voice( bot: &Bot, chat_id: ChatId, file_id: String, shared: &Arc, handle: &Arc, ) { use teloxide::net::Download; let transcriber = match shared.transcriber().await { Some(t) => t, None => { bot.send_message(chat_id, "⚠️ Transcription not available (no transcription provider configured).").await.ok(); return; } }; let file = match bot.get_file(teloxide::types::FileId(file_id)).await { Ok(f) => f, Err(e) => { error!(error = %e, "telegram: get_file failed"); bot.send_message(chat_id, "⚠️ Could not download audio file.").await.ok(); return; } }; let mut audio_bytes = Vec::new(); if let Err(e) = bot.download_file(&file.path, &mut audio_bytes).await { error!(error = %e, "telegram: download_file failed"); bot.send_message(chat_id, "⚠️ Audio download failed.").await.ok(); return; } bot.send_chat_action(chat_id, ChatAction::Typing).await.ok(); let text = match transcriber.transcribe(audio_bytes, "ogg").await { Ok(t) => t, Err(e) => { error!(error = %e, "telegram: transcription failed"); bot.send_message(chat_id, format!("⚠️ Transcription failed: {e}")).await.ok(); return; } }; info!(chat_id = chat_id.0, "telegram: voice transcribed, forwarding to LLM"); // The transcript is the user's actual message — forward it verbatim as the // user text, with no harness wrapper. The agent treats it exactly as if the // user had typed those words. handle_llm_message(bot.clone(), chat_id, text, None, Arc::clone(shared), handle).await; } // ── Edited message (live location updates) ──────────────────────────────────── pub(crate) async fn edited_message_handler( msg: Message, shared: Arc, ) -> ResponseResult<()> { if let Some(loc) = msg.location() { let coord = GpsCoord { latitude: loc.latitude, longitude: loc.longitude }; shared.location.update("telegram", coord, loc.horizontal_accuracy, true); } Ok(()) } // ── File / media attachment ─────────────────────────────────────────────────── async fn handle_attachment( bot: Bot, chat_id: ChatId, attachment: TelegramAttachment, shared: Arc, handle: &Arc, ) { if let TelegramAttachment::Location { latitude, longitude, accuracy, is_live } = &attachment { let coord = GpsCoord { latitude: *latitude, longitude: *longitude }; shared.location.update("telegram", coord, *accuracy, *is_live); } bot.send_chat_action(chat_id, ChatAction::UploadDocument).await.ok(); let downloaded = match attachment.download(&bot).await { Ok(d) => d, Err(e) => { error!(error = %e, "telegram: failed to download attachment"); bot.send_message(chat_id, "⚠️ Could not download the attachment.").await.ok(); return; } }; match downloaded { Some((file_name, mimetype, bytes)) => { // Persist through the shared upload seam so the file lands in the user's // home (`uploads/{session}/…`) with an agent-reachable path — identical // to a web upload. let att = match handle .chat_hub() .save_upload("telegram", &file_name, mimetype, &bytes) .await { Ok(a) => a, Err(e) => { error!(error = %e, "telegram: failed to save attachment"); bot.send_message(chat_id, "⚠️ Could not save the attachment.").await.ok(); return; } }; info!(chat_id = chat_id.0, path = %att.path, "telegram: attachment saved, forwarding to LLM"); let caption = match &attachment { TelegramAttachment::Document { caption, .. } => caption.clone(), TelegramAttachment::Photo { caption, .. } => caption.clone(), TelegramAttachment::Location { .. } => None, }.unwrap_or_default(); let metadata = MessageMetadata { attachments: vec![att], ..Default::default() }; handle_llm_message(bot, chat_id, caption, Some(metadata), shared, handle).await; } None => { // File-less attachment (Location): the `` block is the // whole user message, so strip the leading blank lines `system_extra` // adds for the concatenation case. let message = attachment.system_info_message(None); let message = message.trim_start_matches(['\n', '\r']).to_owned(); handle_llm_message(bot, chat_id, message, None, shared, handle).await; } } }