aboutsummaryrefslogtreecommitdiffstats
path: root/src/commands/utilities.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/commands/utilities.rs918
1 files changed, 918 insertions, 0 deletions
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 @@
1use poise::serenity_prelude as serenity;
2use tracing::warn;
3
4use super::autocomplete::any_file;
5use super::music::sanitize_yt_url;
6use crate::Data;
7use crate::copyparty::{html_decode, percent_decode};
8use crate::error::{Error, Result};
9use crate::lang;
10
11type Context<'a> = poise::Context<'a, Data, Error>;
12
13// Above this size (or when `serwer` is forced) the result is uploaded to
14// copyparty instead of being attached to the Discord reply. Discord's own
15// attachment limit is 25 MiB for non-Nitro, but 10 MiB matches the Python
16// original and keeps replies snappy.
17const LARGE_FILE_THRESHOLD: usize = 10 * 1024 * 1024;
18
19#[derive(Clone, Copy, PartialEq, Eq)]
20enum FileType {
21 Video,
22 Audio,
23 Image,
24 Unknown,
25}
26
27/// Classify a file by its extension.
28///
29/// Mirrors Python's `mimetypes.guess_type`-based `filetype()` helper without
30/// pulling in a mime crate: the set of extensions below covers everything the
31/// original bot handled.
32fn file_type(filename: &str) -> FileType {
33 let Some((_, ext)) = filename.rsplit_once('.') else {
34 return FileType::Unknown;
35 };
36 if ext.is_empty() || ext.contains('/') {
37 return FileType::Unknown;
38 }
39 match ext.to_ascii_lowercase().as_str() {
40 "mp4" | "avi" | "mkv" | "mov" | "webm" | "flv" | "wmv" | "m4v" | "mpg" | "mpeg" | "3gp"
41 | "ts" => FileType::Video,
42 "mp3" | "wav" | "ogg" | "flac" | "m4a" | "opus" | "aac" | "wma" | "oga" => FileType::Audio,
43 "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff" | "tif" | "avif" => {
44 FileType::Image
45 }
46 _ => FileType::Unknown,
47 }
48}
49
50/// Lowercased extension without the leading dot, or `None` if there is none.
51fn ext_of(filename: &str) -> Option<String> {
52 let (_, ext) = filename.rsplit_once('.')?;
53 if ext.is_empty() || ext.contains('/') {
54 None
55 } else {
56 Some(ext.to_ascii_lowercase())
57 }
58}
59
60/// Filename stem: everything before the final dot of the basename.
61fn stem_of(filename: &str) -> &str {
62 let base = filename.rsplit('/').next().unwrap_or(filename);
63 match base.rsplit_once('.') {
64 Some((stem, _)) if !stem.is_empty() => stem,
65 _ => base,
66 }
67}
68
69fn temp_path_for(name: &str) -> String {
70 format!("{}/{}", crate::config::TEMP_PATH, name)
71}
72
73/// Best-effort removal of temp files; ignores missing files.
74async fn cleanup(paths: &[&str]) {
75 for p in paths {
76 let _ = tokio::fs::remove_file(p).await;
77 }
78}
79
80/// Resolve the input source — either an autocomplete-picked copyparty file or
81/// a Discord attachment — downloading the bytes and returning them alongside
82/// the original filename.
83///
84/// Replies with an appropriate user-facing message and returns `Ok(None)` when
85/// no valid source was supplied, so callers can early-return without sending a
86/// second message.
87async fn resolve_input(
88 ctx: &Context<'_>,
89 plik: Option<&str>,
90 zalacznik: Option<&serenity::Attachment>,
91) -> Result<Option<(Vec<u8>, String)>> {
92 if let Some(name) = plik {
93 // Autocomplete-selected copyparty file: the choice value is the decoded
94 // filename, stashed into the per-user ac_cache by `any_file`.
95 let hit = match ctx.data().ac_lookup(ctx.author().id, name).await {
96 Some(h) => h,
97 None => {
98 ctx.say(lang::L_UTIL_PICK_FROM_AUTOCOMPLETE).await?;
99 return Ok(None);
100 }
101 };
102 let path = hit.url_path().unwrap_or("");
103 let path = if path.starts_with('/') {
104 path.to_string()
105 } else {
106 format!("/{path}")
107 };
108 let full_url = ctx.data().copyparty.build_url(&path);
109 let resp = ctx.data().http_client.get(&full_url).send().await?;
110 if !resp.status().is_success() {
111 ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?;
112 return Ok(None);
113 }
114 let bytes = resp.bytes().await?.to_vec();
115 return Ok(Some((bytes, name.to_string())));
116 }
117
118 if let Some(att) = zalacznik {
119 let resp = ctx.data().http_client.get(&att.url).send().await?;
120 if !resp.status().is_success() {
121 ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?;
122 return Ok(None);
123 }
124 let bytes = resp.bytes().await?.to_vec();
125 return Ok(Some((bytes, att.filename.clone())));
126 }
127
128 ctx.say(lang::L_UTIL_NEED_INPUT).await?;
129 Ok(None)
130}
131
132/// Output extension the compressor produces for a given input.
133///
134/// - Video keeps its container extension (ffmpeg re-muxes in place).
135/// - Audio is always re-encoded to Opus in an `.ogg` container.
136/// - JPEG/GIF keep their extension; other images are rewritten to `.jpg`.
137fn compress_output_ext(ft: FileType, original: &str) -> String {
138 let orig = ext_of(original);
139 match ft {
140 FileType::Video => orig.unwrap_or_else(|| "mp4".to_string()),
141 FileType::Audio => "ogg".to_string(),
142 FileType::Image => match orig.as_deref() {
143 Some("jpg" | "jpeg" | "gif") => orig.unwrap(),
144 _ => "jpg".to_string(),
145 },
146 FileType::Unknown => String::new(),
147 }
148}
149
150/// Build the ffmpeg/magick command for extreme-low-quality compression.
151/// Returns `None` for unsupported (`Unknown`) file types.
152fn build_compress_command(
153 ft: FileType,
154 input: &str,
155 output: &str,
156) -> Option<tokio::process::Command> {
157 match ft {
158 FileType::Video => {
159 let mut c = tokio::process::Command::new("ffmpeg");
160 c.arg("-y")
161 .arg("-i")
162 .arg(input)
163 .arg("-c:v")
164 .arg("libx264")
165 .arg("-crf")
166 .arg("51")
167 .arg("-b:v")
168 .arg("100")
169 .arg("-vf")
170 .arg("fps=10,scale=trunc(iw*0.5/2)*2:trunc(ih*0.5/2)*2")
171 .arg("-preset")
172 .arg("ultrafast")
173 .arg("-c:a")
174 .arg("libopus")
175 .arg("-b:a")
176 .arg("500")
177 .arg(output);
178 Some(c)
179 }
180 FileType::Audio => {
181 let mut c = tokio::process::Command::new("ffmpeg");
182 c.arg("-y")
183 .arg("-i")
184 .arg(input)
185 .arg("-c:a")
186 .arg("libopus")
187 .arg("-b:a")
188 .arg("500")
189 .arg("-ar")
190 .arg("12000")
191 .arg("-ac")
192 .arg("1")
193 .arg(output);
194 Some(c)
195 }
196 FileType::Image => {
197 let mut c = tokio::process::Command::new("magick");
198 c.arg(input);
199 match ext_of(input).as_deref() {
200 Some("gif") => {
201 c.arg("-coalesce")
202 .arg("-resize")
203 .arg("30%")
204 .arg("-colors")
205 .arg("32")
206 .arg("-strip")
207 .arg("-layers")
208 .arg("Optimize");
209 }
210 // jpg/jpeg stay jpg; other images are rewritten to .jpg by
211 // `compress_output_ext` and magick infers the encoder from the
212 // output extension.
213 _ => {
214 c.arg("-strip")
215 .arg("-resize")
216 .arg("30%")
217 .arg("-quality")
218 .arg("5");
219 }
220 }
221 c.arg(output);
222 Some(c)
223 }
224 FileType::Unknown => None,
225 }
226}
227
228enum DeliverKind {
229 Compress,
230 Convert,
231}
232
233/// Compress a single in-memory blob (an attachment body) into a compressed
234/// file, returning the encoded bytes and the output filename.
235///
236/// Mirrors the per-attachment path of Python's `Compression` message command,
237/// including sequential `TEMP_PATH` reuse so identical attachment stems don't
238/// collide (each iteration writes, encodes, then `cleanup`s before the next).
239async fn compress_attachment_bytes(
240 ft: FileType,
241 original: &str,
242 input_bytes: &[u8],
243) -> Option<(Vec<u8>, String)> {
244 let stem = stem_of(original).to_string();
245 let out_ext = compress_output_ext(ft, original);
246 let out_filename = format!("compressed_{stem}.{out_ext}");
247 let input_path = temp_path_for(original);
248 let output_path = temp_path_for(&out_filename);
249 let cleanup_paths = [input_path.as_str(), output_path.as_str()];
250
251 if tokio::fs::write(&input_path, input_bytes).await.is_err() {
252 return None;
253 }
254 let mut cmd = match build_compress_command(ft, &input_path, &output_path) {
255 Some(c) => c,
256 None => {
257 cleanup(&cleanup_paths).await;
258 return None;
259 }
260 };
261 let res = match cmd.output().await {
262 Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(),
263 _ => None,
264 };
265 cleanup(&cleanup_paths).await;
266 res.map(|b| (b, out_filename))
267}
268
269/// Strip characters that Discord/coping filenames can't safely carry. Mirrors
270/// Python's `Downloader._sanitize_filename`.
271fn sanitize_filename(name: &str) -> String {
272 const BAD: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
273 name.chars()
274 .filter(|c| !BAD.contains(c))
275 .collect::<String>()
276 .trim()
277 .to_string()
278}
279
280/// Extract `https://` URLs containing a path (host + `/...`) from a message.
281/// Faithful port of Python's `Downloader.URL_PATTERN` regex used by the
282/// message-context `download` command — only URLs with at least one `/` after
283/// the host are matched, and `http://` links are intentionally ignored.
284fn extract_urls(content: &str) -> Vec<String> {
285 let bytes = content.as_bytes();
286 let mut urls = Vec::new();
287 let mut i = 0;
288 while let Some(pos) = content[i..].find("https://") {
289 let start = i + pos;
290 let after = start + "https://".len();
291
292 // Consume the host (until `/` or whitespace).
293 let mut j = after;
294 while j < bytes.len() && !bytes[j].is_ascii_whitespace() && bytes[j] != b'/' {
295 j += 1;
296 }
297
298 if j < bytes.len() && bytes[j] == b'/' {
299 // Consume the rest of the URL (until whitespace).
300 let mut k = j;
301 while k < bytes.len() && !bytes[k].is_ascii_whitespace() {
302 k += 1;
303 }
304 urls.push(content[start..k].to_string());
305 i = k;
306 } else {
307 // No path component — not matched by the regex; advance past it.
308 i = after;
309 }
310 }
311 urls
312}
313
314/// Locate the single file yt-dlp produced in `dir`, picking the most recently
315/// modified one (matches Python's `os.listdir`-first fallback behaviour while
316/// being deterministic enough for the per-call unique subdir).
317async fn find_downloaded_file(dir: &str) -> Option<(String, String)> {
318 let mut rd = tokio::fs::read_dir(dir).await.ok()?;
319 let mut newest: Option<(std::time::SystemTime, String)> = None;
320 while let Ok(Some(entry)) = rd.next_entry().await {
321 let meta = match entry.metadata().await {
322 Ok(m) => m,
323 Err(_) => continue,
324 };
325 if !meta.is_file() {
326 continue;
327 }
328 let mtime = match meta.modified() {
329 Ok(t) => t,
330 Err(_) => continue,
331 };
332 if let Some((t, _)) = &newest {
333 if *t >= mtime {
334 continue;
335 }
336 }
337 newest = Some((mtime, entry.path().to_string_lossy().into_owned()));
338 }
339 newest.map(|(_, p)| {
340 let fname = std::path::Path::new(&p)
341 .file_name()
342 .map(|s| s.to_string_lossy().into_owned())
343 .unwrap_or_default();
344 (p, fname)
345 })
346}
347
348/// Hand the produced download back to the user: attach when below the size
349/// threshold (and upload isn't forced), otherwise push to copyparty and reply
350/// with the download URL. Mirrors Python's `Downloader` slash `/download`.
351async fn deliver_download(
352 ctx: &Context<'_>,
353 bytes: Vec<u8>,
354 filename: String,
355 catbox: bool,
356) -> Result<()> {
357 if bytes.len() > LARGE_FILE_THRESHOLD || catbox {
358 match ctx.data().copyparty.upload(&filename, bytes).await {
359 Ok(url) => {
360 ctx.say(lang::fmt_download_uploaded(&url)).await?;
361 }
362 Err(e) => {
363 tracing::warn!("copyparty upload failed: {e}");
364 ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?;
365 }
366 }
367 } else {
368 ctx.send(
369 poise::CreateReply::default()
370 .attachment(serenity::CreateAttachment::bytes(bytes, filename)),
371 )
372 .await?;
373 }
374 Ok(())
375}
376
377/// Run yt-dlp on `url` and deliver the resulting file. The caller is expected
378/// to have already called `ctx.defer()`. Each invocation uses a fresh unique
379/// subdirectory under `TEMP_PATH` (matching Python's per-call
380/// `tempfile.TemporaryDirectory`) so concurrent downloads never collide.
381async fn run_download(
382 ctx: &Context<'_>,
383 url: &str,
384 catbox: bool,
385 fformat: Option<&str>,
386 filename_user: Option<&str>,
387) -> Result<()> {
388 let fmt = fformat.unwrap_or("mp4");
389 let is_audio = matches!(fmt, "mp3" | "wav" | "m4a");
390 let dl_dir = format!("{}/dl_{}", crate::config::TEMP_PATH, uuid::Uuid::new_v4());
391 tokio::fs::create_dir_all(&dl_dir).await.ok();
392
393 let out_template = match filename_user {
394 Some(name) => {
395 // Custom filename: sanitize, cap at 100 chars, let yt-dlp append ext.
396 let clean: String = sanitize_filename(name).chars().take(100).collect();
397 format!("{dl_dir}/{clean}.%(ext)s")
398 }
399 None => format!("{dl_dir}/%(title).100s.%(ext)s"),
400 };
401
402 let mut cmd = tokio::process::Command::new("yt-dlp");
403 cmd.arg("-f")
404 .arg("bestaudio/bestvideo+bestaudio")
405 .arg("--merge-output-format")
406 .arg("mp4")
407 .arg("-o")
408 .arg(&out_template);
409 if is_audio {
410 cmd.arg("--extract-audio").arg("--audio-format").arg(fmt);
411 }
412 cmd.arg(sanitize_yt_url(url));
413
414 let output = cmd
415 .output()
416 .await
417 .map_err(|e| Error::Custom(lang::fmt_ytdlp_err(&e)))?;
418 if !output.status.success() {
419 let stderr = String::from_utf8_lossy(&output.stderr);
420 // Keep the full yt-dlp trace in the logs; the user only sees the
421 // short `fmt_download_failed` (translated) message.
422 warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end());
423 let _ = tokio::fs::remove_dir_all(&dl_dir).await;
424 ctx.say(lang::fmt_download_failed(&stderr)).await?;
425 return Ok(());
426 }
427
428 let Some((actual_path, basename)) = find_downloaded_file(&dl_dir).await else {
429 let _ = tokio::fs::remove_dir_all(&dl_dir).await;
430 ctx.say(lang::fmt_download_failed("no file produced"))
431 .await?;
432 return Ok(());
433 };
434
435 // Determine the display filename for the upload/attachment.
436 let final_name = match filename_user {
437 Some(name) => {
438 let clean: String = sanitize_filename(name).chars().take(100).collect();
439 let ext = std::path::Path::new(&basename)
440 .extension()
441 .map(|e| format!(".{}", e.to_string_lossy()))
442 .unwrap_or_default();
443 format!("{clean}{ext}")
444 }
445 None => basename,
446 };
447
448 let bytes = match tokio::fs::read(&actual_path).await {
449 Ok(b) => b,
450 Err(e) => {
451 let _ = tokio::fs::remove_dir_all(&dl_dir).await;
452 ctx.say(lang::fmt_download_failed(&e.to_string())).await?;
453 return Ok(());
454 }
455 };
456
457 let deliver_result = deliver_download(ctx, bytes, final_name, catbox).await;
458 let _ = tokio::fs::remove_dir_all(&dl_dir).await;
459 deliver_result
460}
461
462/// Hand the produced bytes back to the user.
463///
464/// Below the size threshold (and when upload isn't forced) the result is
465/// attached directly to the reply. Otherwise it's pushed to copyparty and the
466/// reply contains the download URL. Upload failures are reported gracefully
467/// rather than propagated, matching the Python original.
468async fn deliver(
469 ctx: &Context<'_>,
470 bytes: Vec<u8>,
471 filename: String,
472 force_upload: bool,
473 kind: DeliverKind,
474) -> Result<()> {
475 if bytes.len() > LARGE_FILE_THRESHOLD || force_upload {
476 match ctx.data().copyparty.upload(&filename, bytes).await {
477 Ok(url) => {
478 let msg = match kind {
479 DeliverKind::Compress => lang::fmt_util_compressed(&url),
480 DeliverKind::Convert => lang::fmt_util_converted(&url),
481 };
482 ctx.say(msg).await?;
483 }
484 Err(e) => {
485 tracing::warn!("copyparty upload failed: {e}");
486 ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?;
487 }
488 }
489 } else {
490 ctx.send(
491 poise::CreateReply::default()
492 .attachment(serenity::CreateAttachment::bytes(bytes, filename)),
493 )
494 .await?;
495 }
496 Ok(())
497}
498
499/// Compress media to extreme low quality
500#[poise::command(slash_command, aliases("kompresja"))]
501pub async fn compress(
502 ctx: Context<'_>,
503 #[description = "File from Copyparty"]
504 #[autocomplete = "any_file"]
505 plik: Option<String>,
506 #[description = "File from Discord"] zalacznik: Option<serenity::Attachment>,
507 #[description = "Force upload to Copyparty"] serwer: Option<bool>,
508) -> Result<()> {
509 ctx.defer().await?;
510 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
511 .await
512 .ok();
513
514 let (input_bytes, original_filename) =
515 match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? {
516 Some(v) => v,
517 None => return Ok(()),
518 };
519
520 let ft = file_type(&original_filename);
521 let stem = stem_of(&original_filename).to_string();
522 let out_ext = compress_output_ext(ft, &original_filename);
523 let out_filename = format!("compressed_{stem}.{out_ext}");
524 let input_path = temp_path_for(&original_filename);
525 let output_path = temp_path_for(&out_filename);
526 let cleanup_paths = [input_path.as_str(), output_path.as_str()];
527
528 if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await {
529 tracing::warn!("compress: failed to write input: {e}");
530 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
531 return Ok(());
532 }
533
534 let result = match build_compress_command(ft, &input_path, &output_path) {
535 Some(mut cmd) => cmd.output().await,
536 None => {
537 cleanup(&cleanup_paths).await;
538 ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?;
539 return Ok(());
540 }
541 };
542
543 match result {
544 Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await {
545 Ok(out_bytes) => {
546 cleanup(&cleanup_paths).await;
547 deliver(
548 &ctx,
549 out_bytes,
550 out_filename,
551 serwer.unwrap_or(false),
552 DeliverKind::Compress,
553 )
554 .await?;
555 }
556 Err(e) => {
557 tracing::warn!("compress: output missing after encode: {e}");
558 cleanup(&cleanup_paths).await;
559 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
560 }
561 },
562 _ => {
563 cleanup(&cleanup_paths).await;
564 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
565 }
566 }
567 Ok(())
568}
569
570/// Convert media between formats
571#[poise::command(slash_command, aliases("konwersja"))]
572pub async fn convert(
573 ctx: Context<'_>,
574 #[description = "File from Copyparty"]
575 #[autocomplete = "any_file"]
576 plik: Option<String>,
577 #[description = "File from Discord"] zalacznik: Option<serenity::Attachment>,
578 #[description = "Target format extension (e.g. mp3, wav, avi, png)"] format: String,
579 #[description = "Force upload to Copyparty"] serwer: Option<bool>,
580) -> Result<()> {
581 ctx.defer().await?;
582 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
583 .await
584 .ok();
585
586 let (input_bytes, original_filename) =
587 match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? {
588 Some(v) => v,
589 None => return Ok(()),
590 };
591
592 // Normalise the requested extension: lowercase, no leading dot, and reject
593 // anything containing path separators or extra dots — this keeps it a
594 // single clean extension component and rules out escaping TEMP_PATH.
595 let fmt = format.trim().trim_start_matches('.').to_ascii_lowercase();
596 if fmt.is_empty() || fmt.contains('/') || fmt.contains('\\') || fmt.contains('.') {
597 ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?;
598 return Ok(());
599 }
600
601 let ft = file_type(&original_filename);
602 let stem = stem_of(&original_filename).to_string();
603 let out_filename = format!("{stem}.{fmt}");
604 let input_path = temp_path_for(&original_filename);
605 let output_path = temp_path_for(&out_filename);
606 let cleanup_paths = [input_path.as_str(), output_path.as_str()];
607
608 if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await {
609 tracing::warn!("convert: failed to write input: {e}");
610 ctx.say(lang::L_UTIL_CONVERT_FAILED).await?;
611 return Ok(());
612 }
613
614 let mut cmd = match ft {
615 FileType::Video | FileType::Audio => {
616 let mut c = tokio::process::Command::new("ffmpeg");
617 c.arg("-y").arg("-i").arg(&input_path).arg(&output_path);
618 c
619 }
620 FileType::Image => {
621 let mut c = tokio::process::Command::new("magick");
622 c.arg(&input_path).arg(&output_path);
623 c
624 }
625 FileType::Unknown => {
626 cleanup(&cleanup_paths).await;
627 ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?;
628 return Ok(());
629 }
630 };
631
632 match cmd.output().await {
633 Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await {
634 Ok(out_bytes) => {
635 cleanup(&cleanup_paths).await;
636 deliver(
637 &ctx,
638 out_bytes,
639 out_filename,
640 serwer.unwrap_or(false),
641 DeliverKind::Convert,
642 )
643 .await?;
644 }
645 Err(e) => {
646 tracing::warn!("convert: output missing after encode: {e}");
647 cleanup(&cleanup_paths).await;
648 ctx.say(lang::L_UTIL_CONVERT_FAILED).await?;
649 }
650 },
651 Ok(out) => {
652 let stderr = String::from_utf8_lossy(&out.stderr);
653 cleanup(&cleanup_paths).await;
654 ctx.say(lang::fmt_util_convert_err(&stderr)).await?;
655 }
656 Err(e) => {
657 cleanup(&cleanup_paths).await;
658 ctx.say(lang::fmt_util_convert_err(&e.to_string())).await?;
659 }
660 }
661 Ok(())
662}
663
664/// Upload files from Discord to Copyparty
665#[poise::command(slash_command)]
666pub async fn upload(
667 ctx: Context<'_>,
668 #[description = "File to upload"] zalacznik: serenity::Attachment,
669) -> Result<()> {
670 ctx.defer().await?;
671
672 let data = ctx
673 .data()
674 .http_client
675 .get(&zalacznik.url)
676 .send()
677 .await?
678 .bytes()
679 .await?;
680 let file_url = ctx
681 .data()
682 .copyparty
683 .upload(&zalacznik.filename, data.to_vec())
684 .await?;
685 ctx.say(lang::fmt_uploaded(&file_url)).await?;
686 Ok(())
687}
688
689/// Search Copyparty file server and return a direct URL
690#[poise::command(slash_command)]
691pub async fn search(
692 ctx: Context<'_>,
693 #[description = "Partial filename to search"]
694 #[autocomplete = "any_file"]
695 filename: String,
696) -> Result<()> {
697 // Autocomplete cache: per-user (UserId, filename) → SearchHit.
698 if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &filename).await {
699 let name = html_decode(&percent_decode(hit.filename().unwrap_or(&filename)));
700 let path = hit.url_path().unwrap_or("");
701 let path = if path.starts_with('/') {
702 path.to_string()
703 } else {
704 format!("/{path}")
705 };
706 let full_url = format!("{}{}", ctx.data().config.copyparty_url, path);
707 ctx.say(lang::fmt_search_result(&name, &full_url)).await?;
708 return Ok(());
709 }
710
711 // Direct copyparty path from manual input (starts with /)
712 if filename.starts_with('/') {
713 let full_url = format!("{}{}", ctx.data().config.copyparty_url, filename);
714 ctx.say(lang::fmt_search_result(&filename, &full_url))
715 .await?;
716 return Ok(());
717 }
718
719 let results = ctx.data().copyparty.search(&filename, 10, &[]).await?;
720
721 if results.is_empty() {
722 ctx.say(lang::L_SEARCH_NO_RESULTS).await?;
723 return Ok(());
724 }
725
726 let hit = &results[0];
727 let name = html_decode(&percent_decode(hit.filename().unwrap_or("unknown")));
728 let path = hit.url_path().unwrap_or("");
729 let path = if path.starts_with('/') {
730 path.to_string()
731 } else {
732 format!("/{path}")
733 };
734 let full_url = format!("{}{}", ctx.data().config.copyparty_url, path);
735
736 ctx.say(lang::fmt_search_result(&name, &full_url)).await?;
737 Ok(())
738}
739
740/// Download media from any yt-dlp-supported URL
741#[poise::command(slash_command)]
742pub async fn download(
743 ctx: Context<'_>,
744 #[description = "Media URL"] url: String,
745 #[description = "Force upload to Copyparty"] catbox: Option<bool>,
746 #[description = "Output format (mp4, mp3, wav, etc.)"] fformat: Option<String>,
747 #[description = "Custom output filename"] filename_user: Option<String>,
748) -> Result<()> {
749 ctx.defer().await?;
750 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
751 .await
752 .ok();
753 run_download(
754 &ctx,
755 &url,
756 catbox.unwrap_or(false),
757 fformat.as_deref(),
758 filename_user.as_deref(),
759 )
760 .await
761}
762
763/// Right-click a message → upload its attachments to Copyparty
764/// (mirrors Python's `@commands.message_command(name="upload")`).
765///
766/// Display name is not `"upload"` because poise's dispatcher matches commands
767/// by name alone without considering `CommandType`, so a context-menu named
768/// `"upload"` would shadow — and be shadowed by — the slash `/upload` command
769/// (see `poise::dispatch::find_matching_command`). `"Wypchnij załączniki"`
770/// avoids the collision while keeping the Polish user-facing label.
771#[poise::command(context_menu_command = "Wyślij załączniki")]
772pub async fn message_upload(ctx: Context<'_>, message: serenity::Message) -> Result<()> {
773 if message.attachments.is_empty() {
774 ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?;
775 return Ok(());
776 }
777 ctx.defer().await?;
778
779 let mut urls: Vec<String> = Vec::new();
780 let mut failed = 0usize;
781 for att in &message.attachments {
782 let resp = match ctx.data().http_client.get(&att.url).send().await {
783 Ok(r) if r.status().is_success() => r,
784 _ => {
785 failed += 1;
786 continue;
787 }
788 };
789 let bytes = resp.bytes().await?.to_vec();
790 match ctx.data().copyparty.upload(&att.filename, bytes).await {
791 Ok(url) => urls.push(url),
792 Err(e) => {
793 tracing::warn!("upload: copyparty failed for {}: {e}", att.filename);
794 failed += 1;
795 }
796 }
797 }
798
799 if urls.is_empty() {
800 ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?;
801 return Ok(());
802 }
803
804 let mut resp = lang::fmt_upload_summary(urls.len());
805 for u in &urls {
806 resp.push_str(u);
807 resp.push('\n');
808 }
809 if failed > 0 {
810 resp.push_str(&lang::fmt_upload_partial_failed(failed));
811 }
812 ctx.say(resp).await?;
813 Ok(())
814}
815
816/// Right-click a message → compress all of its attachments
817/// (mirrors Python's `@commands.message_command(name="kompresja")`).
818#[poise::command(context_menu_command = "kompresja")]
819pub async fn message_compression(ctx: Context<'_>, message: serenity::Message) -> Result<()> {
820 if message.attachments.is_empty() {
821 ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?;
822 return Ok(());
823 }
824 ctx.defer().await?;
825 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
826 .await
827 .ok();
828
829 let total = message.attachments.len();
830 let mut attachments_out: Vec<serenity::CreateAttachment> = Vec::new();
831 let mut made = 0usize;
832
833 for att in &message.attachments {
834 let resp = match ctx.data().http_client.get(&att.url).send().await {
835 Ok(r) if r.status().is_success() => r,
836 _ => continue,
837 };
838 let bytes = match resp.bytes().await {
839 Ok(b) => b.to_vec(),
840 Err(_) => continue,
841 };
842 let ft = file_type(&att.filename);
843 if let Some((out_bytes, out_name)) =
844 compress_attachment_bytes(ft, &att.filename, &bytes).await
845 {
846 attachments_out.push(serenity::CreateAttachment::bytes(out_bytes, out_name));
847 made += 1;
848 }
849 }
850
851 if attachments_out.is_empty() {
852 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
853 return Ok(());
854 }
855
856 let mut reply =
857 poise::CreateReply::default().content(lang::fmt_message_compressed(made, total));
858 for a in attachments_out {
859 reply = reply.attachment(a);
860 }
861 ctx.send(reply).await?;
862 Ok(())
863}
864
865/// Right-click a message → download every `https://` URL it contains
866/// (mirrors Python's `@commands.message_command(name="download")`).
867///
868/// Named `"Pobierz z linku"` rather than `"download"` to avoid colliding with
869/// the slash `/download` command in poise's name-only dispatcher (see the note
870/// on `message_upload`).
871#[poise::command(context_menu_command = "Pobierz z linku")]
872pub async fn message_download(ctx: Context<'_>, message: serenity::Message) -> Result<()> {
873 let urls = extract_urls(&message.content);
874 if urls.is_empty() {
875 // Faithful to the Python original, which reuses the attachments-missing
876 // string for the no-URL case.
877 ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?;
878 return Ok(());
879 }
880 ctx.defer().await?;
881 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
882 .await
883 .ok();
884
885 for url in urls {
886 if let Err(e) = run_download(&ctx, &url, false, Some("mp4"), None).await {
887 tracing::warn!("download runner error for {url}: {e}");
888 }
889 }
890 Ok(())
891}
892
893/// Echo text to channel
894#[poise::command(slash_command)]
895pub async fn say(ctx: Context<'_>, #[description = "Message to send"] text: String) -> Result<()> {
896 ctx.say(&text).await?;
897 ctx.send(
898 poise::CreateReply::default()
899 .content("\u{200b}")
900 .ephemeral(true),
901 )
902 .await?;
903 Ok(())
904}
905
906pub fn commands() -> Vec<poise::Command<Data, Error>> {
907 vec![
908 compress(),
909 convert(),
910 upload(),
911 search(),
912 download(),
913 say(),
914 message_upload(),
915 message_compression(),
916 message_download(),
917 ]
918}