aboutsummaryrefslogtreecommitdiffstats
path: root/src/commands
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/commands/ai.rs76
-rw-r--r--src/commands/autocomplete.rs128
-rw-r--r--src/commands/cron.rs155
-rw-r--r--src/commands/math.rs77
-rw-r--r--src/commands/mod.rs26
-rw-r--r--src/commands/music.rs414
-rw-r--r--src/commands/qr.rs87
-rw-r--r--src/commands/trigger.rs199
-rw-r--r--src/commands/tts.rs106
-rw-r--r--src/commands/utilities.rs918
-rw-r--r--src/commands/web.rs122
11 files changed, 2308 insertions, 0 deletions
diff --git a/src/commands/ai.rs b/src/commands/ai.rs
new file mode 100644
index 0000000..a75f164
--- /dev/null
+++ b/src/commands/ai.rs
@@ -0,0 +1,76 @@
1use crate::Data;
2use crate::error::Error;
3use crate::lang;
4
5type Context<'a> = poise::Context<'a, Data, Error>;
6
7/// Query the AI senator via OpenRouter
8#[poise::command(slash_command)]
9pub async fn senator(
10 ctx: Context<'_>,
11 #[description = "Wiadomość do wysłania"] text: String,
12 #[description = "Przemówić w kanale głosowym?"] tts_vc: Option<bool>,
13) -> crate::error::Result<()> {
14 ctx.defer().await?;
15
16 let api_key = ctx
17 .data()
18 .config
19 .openrouter_api_key
20 .as_ref()
21 .ok_or_else(|| Error::Custom(lang::L_OPENROUTER_NO_KEY.into()))?;
22 let model = ctx
23 .data()
24 .config
25 .openrouter_model
26 .as_deref()
27 .unwrap_or("cognitivecomputations/dolphin-mistral-24b-venice-edition:free");
28
29 let client = crate::openrouter::OpenRouterClient::new(api_key.clone(), model.to_string());
30 let response = client.chat(lang::SENATOR_SYSTEM_PROMPT, &text).await?;
31
32 if tts_vc.unwrap_or(false) {
33 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
34 let channel_id = ctx
35 .serenity_context()
36 .cache
37 .guild(guild_id)
38 .and_then(|g| {
39 g.voice_states
40 .get(&ctx.author().id)
41 .and_then(|vs| vs.channel_id)
42 })
43 .ok_or_else(|| lang::err_not_in_voice())?;
44
45 let tts_url = format!(
46 "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob",
47 crate::commands::tts::urlencoding(&response)
48 );
49 let audio_data = ctx
50 .data()
51 .http_client
52 .get(&tts_url)
53 .send()
54 .await?
55 .bytes()
56 .await?;
57
58 let call = ctx
59 .data()
60 .voice_manager
61 .connect(guild_id, channel_id)
62 .await?;
63 let input = crate::voice::decode_audio(&audio_data)?;
64 let mut call_lock = call.lock().await;
65 if call_lock.current_channel().is_some() {
66 call_lock.play_input(input);
67 }
68 }
69
70 ctx.say(response).await?;
71 Ok(())
72}
73
74pub fn commands() -> Vec<poise::Command<Data, Error>> {
75 vec![senator()]
76}
diff --git a/src/commands/autocomplete.rs b/src/commands/autocomplete.rs
new file mode 100644
index 0000000..497aec9
--- /dev/null
+++ b/src/commands/autocomplete.rs
@@ -0,0 +1,128 @@
1use poise::serenity_prelude as serenity;
2
3use crate::Data;
4use crate::copyparty::{html_decode, percent_decode};
5use crate::error::Error;
6
7type Context<'a> = poise::Context<'a, Data, Error>;
8
9const MAX_CHOICE_LEN: usize = 99;
10
11fn filename_label(raw_path: &str) -> Option<String> {
12 let raw_name = raw_path.rsplit('/').next()?;
13 if raw_name.is_empty() {
14 return None;
15 }
16 let decoded = html_decode(&percent_decode(raw_name));
17 if decoded.len() > MAX_CHOICE_LEN {
18 let mut s: String = decoded.chars().take(MAX_CHOICE_LEN - 1).collect();
19 s.push('\u{2026}');
20 Some(s)
21 } else {
22 Some(decoded)
23 }
24}
25
26pub async fn audio_file<'a>(
27 ctx: Context<'_>,
28 partial: &'a str,
29) -> Vec<serenity::AutocompleteChoice> {
30 eprintln!("[DEBUG] autocomplete audio_file(partial={partial:?})");
31 let results = ctx
32 .data()
33 .copyparty
34 .search(partial, 25, &["mp3", "wav", "ogg", "flac", "m4a", "opus"])
35 .await;
36 match results {
37 Ok(hits) => {
38 eprintln!("[DEBUG] audio_file got {} raw hits", hits.len());
39 let mut choices = Vec::new();
40 for (i, hit) in hits.into_iter().enumerate() {
41 let rp = match &hit.rp {
42 Some(p) => p.clone(),
43 None => continue,
44 };
45 eprintln!(
46 "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}",
47 hit.url, hit.name, hit.path
48 );
49 let Some(label) = filename_label(&rp) else {
50 continue;
51 };
52 ctx.data()
53 .ac_stash(ctx.author().id, label.clone(), hit)
54 .await;
55 eprintln!("[DEBUG] -> cached as {label:?}");
56 choices.push(serenity::AutocompleteChoice::new(label.clone(), rp));
57 if choices.len() >= 25 {
58 break;
59 }
60 }
61 eprintln!("[DEBUG] audio_file returning {} choices", choices.len());
62 choices
63 }
64 Err(e) => {
65 eprintln!("[DEBUG] audio_file search failed: {e}");
66 vec![]
67 }
68 }
69}
70
71pub async fn any_file<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<serenity::AutocompleteChoice> {
72 eprintln!("[DEBUG] autocomplete any_file(partial={partial:?})");
73 let results = ctx.data().copyparty.search(partial, 25, &[]).await;
74 match results {
75 Ok(hits) => {
76 eprintln!("[DEBUG] any_file got {} raw hits", hits.len());
77 let mut choices = Vec::new();
78 for (i, hit) in hits.into_iter().enumerate() {
79 let rp = match &hit.rp {
80 Some(p) => p.clone(),
81 None => continue,
82 };
83 eprintln!(
84 "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}",
85 hit.url, hit.name, hit.path
86 );
87 let Some(label) = filename_label(&rp) else {
88 continue;
89 };
90 ctx.data()
91 .ac_stash(ctx.author().id, label.clone(), hit)
92 .await;
93 eprintln!("[DEBUG] -> cached as {label:?}");
94 choices.push(serenity::AutocompleteChoice::new(label.clone(), label));
95 if choices.len() >= 25 {
96 break;
97 }
98 }
99 eprintln!("[DEBUG] any_file returning {} choices", choices.len());
100 choices
101 }
102 Err(e) => {
103 eprintln!("[DEBUG] any_file search failed: {e}");
104 vec![]
105 }
106 }
107}
108
109pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<String> {
110 eprintln!("[DEBUG] autocomplete trigger_name(partial={partial:?})");
111 let db_pool = &ctx.data().db_pool;
112 match crate::database::list_triggers(db_pool).await {
113 Ok(triggers) => {
114 let names: Vec<_> = triggers
115 .into_iter()
116 .map(|t| t.name)
117 .filter(|name| partial.is_empty() || name.starts_with(partial))
118 .take(25)
119 .collect();
120 eprintln!("[DEBUG] trigger_name returning {} names", names.len());
121 names
122 }
123 Err(e) => {
124 eprintln!("[DEBUG] trigger_name query failed: {e}");
125 vec![]
126 }
127 }
128}
diff --git a/src/commands/cron.rs b/src/commands/cron.rs
new file mode 100644
index 0000000..48f6ff1
--- /dev/null
+++ b/src/commands/cron.rs
@@ -0,0 +1,155 @@
1use poise::serenity_prelude as serenity;
2
3use super::autocomplete::audio_file;
4use crate::Data;
5use crate::database;
6use crate::error::{Error, Result};
7use crate::lang;
8
9type Context<'a> = poise::Context<'a, Data, Error>;
10
11/// Cron job management
12#[poise::command(
13 slash_command,
14 subcommands("dodaj_audio", "dodaj_wiadomość", "pokaż", "usuń", "pauza", "wznów",)
15)]
16pub async fn cron(_ctx: Context<'_>) -> Result<()> {
17 Ok(())
18}
19
20/// Schedule audio playback
21#[poise::command(slash_command)]
22pub async fn dodaj_audio(
23 ctx: Context<'_>,
24 #[description = "Cron expression (e.g. \"37 21 * * *\" = every day at 21:37)"] cron_expression: String,
25 #[description = "Target voice channel"] channel: serenity::Channel,
26 #[description = "Copyparty URL of audio file"]
27 #[autocomplete = "audio_file"]
28 audio_filename: String,
29) -> Result<()> {
30 let channel_id = match channel {
31 serenity::Channel::Guild(ch) => ch.id.get() as i64,
32 _ => return Err(lang::err_bad_channel_type()),
33 };
34
35 database::add_cron_job(
36 &ctx.data().db_pool,
37 &cron_expression,
38 "audio",
39 channel_id,
40 Some(&audio_filename),
41 None,
42 )
43 .await?;
44
45 ctx.say(lang::fmt_cron_audio_queued(&cron_expression))
46 .await?;
47 Ok(())
48}
49
50/// Schedule a periodic message
51#[poise::command(slash_command)]
52pub async fn dodaj_wiadomość(
53 ctx: Context<'_>,
54 #[description = "Cron expression (e.g. \"37 21 * * *\" = every day at 21:37)"] cron_expression: String,
55 #[description = "Docelowy kanał tekstowy"] channel: serenity::Channel,
56 #[description = "Zawartość wiadomości"] message: String,
57) -> Result<()> {
58 let channel_id = match channel {
59 serenity::Channel::Guild(ch) => ch.id.get() as i64,
60 _ => return Err(lang::err_bad_channel_type()),
61 };
62
63 database::add_cron_job(
64 &ctx.data().db_pool,
65 &cron_expression,
66 "wiadomość",
67 channel_id,
68 None,
69 Some(&message),
70 )
71 .await?;
72
73 ctx.say(lang::L_CRON_QUEUED).await?;
74 Ok(())
75}
76
77/// List all cron jobs
78#[poise::command(slash_command)]
79pub async fn pokaż(ctx: Context<'_>) -> Result<()> {
80 let jobs = database::list_cron_jobs(&ctx.data().db_pool).await?;
81 if jobs.is_empty() {
82 ctx.say(lang::L_CRON_NONE).await?;
83 return Ok(());
84 }
85
86 let mut embed = serenity::CreateEmbed::default()
87 .title(lang::L_CRON_TITLE)
88 .color(0x00aaff);
89
90 for job in &jobs {
91 let status = if job.active == 1 {
92 lang::L_CRON_ACTIVE
93 } else {
94 lang::L_CRON_PAUSED
95 };
96 let desc = match job.r#type.as_str() {
97 "audio" => lang::fmt_cron_label_audio(job.audio_filename.as_deref().unwrap_or("?")),
98 _ => lang::fmt_cron_label_msg(job.message.as_deref().unwrap_or("?")),
99 };
100 embed = embed.field(
101 lang::fmt_cron_job_status(job.id, status),
102 lang::fmt_cron_field(&job.cron_expression, job.channel_id, &desc),
103 false,
104 );
105 }
106
107 ctx.send(poise::CreateReply::default().embed(embed)).await?;
108 Ok(())
109}
110
111/// Remove a cron job
112#[poise::command(slash_command)]
113pub async fn usuń(
114 ctx: Context<'_>,
115 #[description = "ID pracy do usunięcia"] job_id: i64,
116) -> Result<()> {
117 if database::delete_cron_job(&ctx.data().db_pool, job_id).await? {
118 ctx.say(lang::fmt_cron_deleted(job_id)).await?;
119 } else {
120 ctx.say(lang::fmt_cron_not_found(job_id)).await?;
121 }
122 Ok(())
123}
124
125/// Pause a cron job
126#[poise::command(slash_command)]
127pub async fn pauza(
128 ctx: Context<'_>,
129 #[description = "ID pracy do zapauzowania"] job_id: i64,
130) -> Result<()> {
131 if database::set_cron_job_active(&ctx.data().db_pool, job_id, false).await? {
132 ctx.say(lang::fmt_cron_paused(job_id)).await?;
133 } else {
134 ctx.say(lang::fmt_cron_not_found(job_id)).await?;
135 }
136 Ok(())
137}
138
139/// Resume a paused cron job
140#[poise::command(slash_command)]
141pub async fn wznów(
142 ctx: Context<'_>,
143 #[description = "ID pracy cron do wznowienia"] job_id: i64,
144) -> Result<()> {
145 if database::set_cron_job_active(&ctx.data().db_pool, job_id, true).await? {
146 ctx.say(lang::fmt_cron_resumed(job_id)).await?;
147 } else {
148 ctx.say(lang::fmt_cron_not_found(job_id)).await?;
149 }
150 Ok(())
151}
152
153pub fn commands() -> Vec<poise::Command<Data, Error>> {
154 vec![cron()]
155}
diff --git a/src/commands/math.rs b/src/commands/math.rs
new file mode 100644
index 0000000..43a7de8
--- /dev/null
+++ b/src/commands/math.rs
@@ -0,0 +1,77 @@
1use crate::lang;
2use crate::Data;
3use crate::error::{Error, Result};
4
5type Context<'a> = poise::Context<'a, Data, Error>;
6
7/// Evaluate a math expression via qalc
8#[poise::command(slash_command)]
9pub async fn math(
10 ctx: Context<'_>,
11 #[description = "Ekspresja matematyczn"] equation: String,
12) -> Result<()> {
13 if equation.len() > 1000 {
14 ctx.say(lang::L_MATH_TOO_LONG).await?;
15 return Ok(());
16 }
17
18 if !equation.is_ascii() {
19 ctx.say(lang::L_MATH_NOT_ASCII).await?;
20 return Ok(());
21 }
22
23 let mut depth = 0i32;
24 for c in equation.chars() {
25 match c {
26 '(' | '[' | '{' => depth += 1,
27 ')' | ']' | '}' => depth -= 1,
28 _ => {}
29 }
30 if depth < 0 {
31 ctx.say(lang::L_MATH_UNBALANCED).await?;
32 return Ok(());
33 }
34 }
35 if depth != 0 {
36 ctx.say(lang::L_MATH_UNBALANCED).await?;
37 return Ok(());
38 }
39
40 let dangerous = [
41 "eval",
42 "exec",
43 "os.",
44 "subprocess",
45 "import",
46 "__",
47 "system",
48 "shutil",
49 "typecast",
50 "shell",
51 "fork",
52 "execve",
53 ];
54 let lower = equation.to_lowercase();
55 if dangerous.iter().any(|d| lower.contains(d)) {
56 ctx.say(lang::L_MATH_DANGEROUS).await?;
57 return Ok(());
58 }
59
60 let output = tokio::process::Command::new("qalc")
61 .arg(&equation)
62 .output()
63 .await
64 .map_err(|e| Error::Custom(lang::fmt_math_qalc_err(&e)))?;
65
66 let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
67 if result.is_empty() {
68 ctx.say(lang::L_MATH_NO_RESULT).await?;
69 } else {
70 ctx.say(lang::fmt_math_result(&result)).await?;
71 }
72 Ok(())
73}
74
75pub fn commands() -> Vec<poise::Command<Data, Error>> {
76 vec![math()]
77}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
new file mode 100644
index 0000000..9d6a1d0
--- /dev/null
+++ b/src/commands/mod.rs
@@ -0,0 +1,26 @@
1pub mod music;
2pub mod tts;
3pub mod ai;
4pub mod web;
5pub mod math;
6pub mod qr;
7pub mod cron;
8pub mod trigger;
9pub mod utilities;
10pub mod autocomplete;
11
12use crate::Data;
13
14pub fn all_commands() -> Vec<poise::Command<Data, crate::error::Error>> {
15 let mut cmds = Vec::new();
16 cmds.extend(music::commands());
17 cmds.extend(tts::commands());
18 cmds.extend(ai::commands());
19 cmds.extend(web::commands());
20 cmds.extend(math::commands());
21 cmds.extend(qr::commands());
22 cmds.extend(cron::commands());
23 cmds.extend(trigger::commands());
24 cmds.extend(utilities::commands());
25 cmds
26}
diff --git a/src/commands/music.rs b/src/commands/music.rs
new file mode 100644
index 0000000..313577d
--- /dev/null
+++ b/src/commands/music.rs
@@ -0,0 +1,414 @@
1use poise::serenity_prelude as serenity;
2use tracing::{error, info};
3
4use super::autocomplete::audio_file;
5use crate::Data;
6use crate::copyparty::{html_decode, percent_decode};
7use crate::error::{Error, Result};
8use crate::lang;
9
10type Context<'a> = poise::Context<'a, Data, Error>;
11
12// ── Source classification ──────────────────────────────────────────
13
14/// What kind of audio source are we dealing with?
15///
16/// This mirrors the Python original's split: `EXTERNAL_URL_PATTERN`
17/// (youtube/soundcloud/bandcamp) goes through yt-dlp download, while
18/// *every other* URL is streamed directly via ffmpeg (radio, HLS,
19/// playlists, direct media files).
20#[derive(Debug, Clone, PartialEq)]
21enum SourceKind {
22 /// YouTube, SoundCloud, Twitch, Bandcamp — platforms that don't serve
23 /// raw audio at a direct URL. Needs yt-dlp to extract + download the
24 /// media to memory before decoding.
25 YtDlp,
26 /// Any HTTP(S) URL: internet radio (Icecast/Shoutcast), HLS `.m3u8`,
27 /// `.m3u`/`.pls` playlists, Copyparty files, Discord attachments, or
28 /// directly-accessible media files. Streamed via an ffmpeg subprocess
29 /// — NO full download. ffmpeg handles playlist parsing, Icecast
30 /// metadata, reconnection, and codec/container detection universally.
31 Stream,
32}
33
34/// Play music from Copyparty, URL, or file upload
35#[poise::command(slash_command)]
36pub async fn grajcuj(
37 ctx: Context<'_>,
38 #[description = "Plik do odtworzenia"]
39 #[autocomplete = "audio_file"]
40 melodia: Option<String>,
41 #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>,
42 #[description = "Chujowa kompresja!?"] compressed: Option<bool>,
43) -> Result<()> {
44 let compressed = compressed.unwrap_or(false);
45 eprintln!(
46 "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}",
47 melodia,
48 file.as_ref().map(|f| &f.filename)
49 );
50 let result = play_impl(ctx, melodia, file, compressed).await;
51 if let Err(e) = &result {
52 eprintln!("[DEBUG] play() error: {e}");
53 error!("play command failed: {e}");
54 }
55 result
56}
57
58struct ResolvedSource {
59 url: String,
60 display_name: String,
61 kind: SourceKind,
62}
63
64fn clean_name(raw: &str) -> String {
65 html_decode(&percent_decode(raw))
66}
67
68// ── URL classification helpers ──────────────────────────────────────
69
70/// URLs that require yt-dlp extraction (platforms that don't serve
71/// raw audio at a direct URL). These MUST be downloaded first.
72///
73/// Matches the Python original's `EXTERNAL_URL_PATTERN`:
74/// `youtube|youtu.be|soundcloud|bandcamp` (plus twitch).
75fn needs_ytdlp(url: &str) -> bool {
76 let lower = url.to_lowercase();
77 lower.contains("youtube.com/")
78 || lower.contains("youtu.be/")
79 || lower.contains("twitch.tv/")
80 || lower.contains("soundcloud.com/")
81 || lower.contains("bandcamp.com/")
82}
83
84/// Heuristic: does this URL *look* like a live internet radio stream?
85/// (Used for logging / display only — dispatch is controlled by `SourceKind`.)
86fn is_live_stream_hint(url: &str) -> bool {
87 let lower = url.to_lowercase();
88 // Common Icecast/Shoutcast mount patterns
89 lower.contains("/stream")
90 || lower.contains("/live")
91 || lower.contains("/radio")
92 || lower.contains(";stream")
93 || lower.contains("icecast")
94 || lower.contains("shoutcast")
95 || lower.ends_with(".m3u8")
96 || lower.ends_with(".m3u")
97 || lower.ends_with(".pls")
98 || lower.contains(":8000/") // common Icecast port
99 || lower.contains(":8080/") // common alt port
100}
101
102async fn resolve_source(
103 ctx: Context<'_>,
104 melodia: Option<String>,
105 file: Option<serenity::Attachment>,
106) -> Result<ResolvedSource> {
107 // ── File attachment ──────────────────────────────────────────
108 // Discord attachment URLs are direct media file URLs — ffmpeg streams
109 // them without a full download.
110 if let Some(file) = file {
111 return Ok(ResolvedSource {
112 url: file.url.clone(),
113 display_name: file.filename.clone(),
114 kind: SourceKind::Stream,
115 });
116 }
117
118 let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?;
119
120 // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─
121 if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await {
122 let path = hit.url_path().unwrap_or("");
123 let path = if path.starts_with('/') {
124 path.to_string()
125 } else {
126 format!("/{path}")
127 };
128 let url = ctx.data().copyparty.build_url(&path);
129 let display_name = clean_name(hit.filename().unwrap_or(&name));
130 return Ok(ResolvedSource {
131 url,
132 display_name,
133 kind: SourceKind::Stream,
134 });
135 }
136
137 // ── Direct Copyparty path (starts with '/') ──────────────────
138 if name.starts_with('/') {
139 let url = ctx.data().copyparty.build_url(&name);
140 let display_name = clean_name(name.rsplit('/').next().unwrap_or(&name));
141 return Ok(ResolvedSource {
142 url,
143 display_name,
144 kind: SourceKind::Stream,
145 });
146 }
147
148 // ── Direct HTTP(S) URL ───────────────────────────────────────
149 if name.starts_with("http://") || name.starts_with("https://") {
150 let url = name.clone();
151 let display_name = clean_name(url.rsplit('/').next().unwrap_or(&url));
152
153 // yt-dlp platforms → download; everything else (radio, HLS,
154 // playlists, Copyparty, direct media) → ffmpeg stream.
155 let kind = if needs_ytdlp(&url) {
156 SourceKind::YtDlp
157 } else {
158 SourceKind::Stream
159 };
160 return Ok(ResolvedSource {
161 url,
162 display_name,
163 kind,
164 });
165 }
166
167 // ── Fallback: search Copyparty ───────────────────────────────
168 info!(query = %name, "searching copyparty for audio");
169 let results = ctx
170 .data()
171 .copyparty
172 .search(&name, 5, &["mp3", "wav", "ogg", "flac", "m4a", "opus"])
173 .await
174 .map_err(|e| {
175 error!(query = %name, error = %e, "copyparty search failed");
176 Error::Custom(lang::fmt_music_search_failed(&e))
177 })?;
178 let hit = results
179 .first()
180 .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_AUDIO_ON_CP.into()))?;
181 let path = hit
182 .url_path()
183 .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_URL_IN_RESULT.into()))?;
184 let path = if path.starts_with('/') {
185 path.to_string()
186 } else {
187 format!("/{path}")
188 };
189 let url = ctx.data().copyparty.build_url(&path);
190 let display_name = clean_name(hit.filename().unwrap_or("unknown"));
191 Ok(ResolvedSource {
192 url,
193 display_name,
194 kind: SourceKind::Stream,
195 })
196}
197
198/// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging.
199fn redact_url(url: &str) -> &str {
200 let Some(q) = url.find('?') else {
201 return url;
202 };
203 let before = &url[..q];
204 let after = &url[q..];
205 // Strip `?pw=...` or `&pw=...`
206 if after.strip_prefix("?pw=").is_some() {
207 return before;
208 }
209 if let Some(idx) = after.find("&pw=") {
210 return &url[..q + idx];
211 }
212 url
213}
214
215/// Strip tracking/playlist params (`&...`) from YouTube URLs.
216/// yt-dlp only needs `?v=ID`; extra `&` params can cause failures.
217pub(crate) fn sanitize_yt_url(url: &str) -> String {
218 let lower = url.to_lowercase();
219 if lower.contains("youtube.com/") || lower.contains("youtu.be/") {
220 if let Some(pos) = url.find('&') {
221 return url[..pos].to_string();
222 }
223 }
224 url.to_string()
225}
226
227// ── Playlist resolution is handled by ffmpeg natively ──────────────
228// ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment
229// fetching, PLS/M3U entry parsing) — no manual resolution needed.
230// This mirrors the Python bot's `FFmpegPCMAudio(source, ...)` approach.
231
232async fn play_impl(
233 ctx: Context<'_>,
234 melodia: Option<String>,
235 file: Option<serenity::Attachment>,
236 compressed: bool,
237) -> Result<()> {
238 ctx.defer().await?;
239
240 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
241
242 let channel_id = ctx
243 .serenity_context()
244 .cache
245 .guild(guild_id)
246 .and_then(|g| {
247 g.voice_states
248 .get(&ctx.author().id)
249 .and_then(|vs| vs.channel_id)
250 })
251 .ok_or_else(|| lang::err_not_in_voice())?;
252
253 let src = resolve_source(ctx, melodia, file).await?;
254
255 let call;
256 let input;
257
258 if compressed {
259 // ── Compressed path: download → compress → decode ──────────
260 // Defer stopping current track until compression is done.
261 let audio_bytes = match src.kind {
262 SourceKind::YtDlp => {
263 let clean_url = sanitize_yt_url(&src.url);
264 info!(url = %clean_url, "downloading via yt-dlp for compression");
265 crate::voice::ytdlp_download(&clean_url).await?
266 }
267 SourceKind::Stream => {
268 if is_live_stream_hint(&src.url) {
269 ctx.say(lang::L_MUSIC_CANT_COMPRESS_LIVE).await?;
270 // Fall through to normal streaming below
271 let live = true;
272 info!(url = %redact_url(&src.url), live, "live stream — compression skipped, streaming via ffmpeg");
273 // Stop current, connect, stream
274 {
275 let mut map = ctx.data().music_tracks.lock().await;
276 if let Some(handle) = map.remove(&guild_id.get()) {
277 let _ = handle.stop();
278 }
279 }
280 let (call_res, input_res) = tokio::join!(
281 ctx.data().voice_manager.connect(guild_id, channel_id),
282 crate::voice::ffmpeg_stream_input(&src.url)
283 );
284 call = call_res?;
285 input = input_res?;
286 // skip the rest of the compressed path
287 let handle = {
288 let mut call_lock = call.lock().await;
289 call_lock.play_input(input)
290 };
291 ctx.data()
292 .music_tracks
293 .lock()
294 .await
295 .insert(guild_id.get(), handle);
296 ctx.say(lang::fmt_music_playing(&src.display_name)).await?;
297 return Ok(());
298 }
299 info!(url = %redact_url(&src.url), "downloading for compression");
300 crate::voice::download_url(&ctx.data().http_client, &src.url).await?
301 }
302 };
303
304 info!("degrading audio ({} bytes)", audio_bytes.len());
305 let degraded = crate::voice::degrade_audio(&audio_bytes).await?;
306
307 // Deferred stop: only interrupt current track now that we're ready.
308 {
309 let mut map = ctx.data().music_tracks.lock().await;
310 if let Some(handle) = map.remove(&guild_id.get()) {
311 let _ = handle.stop();
312 }
313 }
314 call = ctx
315 .data()
316 .voice_manager
317 .connect(guild_id, channel_id)
318 .await?;
319 input = degraded;
320 } else {
321 // ── Normal path: stop first, then stream/download ──────────
322 {
323 let mut map = ctx.data().music_tracks.lock().await;
324 if let Some(handle) = map.remove(&guild_id.get()) {
325 let _ = handle.stop();
326 }
327 }
328
329 match src.kind {
330 SourceKind::YtDlp => {
331 let clean_url = sanitize_yt_url(&src.url);
332 info!(url = %clean_url, "downloading via yt-dlp");
333 let audio_bytes = crate::voice::ytdlp_download(&clean_url).await?;
334 let (call_res, input_res) = tokio::join!(
335 ctx.data().voice_manager.connect(guild_id, channel_id),
336 tokio::task::spawn_blocking(move || crate::voice::decode_audio(&audio_bytes))
337 );
338 call = call_res?;
339 input = input_res??;
340 }
341 SourceKind::Stream => {
342 let live = is_live_stream_hint(&src.url);
343 info!(url = %redact_url(&src.url), live, "streaming via ffmpeg subprocess");
344 let (call_res, input_res) = tokio::join!(
345 ctx.data().voice_manager.connect(guild_id, channel_id),
346 crate::voice::ffmpeg_stream_input(&src.url)
347 );
348 call = call_res?;
349 input = input_res?;
350 }
351 }
352 }
353
354 let handle = {
355 let mut call_lock = call.lock().await;
356 call_lock.play_input(input)
357 };
358
359 ctx.data()
360 .music_tracks
361 .lock()
362 .await
363 .insert(guild_id.get(), handle);
364
365 ctx.say(lang::fmt_music_playing(&src.display_name)).await?;
366 Ok(())
367}
368
369/// Stop playback and disconnect from voice
370#[poise::command(slash_command)]
371pub async fn stop(ctx: Context<'_>) -> Result<()> {
372 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
373 ctx.data().voice_manager.disconnect(guild_id).await?;
374 ctx.data().music_tracks.lock().await.remove(&guild_id.get());
375 ctx.send(
376 poise::CreateReply::default()
377 .content(lang::L_MUSIC_STOPPED)
378 .ephemeral(true),
379 )
380 .await?;
381 Ok(())
382}
383
384/// Join your voice channel
385#[poise::command(slash_command)]
386pub async fn join(ctx: Context<'_>) -> Result<()> {
387 ctx.defer().await?;
388 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
389
390 let channel_id = ctx.serenity_context().cache.guild(guild_id).and_then(|g| {
391 g.voice_states
392 .get(&ctx.author().id)
393 .and_then(|vs| vs.channel_id)
394 });
395
396 match channel_id {
397 Some(cid) => match ctx.data().voice_manager.connect(guild_id, cid).await {
398 Ok(_) => {
399 ctx.say(lang::fmt_voice_joined(&cid)).await?;
400 }
401 Err(e) => {
402 ctx.say(lang::fmt_voice_join_failed(&e)).await?;
403 }
404 },
405 None => {
406 ctx.say(lang::L_VOICE_USER_NOT_IN_VC).await?;
407 }
408 }
409 Ok(())
410}
411
412pub fn commands() -> Vec<poise::Command<Data, Error>> {
413 vec![grajcuj(), stop(), join()]
414}
diff --git a/src/commands/qr.rs b/src/commands/qr.rs
new file mode 100644
index 0000000..26232ee
--- /dev/null
+++ b/src/commands/qr.rs
@@ -0,0 +1,87 @@
1use poise::serenity_prelude as serenity;
2
3use crate::Data;
4use crate::error::{Error, Result};
5use crate::lang;
6
7type Context<'a> = poise::Context<'a, Data, Error>;
8
9/// QR group
10#[poise::command(slash_command, subcommands("dekodowanie", "generowanie",))]
11pub async fn qr(_ctx: Context<'_>) -> Result<()> {
12 Ok(())
13}
14
15/// Generate a QR code from text
16#[poise::command(slash_command)]
17pub async fn generowanie(
18 ctx: Context<'_>,
19 #[description = "Zawartość kodu QR"] text: String,
20) -> Result<()> {
21 ctx.defer().await?;
22
23 let code = qrcode::QrCode::new(text.as_bytes())?;
24 let image = code.render::<image::Luma<u8>>().build();
25
26 let scaled = image::imageops::resize(
27 &image,
28 image.width() * 10,
29 image.height() * 10,
30 image::imageops::Nearest,
31 );
32
33 let mut buf = std::io::Cursor::new(Vec::new());
34 scaled.write_to(&mut buf, image::ImageFormat::Png)?;
35
36 ctx.send(
37 poise::CreateReply::default()
38 .content(lang::L_QR_GENERATED)
39 .attachment(serenity::CreateAttachment::bytes(
40 buf.into_inner(),
41 "qrcode.png",
42 )),
43 )
44 .await?;
45 Ok(())
46}
47
48/// Decode a QR code from an uploaded image
49#[poise::command(slash_command)]
50pub async fn dekodowanie(
51 ctx: Context<'_>,
52 #[description = "Obraz zawierający kod QR"] image: serenity::Attachment,
53) -> Result<()> {
54 ctx.defer().await?;
55
56 let img_data = ctx
57 .data()
58 .http_client
59 .get(&image.url)
60 .send()
61 .await?
62 .bytes()
63 .await?;
64 let img = image::load_from_memory(&img_data)?;
65 let gray = img.to_luma8();
66
67 let decoded = decode_qr_simple(&gray);
68 match decoded {
69 Some(text) => {
70 ctx.say(lang::fmt_qr_decoded(&text)).await?;
71 }
72 None => {
73 ctx.say(lang::L_QR_DECODE_FAILED).await?;
74 }
75 }
76 Ok(())
77}
78
79fn decode_qr_simple(img: &image::ImageBuffer<image::Luma<u8>, Vec<u8>>) -> Option<String> {
80 rxing::helpers::detect_in_luma(img.as_raw().to_vec(), img.width(), img.height(), None)
81 .ok()
82 .map(|r| r.getText().to_owned())
83}
84
85pub fn commands() -> Vec<poise::Command<Data, Error>> {
86 vec![qr()]
87}
diff --git a/src/commands/trigger.rs b/src/commands/trigger.rs
new file mode 100644
index 0000000..7453e03
--- /dev/null
+++ b/src/commands/trigger.rs
@@ -0,0 +1,199 @@
1use poise::serenity_prelude as serenity;
2
3use super::autocomplete::trigger_name;
4use crate::Data;
5use crate::database;
6use crate::error::{Error, Result};
7use crate::lang;
8
9type Context<'a> = poise::Context<'a, Data, Error>;
10
11/// Trigger keyword management
12#[poise::command(
13 slash_command,
14 subcommands("create", "add", "remove", "delete", "list", "help",)
15)]
16pub async fn trigger(_ctx: Context<'_>) -> Result<()> {
17 Ok(())
18}
19
20/// Create a new trigger group
21#[poise::command(slash_command)]
22pub async fn create(
23 ctx: Context<'_>,
24 #[description = "Logical group name"]
25 #[autocomplete = "trigger_name"]
26 name: String,
27) -> Result<()> {
28 match database::create_trigger(&ctx.data().db_pool, &name).await {
29 Ok(_) => {
30 ctx.data().reload_triggers().await;
31 ctx.say(lang::fmt_trigger_created(&name)).await?;
32 }
33 Err(e) => {
34 ctx.say(lang::fmt_trigger_failed(&e)).await?;
35 }
36 }
37 Ok(())
38}
39
40/// Add keywords and/or responses to a trigger group
41#[poise::command(slash_command)]
42pub async fn add(
43 ctx: Context<'_>,
44 #[description = "Existing group name"]
45 #[autocomplete = "trigger_name"]
46 name: String,
47 #[description = "Keywords separated by \\"] trigger_keywords: Option<String>,
48 #[description = "Responses separated by \\"] response: Option<String>,
49) -> Result<()> {
50 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
51 .await?
52 .ok_or_else(|| lang::err_trigger_not_found())?;
53
54 let mut modified = false;
55 if let Some(kw) = trigger_keywords {
56 for keyword in kw.split('\\') {
57 let k = keyword.trim();
58 if !k.is_empty() {
59 database::add_keyword(&ctx.data().db_pool, t.id, k).await?;
60 modified = true;
61 }
62 }
63 }
64 if let Some(r) = response {
65 for resp in r.split('\\') {
66 let r = resp.trim();
67 if !r.is_empty() {
68 database::add_response(&ctx.data().db_pool, t.id, r).await?;
69 modified = true;
70 }
71 }
72 }
73
74 if modified {
75 ctx.data().reload_triggers().await;
76 }
77 ctx.say(lang::L_TRIGGER_UPDATED).await?;
78 Ok(())
79}
80
81/// Remove keyword or response from a group
82#[poise::command(slash_command)]
83pub async fn remove(
84 ctx: Context<'_>,
85 #[description = "Group name"]
86 #[autocomplete = "trigger_name"]
87 name: String,
88 #[description = "Keyword to remove"] trigger_keyword: Option<String>,
89 #[description = "Response to remove"] response: Option<String>,
90) -> Result<()> {
91 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
92 .await?
93 .ok_or_else(|| lang::err_trigger_not_found())?;
94
95 let mut modified = false;
96 if let Some(kw) = trigger_keyword {
97 database::remove_keyword(&ctx.data().db_pool, t.id, &kw).await?;
98 modified = true;
99 }
100 if let Some(r) = response {
101 database::remove_response(&ctx.data().db_pool, t.id, &r).await?;
102 modified = true;
103 }
104
105 if modified {
106 ctx.data().reload_triggers().await;
107 }
108 ctx.say(lang::L_TRIGGER_UPDATED).await?;
109 Ok(())
110}
111
112/// Delete an entire trigger group
113#[poise::command(slash_command)]
114pub async fn delete(
115 ctx: Context<'_>,
116 #[description = "Group name"]
117 #[autocomplete = "trigger_name"]
118 name: String,
119) -> Result<()> {
120 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
121 .await?
122 .ok_or_else(|| lang::err_trigger_not_found())?;
123
124 database::delete_trigger(&ctx.data().db_pool, t.id).await?;
125 ctx.data().reload_triggers().await;
126 ctx.say(lang::fmt_trigger_deleted(&name)).await?;
127 Ok(())
128}
129
130/// List keywords and responses for a group
131#[poise::command(slash_command)]
132pub async fn list(
133 ctx: Context<'_>,
134 #[description = "Group name"]
135 #[autocomplete = "trigger_name"]
136 name: String,
137) -> Result<()> {
138 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
139 .await?
140 .ok_or_else(|| lang::err_trigger_not_found())?;
141
142 let keywords = database::get_keywords(&ctx.data().db_pool, t.id).await?;
143 let responses = database::get_responses(&ctx.data().db_pool, t.id).await?;
144
145 let mut embed = serenity::CreateEmbed::default()
146 .title(lang::fmt_trigger_title(&name))
147 .color(0x00aaff);
148
149 embed = embed.field(
150 "Keywords",
151 if keywords.is_empty() {
152 lang::L_TRIGGER_KW_NONE.into()
153 } else {
154 keywords
155 .iter()
156 .map(|r| format!("{r}"))
157 .collect::<Vec<_>>()
158 .join("\n")
159 },
160 false,
161 );
162 embed = embed.field(
163 "Responses",
164 if responses.is_empty() {
165 lang::L_TRIGGER_KW_NONE.into()
166 } else {
167 responses
168 .iter()
169 .map(|r| format!("{r}"))
170 .collect::<Vec<_>>()
171 .join("\n")
172 },
173 false,
174 );
175
176 ctx.send(poise::CreateReply::default().embed(embed)).await?;
177 Ok(())
178}
179
180/// Display trigger command usage info
181#[poise::command(slash_command)]
182pub async fn help(ctx: Context<'_>) -> Result<()> {
183 let help = [
184 "**Trigger Commands**",
185 "`/trigger create <name>` — Create new trigger group",
186 "`/trigger add <name> <trigger> <response>` — Add keywords/responses (separate with \\)",
187 "`/trigger remove <name> <trigger> <response>` — Remove keywords/responses",
188 "`/trigger delete <name>` — Delete entire group",
189 "`/trigger list <name>` — View group details",
190 ]
191 .join("\n");
192
193 ctx.say(help).await?;
194 Ok(())
195}
196
197pub fn commands() -> Vec<poise::Command<Data, Error>> {
198 vec![trigger()]
199}
diff --git a/src/commands/tts.rs b/src/commands/tts.rs
new file mode 100644
index 0000000..ee13596
--- /dev/null
+++ b/src/commands/tts.rs
@@ -0,0 +1,106 @@
1use poise::serenity_prelude as serenity;
2
3use crate::Data;
4use crate::error::{Error, Result};
5use crate::lang;
6
7type Context<'a> = poise::Context<'a, Data, Error>;
8
9#[derive(poise::ChoiceParameter)]
10pub enum OutputMode {
11 #[name = "File"]
12 File,
13 #[name = "Voice Channel"]
14 Vc,
15}
16
17/// Generate Google TTS (Polish) and output to file or voice channel
18#[poise::command(slash_command)]
19pub async fn tts(
20 ctx: Context<'_>,
21 #[description = "Text to synthesize"] text: String,
22 #[description = "Output destination (file or vc)"] output: Option<OutputMode>,
23) -> Result<()> {
24 ctx.defer().await?;
25 let lang = "pl";
26 let tts_url = format!(
27 "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl={}&client=tw-ob",
28 urlencoding(&text),
29 lang
30 );
31
32 let output = output.unwrap_or(OutputMode::Vc);
33
34 let audio_data = ctx
35 .data()
36 .http_client
37 .get(&tts_url)
38 .send()
39 .await?
40 .bytes()
41 .await?;
42
43 match output {
44 OutputMode::File => {
45 tokio::fs::create_dir_all(crate::config::TEMP_PATH)
46 .await
47 .ok();
48 let path = format!(
49 "{}/tts_{}.mp3",
50 crate::config::TEMP_PATH,
51 uuid::Uuid::new_v4()
52 );
53 tokio::fs::write(&path, &audio_data).await?;
54 ctx.send(
55 poise::CreateReply::default()
56 .content(lang::L_TTS_SAVED)
57 .attachment(serenity::CreateAttachment::path(&path).await.unwrap()),
58 )
59 .await?;
60 }
61 OutputMode::Vc => {
62 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
63 let channel_id = ctx
64 .serenity_context()
65 .cache
66 .guild(guild_id)
67 .and_then(|g| {
68 g.voice_states
69 .get(&ctx.author().id)
70 .and_then(|vs| vs.channel_id)
71 })
72 .ok_or_else(|| lang::err_not_in_voice())?;
73
74 let call = ctx
75 .data()
76 .voice_manager
77 .connect(guild_id, channel_id)
78 .await?;
79 let input = crate::voice::decode_audio(&audio_data)?;
80 let mut call_lock = call.lock().await;
81 if call_lock.current_channel().is_some() {
82 call_lock.play_input(input);
83 }
84 ctx.say(lang::L_TTS_PLAYING).await?;
85 }
86 }
87 Ok(())
88}
89
90pub fn urlencoding(s: &str) -> String {
91 let mut out = String::with_capacity(s.len());
92 for &byte in s.as_bytes() {
93 match byte {
94 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'~' => {
95 out.push(byte as char)
96 }
97 b' ' => out.push('+'),
98 _ => out.push_str(&format!("%{:02X}", byte)),
99 }
100 }
101 out
102}
103
104pub fn commands() -> Vec<poise::Command<Data, Error>> {
105 vec![tts()]
106}
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}
diff --git a/src/commands/web.rs b/src/commands/web.rs
new file mode 100644
index 0000000..d68f663
--- /dev/null
+++ b/src/commands/web.rs
@@ -0,0 +1,122 @@
1use poise::serenity_prelude as serenity;
2use scraper::{Html, Selector};
3
4use crate::Data;
5use crate::error::{Error, Result};
6use crate::lang;
7
8type Context<'a> = poise::Context<'a, Data, Error>;
9
10/// Search the web via DuckDuckGo
11#[poise::command(slash_command)]
12pub async fn web(ctx: Context<'_>, #[description = "Search query"] query: String) -> Result<()> {
13 ctx.defer().await?;
14
15 let url = format!(
16 "https://html.duckduckgo.com/html/?q={}",
17 urlencoding(&query)
18 );
19 let html = ctx
20 .data()
21 .http_client
22 .get(&url)
23 .send()
24 .await?
25 .text()
26 .await?;
27
28 let results = {
29 let document = Html::parse_document(&html);
30
31 let result_selector = Selector::parse(".result").unwrap();
32 let title_selector = Selector::parse(".result__title a").unwrap();
33 let snippet_selector = Selector::parse(".result__snippet").unwrap();
34
35 let mut results = Vec::new();
36 for result in document.select(&result_selector).take(5) {
37 let title = result
38 .select(&title_selector)
39 .next()
40 .map(|a| a.text().collect::<String>())
41 .unwrap_or_default();
42
43 let url = result
44 .select(&title_selector)
45 .next()
46 .and_then(|a| a.attr("href"))
47 .unwrap_or("")
48 .to_string();
49
50 let snippet = result
51 .select(&snippet_selector)
52 .next()
53 .map(|s| s.text().collect::<String>())
54 .unwrap_or_default();
55
56 let clean_url = if let Some(encoded) = url.split("uddg=").nth(1) {
57 let encoded = encoded.split('&').next().unwrap_or(encoded);
58 url_decode(encoded)
59 } else {
60 url.to_string()
61 };
62
63 results.push((title, clean_url, snippet));
64 }
65 results
66 };
67
68 if results.is_empty() {
69 ctx.say(lang::L_WEB_NO_RESULTS).await?;
70 return Ok(());
71 }
72
73 let mut embed = serenity::CreateEmbed::default()
74 .title(lang::fmt_web_title(&query))
75 .color(0x00aaff);
76
77 for (i, (title, url, snippet)) in results.iter().enumerate() {
78 embed = embed.field(
79 format!("{}. {title}", i + 1),
80 format!("{url}\n{snippet}"),
81 false,
82 );
83 }
84
85 ctx.send(poise::CreateReply::default().embed(embed)).await?;
86 Ok(())
87}
88
89fn url_decode(s: &str) -> String {
90 let mut result = String::new();
91 let mut chars = s.chars();
92 while let Some(c) = chars.next() {
93 match c {
94 '+' => result.push(' '),
95 '%' => {
96 let hi = chars.next().and_then(|c| c.to_digit(16));
97 let lo = chars.next().and_then(|c| c.to_digit(16));
98 if let (Some(hi), Some(lo)) = (hi, lo) {
99 result.push(char::from_u32((hi << 4) | lo).unwrap_or('�'));
100 } else {
101 result.push('%');
102 }
103 }
104 _ => result.push(c),
105 }
106 }
107 result
108}
109
110fn urlencoding(s: &str) -> String {
111 s.chars()
112 .map(|c| match c {
113 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '~' => c.to_string(),
114 ' ' => "+".to_string(),
115 _ => format!("%{:02X}", c as u8),
116 })
117 .collect()
118}
119
120pub fn commands() -> Vec<poise::Command<Data, Error>> {
121 vec![web()]
122}