From cd8ed8bc5793985df2c1692072a932ceee517b95 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 8 Jul 2026 02:13:35 +0200 Subject: OPEN THE RUST GATES Signed-off-by: salmonik --- src/commands/utilities.rs | 918 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 918 insertions(+) create mode 100644 src/commands/utilities.rs (limited to 'src/commands/utilities.rs') diff --git a/src/commands/utilities.rs b/src/commands/utilities.rs new file mode 100644 index 0000000..10ce403 --- /dev/null +++ b/src/commands/utilities.rs @@ -0,0 +1,918 @@ +use poise::serenity_prelude as serenity; +use tracing::warn; + +use super::autocomplete::any_file; +use super::music::sanitize_yt_url; +use crate::Data; +use crate::copyparty::{html_decode, percent_decode}; +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; + +#[derive(Clone, Copy, PartialEq, Eq)] +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. +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. +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, + } +} + +fn temp_path_for(name: &str) -> String { + format!("{}/{}", crate::config::TEMP_PATH, name) +} + +/// Best-effort removal of temp files; ignores missing files. +async fn cleanup(paths: &[&str]) { + for p in paths { + let _ = tokio::fs::remove_file(p).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`. +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. +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, +/// including sequential `TEMP_PATH` reuse so identical attachment stems don't +/// collide (each iteration writes, encodes, then `cleanup`s before the next). +async fn compress_attachment_bytes( + ft: FileType, + original: &str, + input_bytes: &[u8], +) -> Option<(Vec, String)> { + let stem = stem_of(original).to_string(); + let out_ext = compress_output_ext(ft, original); + let out_filename = format!("compressed_{stem}.{out_ext}"); + let input_path = temp_path_for(original); + let output_path = temp_path_for(&out_filename); + let cleanup_paths = [input_path.as_str(), output_path.as_str()]; + + if tokio::fs::write(&input_path, input_bytes).await.is_err() { + return None; + } + let mut cmd = match build_compress_command(ft, &input_path, &output_path) { + Some(c) => c, + None => { + cleanup(&cleanup_paths).await; + return None; + } + }; + let res = match cmd.output().await { + Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(), + _ => None, + }; + cleanup(&cleanup_paths).await; + res.map(|b| (b, out_filename)) +} + +/// 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 `https://` URLs containing a path (host + `/...`) from a message. +/// Faithful port of Python's `Downloader.URL_PATTERN` regex used by the +/// message-context `download` command — only URLs with at least one `/` after +/// the host are matched, and `http://` links are intentionally ignored. +fn extract_urls(content: &str) -> Vec { + let bytes = content.as_bytes(); + let mut urls = Vec::new(); + let mut i = 0; + while let Some(pos) = content[i..].find("https://") { + let start = i + pos; + let after = start + "https://".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 (until whitespace). + let mut k = j; + while k < bytes.len() && !bytes[k].is_ascii_whitespace() { + k += 1; + } + urls.push(content[start..k].to_string()); + i = k; + } else { + // No path component — not matched by the regex; 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 { + if *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<()> { + 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"); + cmd.arg("-f") + .arg("bestaudio/bestvideo+bestaudio") + .arg("--merge-output-format") + .arg("mp4") + .arg("-o") + .arg(&out_template); + if is_audio { + cmd.arg("--extract-audio").arg("--audio-format").arg(fmt); + } + cmd.arg(sanitize_yt_url(url)); + + let output = cmd + .output() + .await + .map_err(|e| Error::Custom(lang::fmt_ytdlp_err(&e)))?; + 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 = %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, + }; + + 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 ft = file_type(&original_filename); + let stem = stem_of(&original_filename).to_string(); + let out_ext = compress_output_ext(ft, &original_filename); + let out_filename = format!("compressed_{stem}.{out_ext}"); + let input_path = temp_path_for(&original_filename); + let output_path = temp_path_for(&out_filename); + let cleanup_paths = [input_path.as_str(), output_path.as_str()]; + + if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { + tracing::warn!("compress: failed to write input: {e}"); + 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(&cleanup_paths).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(&cleanup_paths).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(&cleanup_paths).await; + ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; + } + }, + _ => { + cleanup(&cleanup_paths).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 ft = file_type(&original_filename); + let stem = stem_of(&original_filename).to_string(); + let out_filename = format!("{stem}.{fmt}"); + let input_path = temp_path_for(&original_filename); + let output_path = temp_path_for(&out_filename); + let cleanup_paths = [input_path.as_str(), output_path.as_str()]; + + if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { + tracing::warn!("convert: failed to write input: {e}"); + 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(&cleanup_paths).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(&cleanup_paths).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(&cleanup_paths).await; + ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; + } + }, + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + cleanup(&cleanup_paths).await; + ctx.say(lang::fmt_util_convert_err(&stderr)).await?; + } + Err(e) => { + cleanup(&cleanup_paths).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)] +pub async fn say(ctx: Context<'_>, #[description = "Message to send"] text: String) -> Result<()> { + ctx.say(&text).await?; + ctx.send( + poise::CreateReply::default() + .content("\u{200b}") + .ephemeral(true), + ) + .await?; + Ok(()) +} + +pub fn commands() -> Vec> { + vec![ + compress(), + convert(), + upload(), + search(), + download(), + say(), + message_upload(), + message_compression(), + message_download(), + ] +} -- cgit v1.2.3