uploads: centralise via ChatHubApi::save_upload, refactor handlers
Nightly Build / build (push) Successful in 6m41s

Extract shared upload seam in skald-core, move Telegram and web
handlers to use it. Simplify media attachment routing. Clean up
unused deps and dead code.
This commit is contained in:
2026-07-22 19:19:29 +01:00
parent e70c4a90f3
commit 6f0461f7f5
15 changed files with 350 additions and 214 deletions
+13 -35
View File
@@ -1,7 +1,6 @@
use std::path::Path;
use anyhow::Result;
use core_api::message_meta::Attachment;
use teloxide::net::Download;
use teloxide::prelude::*;
@@ -10,9 +9,9 @@ use teloxide::prelude::*;
/// # Extending
/// Add a new variant here, then handle it in:
/// 1. `handlers::classify_message` — detect the message type and build the variant
/// 2. `TelegramAttachment::download_and_save` — fetch bytes and persist to disk,
/// returning an [`Attachment`]
/// (return `Ok(None)` if no file is involved)
/// 2. `TelegramAttachment::download` — fetch the bytes (return `Ok(None)` if no
/// file is involved); the caller persists them
/// via the shared `ChatHubApi::save_upload` seam
/// 3. `TelegramAttachment::system_info_message` — describe a file-less variant
/// (Location) for the LLM
pub(crate) enum TelegramAttachment {
@@ -36,20 +35,16 @@ pub(crate) enum TelegramAttachment {
}
impl TelegramAttachment {
/// Downloads the attachment from Telegram, writes it to `base_dir/<chat_id>/<name>`,
/// and returns the saved [`Attachment`] (shared with the web/mobile path so the
/// copilot UI renders it identically). Returns `None` for attachment types that
/// carry no binary content (e.g. Location).
///
/// The returned `path` is made relative to the process working directory (the
/// project root) when possible, so it is both servable under `/data/…` and
/// resolvable by the filesystem tools — matching web uploads.
pub(crate) async fn download_and_save(
/// Downloads the attachment's bytes from Telegram, returning
/// `(file_name, mimetype, bytes)`. Persistence is **not** done here: the caller
/// hands the bytes to the shared upload seam (`ChatHubApi::save_upload`), which
/// saves them into the user's home under `uploads/{session}/…` and produces the
/// [`Attachment`] — the same path every surface uses, so the agent can reach it.
/// Returns `None` for attachment types that carry no binary content (e.g. Location).
pub(crate) async fn download(
&self,
bot: &Bot,
base_dir: &Path,
chat_id: i64,
) -> Result<Option<Attachment>> {
bot: &Bot,
) -> Result<Option<(String, Option<String>, Vec<u8>)>> {
let (file_id, file_name, mimetype): (&str, String, Option<String>) = match self {
Self::Document { file_id, file_name, mime_type, .. } =>
(file_id, file_name.clone(), mime_type.clone()),
@@ -58,28 +53,11 @@ impl TelegramAttachment {
Self::Location { .. } => return Ok(None),
};
let dir = base_dir.join(chat_id.to_string());
tokio::fs::create_dir_all(&dir).await?;
let tg_file = bot.get_file(teloxide::types::FileId(file_id.to_string())).await?;
let mut bytes = Vec::new();
bot.download_file(&tg_file.path, &mut bytes).await?;
let path = dir.join(&file_name);
tokio::fs::write(&path, &bytes).await?;
// Prefer a project-root-relative path so `/data/…` serving works.
let rel = std::env::current_dir()
.ok()
.and_then(|cwd| path.strip_prefix(&cwd).ok().map(Path::to_path_buf))
.unwrap_or_else(|| path.clone());
Ok(Some(Attachment {
path: rel.to_string_lossy().to_string(),
name: file_name,
mimetype,
filesize: Some(bytes.len() as u64),
}))
Ok(Some((file_name, mimetype, bytes)))
}
/// Builds the `[TELEGRAM SYSTEM INFO]` message injected into the conversation history.
+21 -6
View File
@@ -512,17 +512,32 @@ async fn handle_attachment(
bot.send_chat_action(chat_id, ChatAction::UploadDocument).await.ok();
let saved = match attachment.download_and_save(&bot, &shared.uploads_dir, chat_id.0).await {
Ok(s) => s,
let downloaded = match attachment.download(&bot).await {
Ok(d) => d,
Err(e) => {
error!(error = %e, "telegram: failed to save attachment");
bot.send_message(chat_id, "⚠️ Could not save the attachment.").await.ok();
error!(error = %e, "telegram: failed to download attachment");
bot.send_message(chat_id, "⚠️ Could not download the attachment.").await.ok();
return;
}
};
match saved {
Some(att) => {
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(),
-8
View File
@@ -26,7 +26,6 @@
/// `ApprovalApi`.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -106,7 +105,6 @@ pub(crate) struct TgShared {
pub(crate) transcribe: Arc<dyn TranscribeProvider>,
pub(crate) tts: Arc<dyn TtsProvider>,
pub(crate) location: Arc<dyn LocationUpdater>,
pub(crate) uploads_dir: PathBuf,
// ── Pairing / bindings (config-table-backed, cached in memory) ──
pub(crate) bindings: RwLock<auth::TelegramConfig>,
@@ -272,11 +270,6 @@ impl Plugin for TelegramPlugin {
anyhow::bail!("telegram: token is empty — set it via the plugins API");
}
let uploads_dir = std::env::current_dir()
.unwrap_or_default()
.join("uploads")
.join("telegram");
// Load bindings from the config table (or default if absent).
let telegram_config = auth::load_config(&*ctx.config).await
.unwrap_or_default();
@@ -293,7 +286,6 @@ impl Plugin for TelegramPlugin {
transcribe: Arc::clone(&ctx.transcribe),
tts: Arc::clone(&ctx.tts_provider),
location: Arc::clone(&ctx.location),
uploads_dir,
bindings: RwLock::new(telegram_config),
pending_approvals: Mutex::new(HashMap::new()),
pending_questions: Mutex::new(HashMap::new()),