use std::time::Duration; use poise::serenity_prelude as serenity; use tracing::warn; use super::autocomplete::any_file; use crate::music::sanitize_yt_url; use crate::Data; use crate::copyparty::{html_decode, percent_decode, redact_url}; use crate::error::{Error, Result}; use crate::lang; type Context<'a> = poise::Context<'a, Data, Error>; // Above this size (or when `serwer` is forced) the result is uploaded to // copyparty instead of being attached to the Discord reply. Discord's own // attachment limit is 25 MiB for non-Nitro, but 10 MiB matches the Python // original and keeps replies snappy. const LARGE_FILE_THRESHOLD: usize = 10 * 1024 * 1024; /// Hard cap for a single `/download` result (before it is read into memory). const MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024; #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum FileType { Video, Audio, Image, Unknown, } /// Classify a file by its extension. /// /// Mirrors Python's `mimetypes.guess_type`-based `filetype()` helper without /// pulling in a mime crate: the set of extensions below covers everything the /// original bot handled. pub(crate) fn file_type(filename: &str) -> FileType { let Some((_, ext)) = filename.rsplit_once('.') else { return FileType::Unknown; }; if ext.is_empty() || ext.contains('/') { return FileType::Unknown; } match ext.to_ascii_lowercase().as_str() { "mp4" | "avi" | "mkv" | "mov" | "webm" | "flv" | "wmv" | "m4v" | "mpg" | "mpeg" | "3gp" | "ts" => FileType::Video, "mp3" | "wav" | "ogg" | "flac" | "m4a" | "opus" | "aac" | "wma" | "oga" => FileType::Audio, "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff" | "tif" | "avif" => { FileType::Image } _ => FileType::Unknown, } } /// Lowercased extension without the leading dot, or `None` if there is none. fn ext_of(filename: &str) -> Option { let (_, ext) = filename.rsplit_once('.')?; if ext.is_empty() || ext.contains('/') { None } else { Some(ext.to_ascii_lowercase()) } } /// Filename stem: everything before the final dot of the basename. pub(crate) fn stem_of(filename: &str) -> &str { let base = filename.rsplit('/').next().unwrap_or(filename); match base.rsplit_once('.') { Some((stem, _)) if !stem.is_empty() => stem, _ => base, } } /// A safe basename: no directory components, no characters Discord/Copyparty /// filenames commonly abuse, and never `.`/`..`. fn safe_basename(name: &str) -> String { // Treat both separators as directory boundaries: Discord filenames may // come from Windows clients even though the bot runs on Linux. let base = name.rsplit(['/', '\\']).next().unwrap_or(name); let cleaned = sanitize_filename(base) .trim_matches(|c| c == '.' || c == ' ') .to_string(); if cleaned.is_empty() { "file".to_string() } else { cleaned } } /// Create a unique per-operation directory and return /// `(directory, input_path, output_path)`. Concurrent commands can never /// overwrite or delete each other's files. async fn temp_op( original: &str, output_name: &str, ) -> std::io::Result<(String, String, String)> { let safe = safe_basename(original); let ext = ext_of(&safe) .map(|ext| format!(".{ext}")) .unwrap_or_default(); let dir = format!( "{}/op_{}", crate::config::temp_path(), uuid::Uuid::new_v4() ); tokio::fs::create_dir_all(&dir).await?; let input_path = format!("{dir}/input{ext}"); let output_path = format!("{dir}/{}", safe_basename(output_name)); Ok((dir, input_path, output_path)) } /// Best-effort removal of a per-operation temp directory. async fn cleanup_dir(dir: &str) { let _ = tokio::fs::remove_dir_all(dir).await; } /// Resolve the input source — either an autocomplete-picked copyparty file or /// a Discord attachment — downloading the bytes and returning them alongside /// the original filename. /// /// Replies with an appropriate user-facing message and returns `Ok(None)` when /// no valid source was supplied, so callers can early-return without sending a /// second message. async fn resolve_input( ctx: &Context<'_>, plik: Option<&str>, zalacznik: Option<&serenity::Attachment>, ) -> Result, String)>> { if let Some(name) = plik { // Autocomplete-selected copyparty file: the choice value is the decoded // filename, stashed into the per-user ac_cache by `any_file`. let hit = match ctx.data().ac_lookup(ctx.author().id, name).await { Some(h) => h, None => { ctx.say(lang::L_UTIL_PICK_FROM_AUTOCOMPLETE).await?; return Ok(None); } }; let path = hit.url_path().unwrap_or(""); let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") }; let full_url = ctx.data().copyparty.build_url(&path); let resp = ctx.data().http_client.get(&full_url).send().await?; if !resp.status().is_success() { ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?; return Ok(None); } let bytes = resp.bytes().await?.to_vec(); return Ok(Some((bytes, name.to_string()))); } if let Some(att) = zalacznik { let resp = ctx.data().http_client.get(&att.url).send().await?; if !resp.status().is_success() { ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?; return Ok(None); } let bytes = resp.bytes().await?.to_vec(); return Ok(Some((bytes, att.filename.clone()))); } ctx.say(lang::L_UTIL_NEED_INPUT).await?; Ok(None) } /// Output extension the compressor produces for a given input. /// /// - Video keeps its container extension (ffmpeg re-muxes in place). /// - Audio is always re-encoded to Opus in an `.ogg` container. /// - JPEG/GIF keep their extension; other images are rewritten to `.jpg`. pub(crate) fn compress_output_ext(ft: FileType, original: &str) -> String { let orig = ext_of(original); match ft { FileType::Video => orig.unwrap_or_else(|| "mp4".to_string()), FileType::Audio => "ogg".to_string(), FileType::Image => match orig.as_deref() { Some("jpg" | "jpeg" | "gif") => orig.unwrap(), _ => "jpg".to_string(), }, FileType::Unknown => String::new(), } } /// Build the ffmpeg/magick command for extreme-low-quality compression. /// Returns `None` for unsupported (`Unknown`) file types. pub(crate) fn build_compress_command( ft: FileType, input: &str, output: &str, ) -> Option { match ft { FileType::Video => { let mut c = tokio::process::Command::new("ffmpeg"); c.arg("-y") .arg("-i") .arg(input) .arg("-c:v") .arg("libx264") .arg("-crf") .arg("51") .arg("-b:v") .arg("100") .arg("-vf") .arg("fps=10,scale=trunc(iw*0.5/2)*2:trunc(ih*0.5/2)*2") .arg("-preset") .arg("ultrafast") .arg("-c:a") .arg("libopus") .arg("-b:a") .arg("500") .arg(output); Some(c) } FileType::Audio => { let mut c = tokio::process::Command::new("ffmpeg"); c.arg("-y") .arg("-i") .arg(input) .arg("-c:a") .arg("libopus") .arg("-b:a") .arg("500") .arg("-ar") .arg("12000") .arg("-ac") .arg("1") .arg(output); Some(c) } FileType::Image => { let mut c = tokio::process::Command::new("magick"); c.arg(input); match ext_of(input).as_deref() { Some("gif") => { c.arg("-coalesce") .arg("-resize") .arg("30%") .arg("-colors") .arg("32") .arg("-strip") .arg("-layers") .arg("Optimize"); } // jpg/jpeg stay jpg; other images are rewritten to .jpg by // `compress_output_ext` and magick infers the encoder from the // output extension. _ => { c.arg("-strip") .arg("-resize") .arg("30%") .arg("-quality") .arg("5"); } } c.arg(output); Some(c) } FileType::Unknown => None, } } enum DeliverKind { Compress, Convert, } /// Compress a single in-memory blob (an attachment body) into a compressed /// file, returning the encoded bytes and the output filename. /// /// Mirrors the per-attachment path of Python's `Compression` message command, /// but each invocation gets its own temp directory, so concurrent calls cannot /// overwrite or delete each other's files. async fn compress_attachment_bytes( ft: FileType, original: &str, input_bytes: &[u8], ) -> Option<(Vec, String)> { let safe_original = safe_basename(original); let stem = stem_of(&safe_original).to_string(); let out_ext = compress_output_ext(ft, &safe_original); let out_filename = format!("compressed_{stem}.{out_ext}"); let (dir, input_path, output_path) = temp_op(&safe_original, &out_filename).await.ok()?; if tokio::fs::write(&input_path, input_bytes).await.is_err() { cleanup_dir(&dir).await; return None; } let Some(mut cmd) = build_compress_command(ft, &input_path, &output_path) else { cleanup_dir(&dir).await; return None; }; let res = match cmd.output().await { Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(), _ => None, }; cleanup_dir(&dir).await; res.map(|b| (b, out_filename)) } /// Compress audio bytes for playback using Vorbis (symphonia-decodable). /// Uses Vorbis instead of Opus because symphonia 0.6 lacks an Opus decoder. /// Returns the compressed .ogg bytes on success. pub(crate) async fn compress_audio_bytes(input_bytes: &[u8], original_name: &str) -> Option> { let safe_name = safe_basename(original_name); let stem = stem_of(&safe_name).to_string(); let out_filename = format!("compressed_{stem}.ogg"); let (dir, input_path, output_path) = temp_op(&safe_name, &out_filename).await.ok()?; if let Err(e) = tokio::fs::write(&input_path, input_bytes).await { tracing::warn!("compress_audio_bytes: failed to write input: {e}"); cleanup_dir(&dir).await; return None; } tracing::debug!( input = %input_path, output = %output_path, bytes = input_bytes.len(), "compress_audio_bytes: running ffmpeg" ); // Opus at 500bps, 12 kHz, mono — same as /compress. // Decoded via ffmpeg in the caller (symphonia lacks Opus support). let mut cmd = tokio::process::Command::new("ffmpeg"); cmd.arg("-y") .arg("-i") .arg(&input_path) .arg("-c:a") .arg("libopus") .arg("-b:a") .arg("500") .arg("-ar") .arg("12000") .arg("-ac") .arg("1") .arg(&output_path); let res = match cmd.output().await { Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(), Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); tracing::warn!( input = %input_path, output = %output_path, exit = %out.status, "compress_audio_bytes ffmpeg failed:\n{}", stderr.chars().take(500).collect::() ); None } Err(e) => { tracing::warn!("compress_audio_bytes ffmpeg spawn error: {e}"); None } }; cleanup_dir(&dir).await; res } /// Strip characters that Discord/coping filenames can't safely carry. Mirrors /// Python's `Downloader._sanitize_filename`. fn sanitize_filename(name: &str) -> String { const BAD: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*']; name.chars() .filter(|c| !BAD.contains(c)) .collect::() .trim() .to_string() } /// Extract `http://` and `https://` URLs from text. After consuming each URL, /// trailing characters not in the RFC 3986 unreserved + sub-delims set are /// stripped (e.g. a trailing backtick that should have been URL-encoded). pub(crate) fn extract_urls(content: &str) -> Vec { fn is_url_char(b: u8) -> bool { b.is_ascii_alphanumeric() || matches!( b, b'-' | b'.' | b'_' | b'~' | b':' | b'/' | b'?' | b'#' | b'[' | b']' | b'@' | b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'%' | b'=' ) } let bytes = content.as_bytes(); let mut urls = Vec::new(); let mut i = 0; while i < bytes.len() { // Find next http:// or https:// let scheme = if bytes[i..].starts_with(b"https://") { Some("https://") } else if bytes[i..].starts_with(b"http://") { Some("http://") } else { None }; let Some(s) = scheme else { i += 1; continue; }; let start = i; let after = i + s.len(); // Consume the host (until `/` or whitespace). let mut j = after; while j < bytes.len() && !bytes[j].is_ascii_whitespace() && bytes[j] != b'/' { j += 1; } if j < bytes.len() && bytes[j] == b'/' { // Consume the rest of the URL (valid URL chars only). let mut k = j; while k < bytes.len() && is_url_char(bytes[k]) { k += 1; } if k > after { urls.push(content[start..k].to_string()); } i = k; } else { // No path component — not matched; advance past it. i = after; } } urls } /// Locate the single file yt-dlp produced in `dir`, picking the most recently /// modified one (matches Python's `os.listdir`-first fallback behaviour while /// being deterministic enough for the per-call unique subdir). async fn find_downloaded_file(dir: &str) -> Option<(String, String)> { let mut rd = tokio::fs::read_dir(dir).await.ok()?; let mut newest: Option<(std::time::SystemTime, String)> = None; while let Ok(Some(entry)) = rd.next_entry().await { let meta = match entry.metadata().await { Ok(m) => m, Err(_) => continue, }; if !meta.is_file() { continue; } let mtime = match meta.modified() { Ok(t) => t, Err(_) => continue, }; if let Some((t, _)) = &newest && *t >= mtime { continue; } newest = Some((mtime, entry.path().to_string_lossy().into_owned())); } newest.map(|(_, p)| { let fname = std::path::Path::new(&p) .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); (p, fname) }) } /// Hand the produced download back to the user: attach when below the size /// threshold (and upload isn't forced), otherwise push to copyparty and reply /// with the download URL. Mirrors Python's `Downloader` slash `/download`. async fn deliver_download( ctx: &Context<'_>, bytes: Vec, filename: String, catbox: bool, ) -> Result<()> { if bytes.len() > LARGE_FILE_THRESHOLD || catbox { match ctx.data().copyparty.upload(&filename, bytes).await { Ok(url) => { ctx.say(lang::fmt_download_uploaded(&url)).await?; } Err(e) => { tracing::warn!("copyparty upload failed: {e}"); ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; } } } else { ctx.send( poise::CreateReply::default() .attachment(serenity::CreateAttachment::bytes(bytes, filename)), ) .await?; } Ok(()) } /// Run yt-dlp on `url` and deliver the resulting file. The caller is expected /// to have already called `ctx.defer()`. Each invocation uses a fresh unique /// subdirectory under `TEMP_PATH` (matching Python's per-call /// `tempfile.TemporaryDirectory`) so concurrent downloads never collide. async fn run_download( ctx: &Context<'_>, url: &str, catbox: bool, fformat: Option<&str>, filename_user: Option<&str>, ) -> Result<()> { // Copyparty may legitimately live on localhost/LAN; every other host must // be public. This blocks metadata endpoints and internal services. if !ctx.data().copyparty.is_own_url(url) { crate::net::ensure_public_http_url(url).await?; } let fmt = fformat.unwrap_or("mp4"); let is_audio = matches!(fmt, "mp3" | "wav" | "m4a"); let dl_dir = format!("{}/dl_{}", crate::config::temp_path(), uuid::Uuid::new_v4()); tokio::fs::create_dir_all(&dl_dir).await.ok(); let out_template = match filename_user { Some(name) => { // Custom filename: sanitize, cap at 100 chars, let yt-dlp append ext. let clean: String = sanitize_filename(name).chars().take(100).collect(); format!("{dl_dir}/{clean}.%(ext)s") } None => format!("{dl_dir}/%(title).100s.%(ext)s"), }; let mut cmd = tokio::process::Command::new("yt-dlp"); // `--ignore-config` keeps a planted config file from injecting options; // `--` stops a user-supplied URL from being parsed as a flag. cmd.arg("--ignore-config") .arg("--no-playlist") .arg("-f") .arg("bestaudio/bestvideo+bestaudio") .arg("--merge-output-format") .arg("mp4") .arg("--max-filesize") .arg("512M") .arg("-o") .arg(&out_template); if is_audio { cmd.arg("--extract-audio").arg("--audio-format").arg(fmt); } cmd.arg("--").arg(sanitize_yt_url(url)); let output = match tokio::time::timeout(Duration::from_secs(600), cmd.output()).await { Ok(result) => result.map_err(|e| Error::Custom(lang::fmt_ytdlp_err(&e)))?, Err(_) => { let _ = tokio::fs::remove_dir_all(&dl_dir).await; ctx.say(lang::fmt_download_failed("download timed out after 10 minutes")) .await?; return Ok(()); } }; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); // Keep the full yt-dlp trace in the logs; the user only sees the // short `fmt_download_failed` (translated) message. warn!( url = %redact_url(url), "yt-dlp failed:\n{}", stderr.trim_end() ); let _ = tokio::fs::remove_dir_all(&dl_dir).await; ctx.say(lang::fmt_download_failed(&stderr)).await?; return Ok(()); } let Some((actual_path, basename)) = find_downloaded_file(&dl_dir).await else { let _ = tokio::fs::remove_dir_all(&dl_dir).await; ctx.say(lang::fmt_download_failed("no file produced")) .await?; return Ok(()); }; // Determine the display filename for the upload/attachment. let final_name = match filename_user { Some(name) => { let clean: String = sanitize_filename(name).chars().take(100).collect(); let ext = std::path::Path::new(&basename) .extension() .map(|e| format!(".{}", e.to_string_lossy())) .unwrap_or_default(); format!("{clean}{ext}") } None => basename, }; match tokio::fs::metadata(&actual_path).await { Ok(meta) if meta.len() > MAX_DOWNLOAD_BYTES => { let _ = tokio::fs::remove_dir_all(&dl_dir).await; ctx.say(lang::fmt_download_failed("file is larger than 512 MiB")) .await?; return Ok(()); } _ => {} } let bytes = match tokio::fs::read(&actual_path).await { Ok(b) => b, Err(e) => { let _ = tokio::fs::remove_dir_all(&dl_dir).await; ctx.say(lang::fmt_download_failed(&e.to_string())).await?; return Ok(()); } }; let deliver_result = deliver_download(ctx, bytes, final_name, catbox).await; let _ = tokio::fs::remove_dir_all(&dl_dir).await; deliver_result } /// Hand the produced bytes back to the user. /// /// Below the size threshold (and when upload isn't forced) the result is /// attached directly to the reply. Otherwise it's pushed to copyparty and the /// reply contains the download URL. Upload failures are reported gracefully /// rather than propagated, matching the Python original. async fn deliver( ctx: &Context<'_>, bytes: Vec, filename: String, force_upload: bool, kind: DeliverKind, ) -> Result<()> { if bytes.len() > LARGE_FILE_THRESHOLD || force_upload { match ctx.data().copyparty.upload(&filename, bytes).await { Ok(url) => { let msg = match kind { DeliverKind::Compress => lang::fmt_util_compressed(&url), DeliverKind::Convert => lang::fmt_util_converted(&url), }; ctx.say(msg).await?; } Err(e) => { tracing::warn!("copyparty upload failed: {e}"); ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; } } } else { ctx.send( poise::CreateReply::default() .attachment(serenity::CreateAttachment::bytes(bytes, filename)), ) .await?; } Ok(()) } /// Compress media to extreme low quality #[poise::command(slash_command, aliases("kompresja"))] pub async fn compress( ctx: Context<'_>, #[description = "File from Copyparty"] #[autocomplete = "any_file"] plik: Option, #[description = "File from Discord"] zalacznik: Option, #[description = "Force upload to Copyparty"] serwer: Option, ) -> Result<()> { ctx.defer().await?; tokio::fs::create_dir_all(crate::config::temp_path()) .await .ok(); let (input_bytes, original_filename) = match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? { Some(v) => v, None => return Ok(()), }; let safe_original = safe_basename(&original_filename); let ft = file_type(&safe_original); let stem = stem_of(&safe_original).to_string(); let out_ext = compress_output_ext(ft, &safe_original); let out_filename = format!("compressed_{stem}.{out_ext}"); let Some((dir, input_path, output_path)) = temp_op(&safe_original, &out_filename).await.ok() else { ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; return Ok(()); }; if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { tracing::warn!("compress: failed to write input: {e}"); cleanup_dir(&dir).await; ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; return Ok(()); } let result = match build_compress_command(ft, &input_path, &output_path) { Some(mut cmd) => cmd.output().await, None => { cleanup_dir(&dir).await; ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; return Ok(()); } }; match result { Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await { Ok(out_bytes) => { cleanup_dir(&dir).await; deliver( &ctx, out_bytes, out_filename, serwer.unwrap_or(false), DeliverKind::Compress, ) .await?; } Err(e) => { tracing::warn!("compress: output missing after encode: {e}"); cleanup_dir(&dir).await; ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; } }, _ => { cleanup_dir(&dir).await; ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; } } Ok(()) } /// Convert media between formats #[poise::command(slash_command, aliases("konwersja"))] pub async fn convert( ctx: Context<'_>, #[description = "File from Copyparty"] #[autocomplete = "any_file"] plik: Option, #[description = "File from Discord"] zalacznik: Option, #[description = "Target format extension (e.g. mp3, wav, avi, png)"] format: String, #[description = "Force upload to Copyparty"] serwer: Option, ) -> Result<()> { ctx.defer().await?; tokio::fs::create_dir_all(crate::config::temp_path()) .await .ok(); let (input_bytes, original_filename) = match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? { Some(v) => v, None => return Ok(()), }; // Normalise the requested extension: lowercase, no leading dot, and reject // anything containing path separators or extra dots — this keeps it a // single clean extension component and rules out escaping TEMP_PATH. let fmt = format.trim().trim_start_matches('.').to_ascii_lowercase(); if fmt.is_empty() || fmt.contains('/') || fmt.contains('\\') || fmt.contains('.') { ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; return Ok(()); } let safe_original = safe_basename(&original_filename); let ft = file_type(&safe_original); let stem = stem_of(&safe_original).to_string(); let out_filename = format!("{stem}.{fmt}"); let Some((dir, input_path, output_path)) = temp_op(&safe_original, &out_filename).await.ok() else { ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; return Ok(()); }; if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { tracing::warn!("convert: failed to write input: {e}"); cleanup_dir(&dir).await; ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; return Ok(()); } let mut cmd = match ft { FileType::Video | FileType::Audio => { let mut c = tokio::process::Command::new("ffmpeg"); c.arg("-y").arg("-i").arg(&input_path).arg(&output_path); c } FileType::Image => { let mut c = tokio::process::Command::new("magick"); c.arg(&input_path).arg(&output_path); c } FileType::Unknown => { cleanup_dir(&dir).await; ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; return Ok(()); } }; match cmd.output().await { Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await { Ok(out_bytes) => { cleanup_dir(&dir).await; deliver( &ctx, out_bytes, out_filename, serwer.unwrap_or(false), DeliverKind::Convert, ) .await?; } Err(e) => { tracing::warn!("convert: output missing after encode: {e}"); cleanup_dir(&dir).await; ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; } }, Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); cleanup_dir(&dir).await; ctx.say(lang::fmt_util_convert_err(&stderr)).await?; } Err(e) => { cleanup_dir(&dir).await; ctx.say(lang::fmt_util_convert_err(&e.to_string())).await?; } } Ok(()) } /// Upload files from Discord to Copyparty #[poise::command(slash_command)] pub async fn upload( ctx: Context<'_>, #[description = "File to upload"] zalacznik: serenity::Attachment, ) -> Result<()> { ctx.defer().await?; let data = ctx .data() .http_client .get(&zalacznik.url) .send() .await? .bytes() .await?; let file_url = ctx .data() .copyparty .upload(&zalacznik.filename, data.to_vec()) .await?; ctx.say(lang::fmt_uploaded(&file_url)).await?; Ok(()) } /// Search Copyparty file server and return a direct URL #[poise::command(slash_command)] pub async fn search( ctx: Context<'_>, #[description = "Partial filename to search"] #[autocomplete = "any_file"] filename: String, ) -> Result<()> { // Autocomplete cache: per-user (UserId, filename) → SearchHit. if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &filename).await { let name = html_decode(&percent_decode(hit.filename().unwrap_or(&filename))); let path = hit.url_path().unwrap_or(""); let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") }; let full_url = format!("{}{}", ctx.data().config.copyparty_url, path); ctx.say(lang::fmt_search_result(&name, &full_url)).await?; return Ok(()); } // Direct copyparty path from manual input (starts with /) if filename.starts_with('/') { let full_url = format!("{}{}", ctx.data().config.copyparty_url, filename); ctx.say(lang::fmt_search_result(&filename, &full_url)) .await?; return Ok(()); } let results = ctx.data().copyparty.search(&filename, 10, &[]).await?; if results.is_empty() { ctx.say(lang::L_SEARCH_NO_RESULTS).await?; return Ok(()); } let hit = &results[0]; let name = html_decode(&percent_decode(hit.filename().unwrap_or("unknown"))); let path = hit.url_path().unwrap_or(""); let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") }; let full_url = format!("{}{}", ctx.data().config.copyparty_url, path); ctx.say(lang::fmt_search_result(&name, &full_url)).await?; Ok(()) } /// Download media from any yt-dlp-supported URL #[poise::command(slash_command)] pub async fn download( ctx: Context<'_>, #[description = "Media URL"] url: String, #[description = "Force upload to Copyparty"] catbox: Option, #[description = "Output format (mp4, mp3, wav, etc.)"] fformat: Option, #[description = "Custom output filename"] filename_user: Option, ) -> Result<()> { ctx.defer().await?; tokio::fs::create_dir_all(crate::config::temp_path()) .await .ok(); run_download( &ctx, &url, catbox.unwrap_or(false), fformat.as_deref(), filename_user.as_deref(), ) .await } /// Right-click a message → upload its attachments to Copyparty /// (mirrors Python's `@commands.message_command(name="upload")`). /// /// Display name is not `"upload"` because poise's dispatcher matches commands /// by name alone without considering `CommandType`, so a context-menu named /// `"upload"` would shadow — and be shadowed by — the slash `/upload` command /// (see `poise::dispatch::find_matching_command`). `"Wypchnij załączniki"` /// avoids the collision while keeping the Polish user-facing label. #[poise::command(context_menu_command = "Wyślij załączniki")] pub async fn message_upload(ctx: Context<'_>, message: serenity::Message) -> Result<()> { if message.attachments.is_empty() { ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; return Ok(()); } ctx.defer().await?; let mut urls: Vec = Vec::new(); let mut failed = 0usize; for att in &message.attachments { let resp = match ctx.data().http_client.get(&att.url).send().await { Ok(r) if r.status().is_success() => r, _ => { failed += 1; continue; } }; let bytes = resp.bytes().await?.to_vec(); match ctx.data().copyparty.upload(&att.filename, bytes).await { Ok(url) => urls.push(url), Err(e) => { tracing::warn!("upload: copyparty failed for {}: {e}", att.filename); failed += 1; } } } if urls.is_empty() { ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; return Ok(()); } let mut resp = lang::fmt_upload_summary(urls.len()); for u in &urls { resp.push_str(u); resp.push('\n'); } if failed > 0 { resp.push_str(&lang::fmt_upload_partial_failed(failed)); } ctx.say(resp).await?; Ok(()) } /// Right-click a message → compress all of its attachments /// (mirrors Python's `@commands.message_command(name="kompresja")`). #[poise::command(context_menu_command = "kompresja")] pub async fn message_compression(ctx: Context<'_>, message: serenity::Message) -> Result<()> { if message.attachments.is_empty() { ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; return Ok(()); } ctx.defer().await?; tokio::fs::create_dir_all(crate::config::temp_path()) .await .ok(); let total = message.attachments.len(); let mut attachments_out: Vec = Vec::new(); let mut made = 0usize; for att in &message.attachments { let resp = match ctx.data().http_client.get(&att.url).send().await { Ok(r) if r.status().is_success() => r, _ => continue, }; let bytes = match resp.bytes().await { Ok(b) => b.to_vec(), Err(_) => continue, }; let ft = file_type(&att.filename); if let Some((out_bytes, out_name)) = compress_attachment_bytes(ft, &att.filename, &bytes).await { attachments_out.push(serenity::CreateAttachment::bytes(out_bytes, out_name)); made += 1; } } if attachments_out.is_empty() { ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; return Ok(()); } let mut reply = poise::CreateReply::default().content(lang::fmt_message_compressed(made, total)); for a in attachments_out { reply = reply.attachment(a); } ctx.send(reply).await?; Ok(()) } /// Right-click a message → download every `https://` URL it contains /// (mirrors Python's `@commands.message_command(name="download")`). /// /// Named `"Pobierz z linku"` rather than `"download"` to avoid colliding with /// the slash `/download` command in poise's name-only dispatcher (see the note /// on `message_upload`). #[poise::command(context_menu_command = "Pobierz z linku")] pub async fn message_download(ctx: Context<'_>, message: serenity::Message) -> Result<()> { let urls = extract_urls(&message.content); if urls.is_empty() { // Faithful to the Python original, which reuses the attachments-missing // string for the no-URL case. ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; return Ok(()); } ctx.defer().await?; tokio::fs::create_dir_all(crate::config::temp_path()) .await .ok(); for url in urls { if let Err(e) = run_download(&ctx, &url, false, Some("mp4"), None).await { tracing::warn!("download runner error for {url}: {e}"); } } Ok(()) } /// Echo text to channel #[poise::command(slash_command, required_permissions = "MANAGE_MESSAGES")] pub async fn say(ctx: Context<'_>, #[description = "Message to send"] text: String) -> Result<()> { // Acknowledge the interaction with an invisible ephemeral reply, delete it, // then post the actual text as a normal channel message (not a reply to the // command invocation). let ack = ctx .send( poise::CreateReply::default() .content("\u{200b}") .ephemeral(true), ) .await?; if let Err(e) = ack.delete(ctx).await { warn!("say: failed to delete ephemeral acknowledgement: {e}"); } ctx.channel_id() .say(ctx.serenity_context(), &text) .await?; Ok(()) } pub fn commands() -> Vec> { vec![ compress(), convert(), upload(), search(), download(), say(), message_upload(), message_compression(), message_download(), ] } #[cfg(test)] mod tests { use super::*; #[test] fn safe_basename_strips_directories() { assert_eq!(safe_basename("/music/foo.mp3"), "foo.mp3"); assert_eq!(safe_basename("../../etc/passwd"), "passwd"); assert_eq!( safe_basename(r"..\..\windows\system32\evil.exe"), "evil.exe" ); assert_eq!(safe_basename(r"C:\Users\x\song.mp3"), "song.mp3"); } #[test] fn safe_basename_never_returns_dot_entries() { assert_eq!(safe_basename(".."), "file"); assert_eq!(safe_basename("."), "file"); assert_eq!(safe_basename(""), "file"); } }