Files
Skald-Circle/crates/plugin-telegram-bot/src/handlers.rs
T
Daniele 55dcb48299
Nightly Build / build (push) Successful in 8m6s
fix(telegram): resolve send_attachment paths in the user's workspace
`send_attachment` handed its `file_path` argument straight to
`InputFile::file`, which resolves against the **server process's** working
directory. Every path the model can actually have — relative to the user's
home, or absolute inside their container — failed the `path.exists()` check,
and the one class that didn't (a name that happens to exist next to the
binary) would have sent the wrong file.

The routing already exists for the fs-tools, so expose it rather than repeat
it: `UserFilesApi` (core-api) reads a path in the agent's own vocabulary and
is obtained from `UserChannelHandle::files()`, so it is scoped to one user by
construction. skald-core implements it over `resolve_view_target` — host
mount read directly, container-only path through `docker exec` — holding the
`SharedFs` cell rather than a snapshot, so a remount lands without a login.

The size cap is checked before the read (a new `exec_fs::size` for the
container branch): the point of a cap is to keep an oversized file out of RAM,
so checking it afterwards would protect nothing. A photo above `sendPhoto`'s
narrower 10 MB ceiling goes out as a document instead of as an API error.
2026-08-10 00:08:16 +01:00

560 lines
22 KiB
Rust

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 = "<b>Available commands</b>\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 &lt;N|name|auto&gt; — 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\n<b>Custom commands</b>");
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<String> },
Voice { file_id: String },
Attachment(TelegramAttachment),
}
pub(crate) fn classify_message(msg: &Message) -> Option<IncomingEvent> {
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<String> = 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<TgShared>,
) -> 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 <b>home</b>. 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<dyn core_api::chat_hub::ChatHubApi>) {
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<dyn core_api::chat_hub::ChatHubApi>) {
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!("<i>↑{input_str} tok · ↓{output_str} tok</i>"),
)
.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<dyn core_api::chat_hub::ChatHubApi>) {
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<dyn core_api::chat_hub::ChatHubApi>) {
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<dyn core_api::chat_hub::ChatHubApi>) {
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<dyn core_api::chat_hub::ChatHubApi>) {
let items = hub.list_clients_marked("telegram").await;
let mut text = String::from("<b>Available models</b>\n\n");
for (i, name, is_current) in &items {
let marker = if *is_current { "●" } else { "○" };
text.push_str(&format!(
"{} <code>{:2}</code> {}\n",
marker,
i,
super::helpers::escape_html(name)
));
}
text.push_str("\nUse <code>/model N</code>, <code>/model name</code>, or <code>/model auto</code>.");
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<dyn core_api::chat_hub::ChatHubApi>) {
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: <b>{}</b>", super::helpers::escape_html(&name)),
ModelCommandOutcome::Cleared => "✅ Model reset to <b>auto</b>.".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<MessageMetadata>,
shared: Arc<TgShared>,
handle: &Arc<dyn UserChannelHandle>,
) {
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<TgShared>,
handle: &Arc<dyn UserChannelHandle>,
) {
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<TgShared>,
) -> 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<TgShared>,
handle: &Arc<dyn UserChannelHandle>,
) {
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 `<system-extra>` 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;
}
}
}