aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
-rw-r--r--src/config.rs145
-rw-r--r--src/copyparty/mod.rs267
-rw-r--r--src/database/mod.rs344
-rw-r--r--src/error.rs108
-rw-r--r--src/events/mod.rs368
-rw-r--r--src/lang.rs387
-rw-r--r--src/main.rs190
-rw-r--r--src/openrouter/mod.rs134
-rw-r--r--src/tasks/mod.rs172
-rw-r--r--src/voice/mod.rs404
21 files changed, 4827 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}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..96a6849
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,145 @@
1use std::env;
2
3pub const TEMP_PATH: &str = "/tmp/senator_sperma";
4
5#[derive(Debug, Clone)]
6pub struct Config {
7 pub token: String,
8 pub status_channel_id: Option<u64>,
9 pub assistant_keyword: String,
10 pub copyparty_url: String,
11 pub copyparty_url_upload: String,
12 pub copyparty_password: Option<String>,
13 pub openrouter_api_key: Option<String>,
14 pub openrouter_model: Option<String>,
15 pub database_path: String,
16}
17
18struct EnvVar {
19 name: &'static str,
20 required: bool,
21 default: Option<&'static str>,
22 description: &'static str,
23}
24
25const ENV_VARS: &[EnvVar] = &[
26 EnvVar {
27 name: "TOKEN",
28 required: true,
29 default: None,
30 description: "Discord bot token",
31 },
32 EnvVar {
33 name: "STATUS_CHANNEL_ID",
34 required: false,
35 default: None,
36 description: "status channel ID",
37 },
38 EnvVar {
39 name: "ASSISTANT_KEYWORD",
40 required: false,
41 default: Some("jarvis"),
42 description: "keyword for AI assistant",
43 },
44 EnvVar {
45 name: "COPYPARTY_URL",
46 required: false,
47 default: Some("http://localhost:3939"),
48 description: "Copyparty base URL",
49 },
50 EnvVar {
51 name: "COPYPARTY_URL_UPLOAD",
52 required: false,
53 default: Some("http://localhost:3939"),
54 description: "Copyparty upload URL",
55 },
56 EnvVar {
57 name: "COPYPARTY_PASSWORD",
58 required: false,
59 default: None,
60 description: "Copyparty auth password",
61 },
62 EnvVar {
63 name: "OPENROUTER_API_KEY",
64 required: false,
65 default: None,
66 description: "OpenRouter API key (disables AI if missing)",
67 },
68 EnvVar {
69 name: "OPENROUTER_MODEL",
70 required: false,
71 default: Some("cognitivecomputations/dolphin-mistral-24b-venice-edition:free"),
72 description: "OpenRouter model",
73 },
74 EnvVar {
75 name: "SENATOR_DB_PATH",
76 required: false,
77 default: Some("data/senator.db"),
78 description: "SQLite database path",
79 },
80 EnvVar {
81 name: "RUST_LOG",
82 required: false,
83 default: Some("info"),
84 description: "tracing/logging level filter",
85 },
86];
87
88pub fn check_env_vars() {
89 let mut missing_required = Vec::new();
90 let mut missing_optional = Vec::new();
91
92 for var in ENV_VARS {
93 match env::var(var.name) {
94 Ok(_) => continue,
95 Err(_) => {
96 if var.required {
97 tracing::error!("MISSING REQUIRED: {} ({})", var.name, var.description);
98 missing_required.push(var.name);
99 } else {
100 match var.default {
101 Some(default) => {
102 tracing::warn!("{} not set, using default: {}", var.name, default)
103 }
104 None => tracing::warn!("{} not set ({})", var.name, var.description),
105 }
106 missing_optional.push(var.name);
107 }
108 }
109 }
110 }
111
112 if !missing_required.is_empty() {
113 tracing::error!(
114 "Missing required environment variables: {}. The application will not function correctly.",
115 missing_required.join(", ")
116 );
117 }
118
119 if !missing_optional.is_empty() {
120 tracing::warn!(
121 "Missing optional environment variables: {}. Some features will be disabled or use defaults.",
122 missing_optional.join(", ")
123 );
124 }
125}
126
127impl Config {
128 pub fn from_env() -> Self {
129 Self {
130 token: env::var("TOKEN").expect("Missing TOKEN"),
131 status_channel_id: env::var("STATUS_CHANNEL_ID")
132 .ok()
133 .and_then(|v| v.parse().ok()),
134 assistant_keyword: env::var("ASSISTANT_KEYWORD").unwrap_or_else(|_| "jarvis".into()),
135 copyparty_url: env::var("COPYPARTY_URL")
136 .unwrap_or_else(|_| "http://localhost:3939".into()),
137 copyparty_url_upload: env::var("COPYPARTY_URL_UPLOAD")
138 .unwrap_or_else(|_| "http://localhost:3939".into()),
139 copyparty_password: env::var("COPYPARTY_PASSWORD").ok(),
140 openrouter_api_key: env::var("OPENROUTER_API_KEY").ok(),
141 openrouter_model: env::var("OPENROUTER_MODEL").ok(),
142 database_path: env::var("SENATOR_DB_PATH").unwrap_or_else(|_| "data/senator.db".into()),
143 }
144 }
145}
diff --git a/src/copyparty/mod.rs b/src/copyparty/mod.rs
new file mode 100644
index 0000000..00bfd53
--- /dev/null
+++ b/src/copyparty/mod.rs
@@ -0,0 +1,267 @@
1use reqwest::Client;
2use serde::Deserialize;
3use tracing::warn;
4
5use crate::error::Result;
6use crate::lang;
7
8#[derive(Clone)]
9pub struct CopypartyClient {
10 pub base_url: String,
11 pub upload_url: String,
12 pub password: Option<String>,
13 client: Client,
14}
15
16/// Decode percent-encoded copyparty paths (e.g. `%20` → space, `%C5%BA` → `ź`).
17pub fn percent_decode(s: &str) -> String {
18 let mut buf: Vec<u8> = Vec::with_capacity(s.len());
19 let bytes = s.as_bytes();
20 let mut i = 0;
21 while i < bytes.len() {
22 let b = bytes[i];
23 if b == b'%' && i + 2 < bytes.len() {
24 if let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) {
25 buf.push(hi << 4 | lo);
26 i += 3;
27 continue;
28 }
29 }
30 if b == b'+' {
31 buf.push(b' ');
32 } else {
33 buf.push(b);
34 }
35 i += 1;
36 }
37 String::from_utf8_lossy(&buf).into_owned()
38}
39
40/// Decode common HTML entities in a display string.
41/// Handles named (`&amp;`, `&lt;`) and numeric (`&#123;`, `&#x7B;`) forms.
42/// Uses a byte buffer (like `percent_decode`) to preserve multi-byte UTF-8.
43pub fn html_decode(s: &str) -> String {
44 let mut buf: Vec<u8> = Vec::with_capacity(s.len());
45 let bytes = s.as_bytes();
46 let mut i = 0;
47 while i < bytes.len() {
48 if bytes[i] == b'&' {
49 let rest = &s[i..];
50 if let Some(end) = rest.find(';') {
51 let entity = &rest[..=end];
52 match entity {
53 "&amp;" => buf.extend_from_slice(b"&"),
54 "&lt;" => buf.extend_from_slice(b"<"),
55 "&gt;" => buf.extend_from_slice(b">"),
56 "&quot;" => buf.extend_from_slice(b"\""),
57 "&apos;" => buf.extend_from_slice(b"'"),
58 "&nbsp;" => buf.extend_from_slice(b" "),
59 _ if entity.as_bytes().len() > 3 && entity.as_bytes()[1] == b'#' => {
60 let (num_str, radix) =
61 if entity.as_bytes()[2] == b'x' || entity.as_bytes()[2] == b'X' {
62 (&entity[3..entity.len() - 1], 16u32)
63 } else {
64 (&entity[2..entity.len() - 1], 10u32)
65 };
66 let decoded = u32::from_str_radix(num_str, radix)
67 .ok()
68 .and_then(std::char::from_u32);
69 if let Some(c) = decoded {
70 let mut tmp = [0u8; 4];
71 buf.extend_from_slice(c.encode_utf8(&mut tmp).as_bytes());
72 i += entity.len();
73 continue;
74 }
75 buf.extend_from_slice(entity.as_bytes());
76 }
77 _ => buf.extend_from_slice(entity.as_bytes()),
78 }
79 i += entity.len();
80 continue;
81 }
82 }
83 buf.push(bytes[i]);
84 i += 1;
85 }
86 String::from_utf8_lossy(&buf).into_owned()
87}
88
89fn hex_nibble(b: u8) -> Option<u8> {
90 match b {
91 b'0'..=b'9' => Some(b - b'0'),
92 b'A'..=b'F' => Some(b - b'A' + 10),
93 b'a'..=b'f' => Some(b - b'a' + 10),
94 _ => None,
95 }
96}
97
98/// A search result from copyparty's `/?srch` API.
99///
100/// **Important**: `/?srch` responses use `rp` (relative path like `/music/foo.mp3`).
101/// The `name` / `url` / `path` fields are typically only present in directory
102/// listing (`?ls`) responses, not in search. Use the convenience accessors
103/// below instead of reading fields directly.
104#[derive(Debug, Clone, Deserialize)]
105pub struct SearchHit {
106 /// Relative path including filename (e.g. `/music/foo.mp3`).
107 /// This is the primary field populated by `/?srch`.
108 #[serde(default)]
109 pub rp: Option<String>,
110 /// Path component (directory listing only)
111 #[serde(default)]
112 pub path: Option<String>,
113 /// URL fragment for building a download URL
114 #[serde(default)]
115 pub url: Option<String>,
116 /// Bare filename (directory listing only)
117 #[serde(default)]
118 pub name: Option<String>,
119}
120
121impl SearchHit {
122 /// Best-effort filename extraction: tries `name`, then `rp` tail, then `url` tail.
123 pub fn filename(&self) -> Option<&str> {
124 self.name
125 .as_deref()
126 .or_else(|| self.rp.as_deref().and_then(|p| p.rsplit('/').next()))
127 .or_else(|| self.url.as_deref().and_then(|u| u.rsplit('/').next()))
128 }
129
130 /// Best-effort URL path for building a full download URL.
131 /// Tries `url`, then `rp`.
132 pub fn url_path(&self) -> Option<&str> {
133 self.url.as_deref().or_else(|| self.rp.as_deref())
134 }
135}
136
137#[derive(Debug, Deserialize)]
138pub struct UploadResponse {
139 pub fileurl: Option<String>,
140}
141
142impl CopypartyClient {
143 pub fn new(base_url: String, upload_url: String, password: Option<String>) -> Self {
144 Self {
145 base_url,
146 upload_url,
147 password,
148 client: Client::new(),
149 }
150 }
151
152 /// Build a full Copyparty URL from a path, appending `?pw=PASSWORD`
153 /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`)
154 /// or relative (`music/foo.mp3`).
155 pub fn build_url(&self, path: &str) -> String {
156 let prefixed = if path.starts_with('/') {
157 format!("{}{}", self.base_url, path)
158 } else {
159 format!("{}/{}", self.base_url, path)
160 };
161 if let Some(pw) = &self.password {
162 format!("{prefixed}?pw={pw}")
163 } else {
164 prefixed
165 }
166 }
167
168 /// Search copyparty via the jPOST `/?srch` endpoint.
169 ///
170 /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python
171 /// `_extract_files_from_response` helper), because the exact JSON shape can vary
172 /// across copyparty versions.
173 pub async fn search(
174 &self,
175 query: &str,
176 max_results: u32,
177 ext_filter: &[&str],
178 ) -> Result<Vec<SearchHit>> {
179 let tokens: Vec<&str> = query.split_whitespace().collect();
180 let conditions: Vec<String> = tokens
181 .iter()
182 .map(|t| format!("(path like \"*{t}*\" or name like \"*{t}*\")"))
183 .collect();
184
185 let mut q = conditions.join(" and ");
186 if !ext_filter.is_empty() {
187 let ext_conditions: Vec<String> = ext_filter
188 .iter()
189 .map(|e| format!("name like \"*.{e}\""))
190 .collect();
191 let ext_q = ext_conditions.join(" or ");
192 // When query has no tokens (e.g. empty autocomplete partial),
193 // avoid wrapping an empty string in parens → "() and ..." is invalid.
194 if q.is_empty() {
195 q = ext_q;
196 } else {
197 q = format!("({q}) and ({ext_q})");
198 }
199 }
200
201 let payload = serde_json::json!({"n": max_results, "q": q});
202
203 let mut req = self
204 .client
205 .post(format!("{}/?srch", self.base_url))
206 .json(&payload);
207 if let Some(pw) = &self.password {
208 req = req.header("PW", pw);
209 }
210
211 let resp = req.send().await?;
212 let status = resp.status();
213 let body_text = resp.text().await?;
214
215 if !status.is_success() {
216 let snippet: String = body_text.chars().take(200).collect();
217 return Err(crate::error::Error::Custom(lang::fmt_cp_search_http(
218 &status, &snippet,
219 )));
220 }
221
222 let root: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| {
223 let snippet: String = body_text.chars().take(200).collect();
224 crate::error::Error::Custom(lang::fmt_cp_invalid_json(&e, &snippet))
225 })?;
226
227 // Extract hits array – copyparty wraps results in {"hits": [...]}
228 let hits_val = match &root {
229 serde_json::Value::Object(map) => map.get("hits"),
230 _ => None,
231 };
232
233 let hits: Vec<SearchHit> = match hits_val {
234 Some(serde_json::Value::Array(arr)) => arr
235 .iter()
236 .filter_map(|v| SearchHit::deserialize(v).ok())
237 .collect(),
238 _ => {
239 warn!(
240 "copyparty search response missing 'hits' array; body starts: {:?}",
241 body_text.chars().take(100).collect::<String>()
242 );
243 Vec::new()
244 }
245 };
246
247 Ok(hits)
248 }
249
250 pub async fn upload(&self, file_name: &str, data: Vec<u8>) -> Result<String> {
251 let part = reqwest::multipart::Part::bytes(data).file_name(file_name.to_string());
252 let form = reqwest::multipart::Form::new().part("f", part);
253
254 let mut req = self
255 .client
256 .post(format!("{}/?j=1&ck=b2", self.upload_url))
257 .multipart(form);
258
259 if let Some(pw) = &self.password {
260 req = req.header("Cookie", format!("cppwd={pw}"));
261 }
262
263 let resp: UploadResponse = req.send().await?.json().await?;
264 resp.fileurl
265 .ok_or_else(|| crate::error::Error::Custom(lang::L_CP_UPLOAD_NO_URL.into()))
266 }
267}
diff --git a/src/database/mod.rs b/src/database/mod.rs
new file mode 100644
index 0000000..53f4bf4
--- /dev/null
+++ b/src/database/mod.rs
@@ -0,0 +1,344 @@
1use chrono::{DateTime, Utc};
2use sqlx::FromRow;
3use sqlx::SqlitePool;
4
5use crate::error::{Error, Result};
6
7pub type DbPool = SqlitePool;
8
9pub async fn init_pool(path: &str) -> Result<DbPool> {
10 if let Some(parent) = std::path::Path::new(path).parent() {
11 tokio::fs::create_dir_all(parent).await.map_err(Error::Io)?;
12 }
13 let pool = SqlitePool::connect(&format!("sqlite:{path}?mode=rwc")).await?;
14 run_migrations(&pool).await?;
15 Ok(pool)
16}
17
18async fn run_migrations(pool: &DbPool) -> Result<()> {
19 sqlx::query(
20 "CREATE TABLE IF NOT EXISTS settings (
21 key TEXT PRIMARY KEY,
22 value TEXT NOT NULL
23 )",
24 )
25 .execute(pool)
26 .await?;
27
28 sqlx::query(
29 "CREATE TABLE IF NOT EXISTS voice_activity (
30 user_id TEXT PRIMARY KEY,
31 join_time REAL NOT NULL DEFAULT 0,
32 total_active REAL NOT NULL DEFAULT 0,
33 total_afk REAL NOT NULL DEFAULT 0,
34 is_afk INTEGER NOT NULL DEFAULT 0,
35 afk_start REAL NOT NULL DEFAULT 0,
36 active_start REAL NOT NULL DEFAULT 0,
37 channel_name TEXT NOT NULL DEFAULT '',
38 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
39 )",
40 )
41 .execute(pool)
42 .await?;
43
44 sqlx::query(
45 "CREATE TABLE IF NOT EXISTS cron_jobs (
46 id INTEGER PRIMARY KEY AUTOINCREMENT,
47 cron_expression TEXT NOT NULL,
48 type TEXT NOT NULL CHECK(type IN ('audio', 'wiadomość')),
49 channel_id INTEGER NOT NULL,
50 active INTEGER NOT NULL DEFAULT 1,
51 audio_filename TEXT,
52 message TEXT,
53 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
54 )",
55 )
56 .execute(pool)
57 .await?;
58
59 sqlx::query(
60 "CREATE TABLE IF NOT EXISTS triggers (
61 id INTEGER PRIMARY KEY AUTOINCREMENT,
62 name TEXT UNIQUE NOT NULL,
63 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
64 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
65 )",
66 )
67 .execute(pool)
68 .await?;
69
70 sqlx::query(
71 "CREATE TABLE IF NOT EXISTS trigger_keywords (
72 id INTEGER PRIMARY KEY AUTOINCREMENT,
73 trigger_id INTEGER NOT NULL REFERENCES triggers(id) ON DELETE CASCADE,
74 keyword TEXT NOT NULL,
75 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
76 UNIQUE(trigger_id, keyword)
77 )",
78 )
79 .execute(pool)
80 .await?;
81
82 sqlx::query(
83 "CREATE TABLE IF NOT EXISTS trigger_responses (
84 id INTEGER PRIMARY KEY AUTOINCREMENT,
85 trigger_id INTEGER NOT NULL REFERENCES triggers(id) ON DELETE CASCADE,
86 response_text TEXT NOT NULL,
87 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
88 )",
89 )
90 .execute(pool)
91 .await?;
92
93 Ok(())
94}
95
96// -- Voice Activity --
97#[derive(Debug, Clone, FromRow)]
98pub struct VoiceActivity {
99 pub user_id: String,
100 pub join_time: f64,
101 pub total_active: f64,
102 pub total_afk: f64,
103 pub is_afk: i32,
104 pub afk_start: f64,
105 pub active_start: f64,
106 pub channel_name: String,
107 #[allow(dead_code)]
108 pub last_updated: Option<DateTime<Utc>>,
109}
110
111#[allow(dead_code)]
112pub async fn get_voice_activity(pool: &DbPool, user_id: &str) -> Result<Option<VoiceActivity>> {
113 Ok(
114 sqlx::query_as("SELECT * FROM voice_activity WHERE user_id = ?")
115 .bind(user_id)
116 .fetch_optional(pool)
117 .await?,
118 )
119}
120
121pub async fn upsert_voice_activity(pool: &DbPool, va: &VoiceActivity) -> Result<()> {
122 sqlx::query(
123 "INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name)
124 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
125 ON CONFLICT(user_id) DO UPDATE SET
126 join_time = excluded.join_time,
127 total_active = excluded.total_active,
128 total_afk = excluded.total_afk,
129 is_afk = excluded.is_afk,
130 afk_start = excluded.afk_start,
131 active_start = excluded.active_start,
132 channel_name = excluded.channel_name,
133 last_updated = CURRENT_TIMESTAMP",
134 )
135 .bind(&va.user_id)
136 .bind(va.join_time)
137 .bind(va.total_active)
138 .bind(va.total_afk)
139 .bind(va.is_afk)
140 .bind(va.afk_start)
141 .bind(va.active_start)
142 .bind(&va.channel_name)
143 .execute(pool)
144 .await?;
145 Ok(())
146}
147
148// -- Cron Jobs --
149#[derive(Debug, Clone, FromRow)]
150pub struct CronJob {
151 pub id: i64,
152 pub cron_expression: String,
153 pub r#type: String,
154 pub channel_id: i64,
155 pub active: i32,
156 pub audio_filename: Option<String>,
157 pub message: Option<String>,
158 #[allow(dead_code)]
159 pub created_at: Option<DateTime<Utc>>,
160}
161
162#[allow(dead_code)]
163pub async fn list_cron_jobs(pool: &DbPool) -> Result<Vec<CronJob>> {
164 Ok(sqlx::query_as("SELECT * FROM cron_jobs ORDER BY id")
165 .fetch_all(pool)
166 .await?)
167}
168
169#[allow(dead_code)]
170pub async fn list_active_cron_jobs(pool: &DbPool) -> Result<Vec<CronJob>> {
171 Ok(sqlx::query_as("SELECT * FROM cron_jobs WHERE active = 1")
172 .fetch_all(pool)
173 .await?)
174}
175
176pub async fn add_cron_job(
177 pool: &DbPool,
178 cron_expression: &str,
179 r#type: &str,
180 channel_id: i64,
181 audio_filename: Option<&str>,
182 message: Option<&str>,
183) -> Result<i64> {
184 let result = sqlx::query(
185 "INSERT INTO cron_jobs (cron_expression, type, channel_id, audio_filename, message) VALUES (?, ?, ?, ?, ?)",
186 )
187 .bind(cron_expression)
188 .bind(r#type)
189 .bind(channel_id)
190 .bind(audio_filename)
191 .bind(message)
192 .execute(pool)
193 .await?;
194 Ok(result.last_insert_rowid())
195}
196
197pub async fn delete_cron_job(pool: &DbPool, id: i64) -> Result<bool> {
198 let result = sqlx::query("DELETE FROM cron_jobs WHERE id = ?")
199 .bind(id)
200 .execute(pool)
201 .await?;
202 Ok(result.rows_affected() > 0)
203}
204
205pub async fn set_cron_job_active(pool: &DbPool, id: i64, active: bool) -> Result<bool> {
206 let result = sqlx::query("UPDATE cron_jobs SET active = ? WHERE id = ?")
207 .bind(if active { 1 } else { 0 })
208 .bind(id)
209 .execute(pool)
210 .await?;
211 Ok(result.rows_affected() > 0)
212}
213
214// -- Triggers --
215#[derive(Debug, Clone, FromRow)]
216pub struct Trigger {
217 pub id: i64,
218 pub name: String,
219 pub created_at: Option<DateTime<Utc>>,
220 pub last_updated: Option<DateTime<Utc>>,
221}
222
223#[allow(dead_code)]
224#[derive(Debug, Clone, FromRow)]
225pub struct TriggerKeyword {
226 pub id: i64,
227 pub trigger_id: i64,
228 pub keyword: String,
229}
230
231#[allow(dead_code)]
232#[derive(Debug, Clone, FromRow)]
233pub struct TriggerResponse {
234 pub id: i64,
235 pub trigger_id: i64,
236 pub response_text: String,
237}
238
239pub async fn create_trigger(pool: &DbPool, name: &str) -> Result<i64> {
240 let result = sqlx::query("INSERT INTO triggers (name) VALUES (?)")
241 .bind(name)
242 .execute(pool)
243 .await?;
244 Ok(result.last_insert_rowid())
245}
246
247pub async fn delete_trigger(pool: &DbPool, id: i64) -> Result<bool> {
248 let result = sqlx::query("DELETE FROM triggers WHERE id = ?")
249 .bind(id)
250 .execute(pool)
251 .await?;
252 Ok(result.rows_affected() > 0)
253}
254
255pub async fn get_trigger_by_name(pool: &DbPool, name: &str) -> Result<Option<Trigger>> {
256 Ok(sqlx::query_as("SELECT * FROM triggers WHERE name = ?")
257 .bind(name)
258 .fetch_optional(pool)
259 .await?)
260}
261
262pub async fn list_triggers(pool: &DbPool) -> Result<Vec<Trigger>> {
263 Ok(sqlx::query_as("SELECT * FROM triggers ORDER BY name")
264 .fetch_all(pool)
265 .await?)
266}
267
268pub async fn add_keyword(pool: &DbPool, trigger_id: i64, keyword: &str) -> Result<()> {
269 sqlx::query("INSERT OR IGNORE INTO trigger_keywords (trigger_id, keyword) VALUES (?, ?)")
270 .bind(trigger_id)
271 .bind(keyword)
272 .execute(pool)
273 .await?;
274 update_trigger_timestamp(pool, trigger_id).await
275}
276
277pub async fn remove_keyword(pool: &DbPool, trigger_id: i64, keyword: &str) -> Result<bool> {
278 let result = sqlx::query("DELETE FROM trigger_keywords WHERE trigger_id = ? AND keyword = ?")
279 .bind(trigger_id)
280 .bind(keyword)
281 .execute(pool)
282 .await?;
283 update_trigger_timestamp(pool, trigger_id).await?;
284 Ok(result.rows_affected() > 0)
285}
286
287pub async fn add_response(pool: &DbPool, trigger_id: i64, response: &str) -> Result<()> {
288 sqlx::query("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)")
289 .bind(trigger_id)
290 .bind(response)
291 .execute(pool)
292 .await?;
293 update_trigger_timestamp(pool, trigger_id).await
294}
295
296pub async fn remove_response(pool: &DbPool, trigger_id: i64, response: &str) -> Result<bool> {
297 let result =
298 sqlx::query("DELETE FROM trigger_responses WHERE trigger_id = ? AND response_text = ?")
299 .bind(trigger_id)
300 .bind(response)
301 .execute(pool)
302 .await?;
303 update_trigger_timestamp(pool, trigger_id).await?;
304 Ok(result.rows_affected() > 0)
305}
306
307pub async fn get_keywords(pool: &DbPool, trigger_id: i64) -> Result<Vec<String>> {
308 let rows: Vec<(String,)> =
309 sqlx::query_as("SELECT keyword FROM trigger_keywords WHERE trigger_id = ?")
310 .bind(trigger_id)
311 .fetch_all(pool)
312 .await?;
313 Ok(rows.into_iter().map(|r| r.0).collect())
314}
315
316pub async fn get_responses(pool: &DbPool, trigger_id: i64) -> Result<Vec<String>> {
317 let rows: Vec<(String,)> =
318 sqlx::query_as("SELECT response_text FROM trigger_responses WHERE trigger_id = ?")
319 .bind(trigger_id)
320 .fetch_all(pool)
321 .await?;
322 Ok(rows.into_iter().map(|r| r.0).collect())
323}
324
325pub async fn load_all_triggers_with_keywords_and_responses(
326 pool: &DbPool,
327) -> Result<Vec<(Trigger, Vec<String>, Vec<String>)>> {
328 let triggers = list_triggers(pool).await?;
329 let mut result = Vec::new();
330 for t in triggers {
331 let keywords = get_keywords(pool, t.id).await?;
332 let responses = get_responses(pool, t.id).await?;
333 result.push((t, keywords, responses));
334 }
335 Ok(result)
336}
337
338async fn update_trigger_timestamp(pool: &DbPool, id: i64) -> Result<()> {
339 sqlx::query("UPDATE triggers SET last_updated = CURRENT_TIMESTAMP WHERE id = ?")
340 .bind(id)
341 .execute(pool)
342 .await?;
343 Ok(())
344}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..d90d2e9
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,108 @@
1use std::fmt;
2
3#[derive(Debug)]
4pub enum Error {
5 Sqlx(sqlx::Error),
6 Reqwest(reqwest::Error),
7 Serenity(serenity::Error),
8 SongbirdJoin(songbird::error::JoinError),
9 SongbirdControl(songbird::tracks::ControlError),
10 Io(std::io::Error),
11 Cron(cron::error::Error),
12 Image(image::ImageError),
13 Qr(qrcode::types::QrError),
14 Scraper(String),
15 Config(String),
16 TaskJoin(String),
17 Custom(String),
18}
19
20impl fmt::Display for Error {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 use crate::lang;
23 match self {
24 Self::Sqlx(e) => write!(f, "{}: {e}", lang::L_ERR_DATABASE),
25 Self::Reqwest(e) => write!(f, "{}: {e}", lang::L_ERR_HTTP),
26 Self::Serenity(e) => write!(f, "{}: {e}", lang::L_ERR_DISCORD),
27 Self::SongbirdJoin(e) => write!(f, "{}: {e}", lang::L_ERR_VOICE_JOIN),
28 Self::SongbirdControl(e) => write!(f, "{}: {e}", lang::L_ERR_VOICE_CONTROL),
29 Self::Io(e) => write!(f, "{}: {e}", lang::L_ERR_IO),
30 Self::Cron(e) => write!(f, "{}: {e}", lang::L_ERR_CRON),
31 Self::Image(e) => write!(f, "{}: {e}", lang::L_ERR_IMAGE),
32 Self::Qr(e) => write!(f, "{}: {e}", lang::L_ERR_QR),
33 Self::Scraper(e) => write!(f, "{}: {e}", lang::L_ERR_SCRAPER),
34 Self::Config(e) => write!(f, "{}: {e}", lang::L_ERR_CONFIG),
35 Self::TaskJoin(e) => write!(f, "{}: {e}", lang::L_ERR_TASK_JOIN),
36 Self::Custom(e) => write!(f, "{e}"),
37 }
38 }
39}
40
41impl std::error::Error for Error {
42 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
43 match self {
44 Self::Sqlx(e) => Some(e),
45 Self::Reqwest(e) => Some(e),
46 Self::Serenity(e) => Some(e),
47 Self::SongbirdJoin(e) => Some(e),
48 Self::SongbirdControl(e) => Some(e),
49 Self::Io(e) => Some(e),
50 Self::Image(e) => Some(e),
51 Self::Qr(e) => Some(e),
52 _ => None,
53 }
54 }
55}
56
57impl From<sqlx::Error> for Error {
58 fn from(e: sqlx::Error) -> Self {
59 Self::Sqlx(e)
60 }
61}
62impl From<reqwest::Error> for Error {
63 fn from(e: reqwest::Error) -> Self {
64 Self::Reqwest(e)
65 }
66}
67impl From<serenity::Error> for Error {
68 fn from(e: serenity::Error) -> Self {
69 Self::Serenity(e)
70 }
71}
72impl From<songbird::error::JoinError> for Error {
73 fn from(e: songbird::error::JoinError) -> Self {
74 Self::SongbirdJoin(e)
75 }
76}
77impl From<songbird::tracks::ControlError> for Error {
78 fn from(e: songbird::tracks::ControlError) -> Self {
79 Self::SongbirdControl(e)
80 }
81}
82impl From<std::io::Error> for Error {
83 fn from(e: std::io::Error) -> Self {
84 Self::Io(e)
85 }
86}
87impl From<cron::error::Error> for Error {
88 fn from(e: cron::error::Error) -> Self {
89 Self::Cron(e)
90 }
91}
92impl From<image::ImageError> for Error {
93 fn from(e: image::ImageError) -> Self {
94 Self::Image(e)
95 }
96}
97impl From<qrcode::types::QrError> for Error {
98 fn from(e: qrcode::types::QrError) -> Self {
99 Self::Qr(e)
100 }
101}
102impl From<tokio::task::JoinError> for Error {
103 fn from(e: tokio::task::JoinError) -> Self {
104 Self::TaskJoin(e.to_string())
105 }
106}
107
108pub type Result<T> = std::result::Result<T, Error>;
diff --git a/src/events/mod.rs b/src/events/mod.rs
new file mode 100644
index 0000000..457ebcd
--- /dev/null
+++ b/src/events/mod.rs
@@ -0,0 +1,368 @@
1use chrono::{Local, Utc};
2use poise::serenity_prelude as serenity;
3use rand::seq::IndexedRandom;
4use tracing::{info, warn};
5
6use crate::Data;
7use crate::database::{self, VoiceActivity};
8use crate::error::Result;
9use crate::lang;
10
11pub async fn handle_ready(_ctx: &serenity::Context) -> Result<()> {
12 info!("{}", lang::L_EV_READY);
13 Ok(())
14}
15
16fn format_duration(seconds: f64) -> String {
17 let total = seconds as u64;
18 let hours = total / 3600;
19 let minutes = (total % 3600) / 60;
20 let secs = total % 60;
21 if hours > 0 {
22 format!("{hours}h {minutes}m {secs}s")
23 } else if minutes > 0 {
24 format!("{minutes}m {secs}s")
25 } else {
26 format!("{secs}s")
27 }
28}
29
30async fn play_tts_in_channel(
31 _ctx: &serenity::Context,
32 data: &Data,
33 guild_id: serenity::GuildId,
34 channel_id: serenity::ChannelId,
35 text: &str,
36) {
37 let tts_url = format!(
38 "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob",
39 crate::commands::tts::urlencoding(text),
40 );
41
42 let audio_data = match data.http_client.get(&tts_url).send().await {
43 Ok(resp) => match resp.bytes().await {
44 Ok(b) => b,
45 Err(e) => {
46 warn!("Failed to read TTS audio bytes: {e}");
47 return;
48 }
49 },
50 Err(e) => {
51 warn!("Failed to fetch TTS audio: {e}");
52 return;
53 }
54 };
55
56 let call = match data.voice_manager.connect(guild_id, channel_id).await {
57 Ok(c) => c,
58 Err(e) => {
59 warn!("Failed to connect to voice for TTS: {e}");
60 return;
61 }
62 };
63
64 let input = match crate::voice::decode_audio(&audio_data) {
65 Ok(i) => i,
66 Err(e) => {
67 warn!("Failed to decode TTS audio: {e}");
68 return;
69 }
70 };
71
72 let mut call_lock = call.lock().await;
73 if call_lock.current_channel().is_some() {
74 call_lock.play_input(input);
75 }
76}
77
78pub async fn handle_voice_state_update(
79 ctx: &serenity::Context,
80 data: &Data,
81 old: Option<serenity::VoiceState>,
82 new: &serenity::VoiceState,
83) -> Result<()> {
84 let log_channel = data.config.status_channel_id.map(serenity::ChannelId::new);
85 let current_ts = Utc::now().timestamp();
86 let current_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
87
88 let user_id = new.user_id;
89 let guild_id = new.guild_id;
90
91 // skip bots (Python behavior)
92 let member = guild_id
93 .and_then(|gid| ctx.cache.guild(gid))
94 .and_then(|g| g.members.get(&user_id).cloned());
95 if let Some(ref m) = member {
96 if m.user.bot {
97 return Ok(());
98 }
99 }
100
101 let member_name = member
102 .as_ref()
103 .map(|m| m.user.name.clone())
104 .unwrap_or_default();
105 let display_name = member
106 .as_ref()
107 .map(|m| m.display_name().to_string())
108 .unwrap_or_default();
109
110 let now = current_ts as f64;
111
112 let user_id_str = user_id.to_string();
113 let mut va = database::get_voice_activity(&data.db_pool, &user_id_str)
114 .await?
115 .unwrap_or(VoiceActivity {
116 user_id: user_id_str.clone(),
117 join_time: 0.0,
118 total_active: 0.0,
119 total_afk: 0.0,
120 is_afk: 0,
121 afk_start: 0.0,
122 active_start: 0.0,
123 channel_name: String::new(),
124 last_updated: None,
125 });
126
127 let old_channel = old.as_ref().and_then(|v| v.channel_id);
128 let new_channel = new.channel_id;
129
130 let mut tts_text: Option<String> = None;
131 let mut tts_channel: Option<serenity::ChannelId> = None;
132
133 // ── JOIN ──
134 if old_channel.is_none() && new_channel.is_some() {
135 let cid = new_channel.unwrap();
136 let is_afk = new.self_mute || new.self_deaf;
137
138 va.join_time = now;
139 va.total_active = 0.0;
140 va.total_afk = 0.0;
141 va.is_afk = if is_afk { 1 } else { 0 };
142 va.afk_start = if is_afk { now } else { 0.0 };
143 va.active_start = if !is_afk { now } else { 0.0 };
144 va.channel_name = guild_id
145 .and_then(|gid| ctx.cache.guild(gid))
146 .map(|g| {
147 g.channels
148 .get(&cid)
149 .map(|c| c.name.clone())
150 .unwrap_or_default()
151 })
152 .unwrap_or_default();
153 database::upsert_voice_activity(&data.db_pool, &va).await?;
154 info!("User {user_id} joined voice");
155
156 if let Some(lc) = log_channel {
157 let msg = format!(
158 "[{current_time}] {member_name} dołączył na {} <t:{current_ts}:R>",
159 va.channel_name
160 );
161 lc.say(ctx, &msg).await.ok();
162 }
163 tts_text = Some(lang::fmt_ev_joined(&display_name));
164 tts_channel = Some(cid);
165 }
166 // ── LEAVE ──
167 else if old_channel.is_some() && new_channel.is_none() {
168 let ocid = old_channel.unwrap();
169 let channel_name = guild_id
170 .and_then(|gid| ctx.cache.guild(gid))
171 .and_then(|g| g.channels.get(&ocid).map(|c| c.name.clone()))
172 .unwrap_or_default();
173
174 if va.join_time > 0.0 {
175 if va.is_afk == 1 {
176 va.total_afk += now - va.afk_start;
177 } else {
178 va.total_active += (now - va.active_start).max(0.0);
179 }
180
181 let active_str = format_duration(va.total_active);
182 let afk_str = format_duration(va.total_afk);
183
184 if let Some(lc) = log_channel {
185 let msg = format!(
186 "[{current_time}] {member_name} rozłączył się z {} <t:{current_ts}:R>\n\
187 Aktywny: {active_str} | AFK: {afk_str}",
188 channel_name,
189 );
190 lc.say(ctx, &msg).await.ok();
191 }
192 info!("User {user_id} left voice");
193
194 database::upsert_voice_activity(&data.db_pool, &va).await?;
195 } else {
196 if let Some(lc) = log_channel {
197 let msg = format!(
198 "[{current_time}] {member_name} rozłączył się z {channel_name} <t:{current_ts}:R>"
199 );
200 lc.say(ctx, &msg).await.ok();
201 }
202 info!("User {user_id} left voice (no data)");
203 }
204
205 tts_text = Some(lang::fmt_ev_left(&display_name));
206 tts_channel = Some(ocid);
207 }
208 // ── AFK STATE CHANGE (only for tracked users already in channel) ──
209 else if new_channel.is_some() && va.join_time > 0.0 {
210 let is_afk = new.self_mute || new.self_deaf;
211 let was_afk = va.is_afk == 1;
212
213 if was_afk && !is_afk {
214 // Return from AFK
215 let afk_duration = now - va.afk_start;
216 va.total_afk += afk_duration;
217 va.is_afk = 0;
218 va.afk_start = 0.0;
219 va.active_start = now;
220 database::upsert_voice_activity(&data.db_pool, &va).await?;
221
222 let afk_str = format_duration(afk_duration);
223 // Python behavior: after.self_mute is always false here so always "odgłuchnął"
224 let activity = if new.self_mute {
225 "odciszył"
226 } else {
227 "odgłuchnął"
228 };
229
230 if let Some(lc) = log_channel {
231 let msg = format!(
232 "[{current_time}] {member_name} {activity} się na {} <t:{current_ts}:R>\n\
233 AFK: {afk_str}",
234 va.channel_name,
235 );
236 lc.say(ctx, &msg).await.ok();
237 }
238 info!("User {user_id} returned from AFK");
239 } else if !was_afk && is_afk {
240 // Went AFK
241 let active_duration = if va.active_start > 0.0 {
242 (now - va.active_start).max(0.0)
243 } else {
244 0.0
245 };
246 va.total_active += active_duration;
247 va.is_afk = 1;
248 va.afk_start = now;
249 va.active_start = 0.0;
250 database::upsert_voice_activity(&data.db_pool, &va).await?;
251
252 let active_str = format_duration(active_duration);
253 let activity = if new.self_mute {
254 "wyciszył"
255 } else {
256 "ogłuchnął"
257 };
258
259 if let Some(lc) = log_channel {
260 let msg = format!(
261 "[{current_time}] {member_name} {activity} się na {} <t:{current_ts}:R>\n\
262 Aktywny: {active_str}",
263 va.channel_name,
264 );
265 lc.say(ctx, &msg).await.ok();
266 }
267 info!("User {user_id} went AFK");
268 }
269 }
270
271 // ── TTS for mute/deafen (deaf takes priority with early return) ──
272 if old.as_ref().map_or(false, |v| v.self_deaf) != new.self_deaf {
273 let phrase = if new.self_deaf {
274 lang::fmt_ev_deafened(&display_name)
275 } else {
276 lang::fmt_ev_undeafened(&display_name)
277 };
278 if let Some(gid) = guild_id {
279 if let Some(cid) = tts_channel.or(new_channel) {
280 play_tts_in_channel(ctx, data, gid, cid, &phrase).await;
281 }
282 }
283 return Ok(());
284 }
285
286 if old.as_ref().map_or(false, |v| v.self_mute) != new.self_mute {
287 let phrase = if new.self_mute {
288 lang::fmt_ev_muted(&display_name)
289 } else {
290 lang::fmt_ev_unmuted(&display_name)
291 };
292 if let Some(gid) = guild_id {
293 if let Some(cid) = tts_channel.or(new_channel) {
294 play_tts_in_channel(ctx, data, gid, cid, &phrase).await;
295 }
296 }
297 return Ok(());
298 }
299
300 // ── Play join/leave TTS if no mute/deafen change ──
301 if let (Some(text), Some(cid)) = (tts_text, tts_channel) {
302 if let Some(gid) = guild_id {
303 play_tts_in_channel(ctx, data, gid, cid, &text).await;
304 }
305 }
306
307 Ok(())
308}
309
310pub async fn handle_message(
311 ctx: &serenity::Context,
312 data: &Data,
313 new_message: &serenity::Message,
314) -> Result<()> {
315 if new_message.author.id == ctx.cache.current_user().id {
316 return Ok(());
317 }
318
319 let content_lower = new_message.content.to_lowercase();
320 let response_to_send = {
321 let triggers = data.triggers.lock().await;
322 let mut chosen = None;
323 for (_trigger, keywords, responses) in triggers.iter() {
324 if keywords.iter().any(|k| content_lower.contains(k)) {
325 chosen = responses.choose(&mut rand::rng()).cloned();
326 break;
327 }
328 }
329 chosen
330 };
331 if let Some(response) = response_to_send {
332 new_message.channel_id.say(ctx, response).await.ok();
333 }
334
335 let is_mention = new_message.mentions_user_id(ctx.cache.current_user().id);
336 let has_keyword = content_lower.contains(&data.config.assistant_keyword);
337
338 if (is_mention || has_keyword) && data.config.openrouter_api_key.is_some() {
339 let api_key = data.config.openrouter_api_key.clone().unwrap();
340 let model = data.config.openrouter_model.clone().unwrap_or_else(|| {
341 "cognitivecomputations/dolphin-mistral-24b-venice-edition:free".into()
342 });
343
344 let client = crate::openrouter::OpenRouterClient::new(api_key, model);
345 let assistant_prompt = lang::ASSISTANT_SYSTEM_PROMPT;
346
347 let mut messages = Vec::new();
348 let mut current_msg = Some(new_message.clone());
349 for _ in 0..5 {
350 if let Some(msg) = current_msg {
351 messages.push(("user".into(), msg.content.clone()));
352 current_msg = match msg.message_reference.and_then(|r| r.message_id) {
353 Some(ref_id) => msg.channel_id.message(ctx, ref_id).await.ok(),
354 None => None,
355 };
356 } else {
357 break;
358 }
359 }
360 messages.reverse();
361
362 if let Ok(response) = client.chat_with_history(assistant_prompt, messages).await {
363 new_message.reply(ctx, response).await.ok();
364 }
365 }
366
367 Ok(())
368}
diff --git a/src/lang.rs b/src/lang.rs
new file mode 100644
index 0000000..626f387
--- /dev/null
+++ b/src/lang.rs
@@ -0,0 +1,387 @@
1// ┌──────────────────────────────────────────────────────────────────┐
2// │ lang.rs — All user-facing messages live here. │
3// │ Translate the bot by editing this single file. │
4// │ Naming: L_<CAT>_<NAME> for simple &str; │
5// │ fn fmt_<cat>_<name>(...) -> String for parameterized. │
6// └──────────────────────────────────────────────────────────────────┘
7
8// ── Error display prefixes (used in error.rs Display impl) ────────────
9
10pub const L_ERR_DATABASE: &str = "Database error";
11pub const L_ERR_HTTP: &str = "HTTP error";
12pub const L_ERR_DISCORD: &str = "Discord error";
13pub const L_ERR_VOICE_JOIN: &str = "Voice join error";
14pub const L_ERR_VOICE_CONTROL: &str = "Voice control error";
15pub const L_ERR_IO: &str = "IO error";
16pub const L_ERR_CRON: &str = "Cron error";
17pub const L_ERR_IMAGE: &str = "Image error";
18pub const L_ERR_QR: &str = "QR error";
19pub const L_ERR_SCRAPER: &str = "Scraper error";
20pub const L_ERR_CONFIG: &str = "Config error";
21pub const L_ERR_TASK_JOIN: &str = "Task join error";
22
23// ── Common / shared error messages ────────────────────────────────────
24
25/// "Nie jestem w gildii" — bot must be invoked inside a guild
26pub const L_NOT_IN_GUILD: &str = "Nie jestem w gildii";
27/// "Nie jesteś na kanale głosowym" — user must be in a voice channel
28pub const L_NOT_IN_VOICE: &str = "Nie jesteś na kanale głosowym";
29/// "Zły typ kanału" — wrong channel variant
30pub const L_BAD_CHANNEL_TYPE: &str = "Zły typ kanału";
31/// "Grupa triggerów nie znaleziona" — trigger group not found
32pub const L_TRIGGER_NOT_FOUND: &str = "Grupa triggerów nie znaleziona";
33
34// ── AI / senator command ──────────────────────────────────────────────
35
36pub const SENATOR_SYSTEM_PROMPT: &str = "Jesteś Senatorem Steavenem Sperma Armstrongiem. Jesteś rasistą, seksistą i homofobem. Odpowiadasz w języku polskim. Odpowiedź ma maksymalnie 100 słów. Nie używaj formatowania markdown.";
37
38pub const L_OPENROUTER_NO_KEY: &str = "Klucz API OpenRouter nie jest skonfigurowany w .env";
39
40// ── Cron command ──────────────────────────────────────────────────────
41
42pub const L_CRON_QUEUED: &str = "Praca cron zakolejkowana";
43pub const L_CRON_NONE: &str = "Brak zakolejkowanych prac cron";
44pub const L_CRON_TITLE: &str = "Prace cron";
45pub const L_CRON_ACTIVE: &str = "✅ Aktywna";
46pub const L_CRON_PAUSED: &str = "⏸️ Zapauzowana";
47
48pub fn fmt_cron_audio_queued(cron: &str) -> String {
49 format!("Praca audio zakolejkowana z cron'em `{cron}`")
50}
51pub fn fmt_cron_label_audio(name: &str) -> String {
52 format!("Audio: {name}")
53}
54pub fn fmt_cron_label_msg(msg: &str) -> String {
55 format!("Wiadomość: {msg}")
56}
57pub fn fmt_cron_job_status(id: i64, status: &str) -> String {
58 format!("ID {id} — {status}")
59}
60pub fn fmt_cron_field(cron: &str, chan: i64, desc: &str) -> String {
61 format!("`{cron}` — <#{chan}>\n{desc}")
62}
63pub fn fmt_cron_deleted(id: i64) -> String {
64 format!("Praca cron z ID {id} została usunięta")
65}
66pub fn fmt_cron_not_found(id: i64) -> String {
67 format!("Praca z ID {id} nie została znaleziona")
68}
69pub fn fmt_cron_paused(id: i64) -> String {
70 format!("Praca cron z ID {id} została zapauzowana")
71}
72pub fn fmt_cron_resumed(id: i64) -> String {
73 format!("Praca cron z ID {id} została wznowiona")
74}
75
76// ── Math command ──────────────────────────────────────────────────────
77
78pub const L_MATH_TOO_LONG: &str = "Ekspresja za długa (max 1000 znaków";
79pub const L_MATH_NOT_ASCII: &str = "Tylko znaki ASCII dozwolone";
80pub const L_MATH_UNBALANCED: &str = "Nawiasy są niezbalansowane";
81pub const L_MATH_DANGEROUS: &str = "Ekspresja zawiera groźne słowa";
82pub const L_MATH_NO_RESULT: &str = "Brak rezultatu";
83
84pub fn fmt_math_qalc_err(e: &dyn std::fmt::Display) -> String {
85 format!("qalc nie jest dostępny: {e}")
86}
87pub fn fmt_math_result(r: &str) -> String {
88 format!("```\n{r}\n```")
89}
90
91// ── Music command ─────────────────────────────────────────────────────
92
93pub const L_MUSIC_NEED_NAME: &str = "Daj mi nazwę pliku, URL albo plik";
94pub const L_MUSIC_NO_AUDIO_ON_CP: &str = "Brak powiązanego audio na copyparty";
95pub const L_MUSIC_NO_URL_IN_RESULT: &str = "Wynik nie ma URL";
96pub const L_MUSIC_CANT_COMPRESS_LIVE: &str =
97 "Nie można skompresować transmisji na żywo, odtwarzam normalnie";
98pub const L_MUSIC_STOPPED: &str = "wyszed";
99pub const L_VOICE_USER_NOT_IN_VC: &str = "You are not in a voice channel";
100
101pub fn fmt_music_search_failed(e: &dyn std::fmt::Display) -> String {
102 format!("Szukanie na copyparty zawiodło: {e}")
103}
104pub fn fmt_music_playing(name: &str) -> String {
105 format!("Grajcowanie: {name}")
106}
107pub fn fmt_voice_joined(cid: &dyn std::fmt::Display) -> String {
108 format!("Dołączon <#{cid}>")
109}
110pub fn fmt_voice_join_failed(e: &dyn std::fmt::Display) -> String {
111 format!("Nie udało się połączyć z VC: {e}")
112}
113
114// ── TTS command ───────────────────────────────────────────────────────
115
116pub const L_TTS_SAVED: &str = "TTS zapisany";
117pub const L_TTS_PLAYING: &str = "Grom TTS na kanale głosowym";
118
119// ── Trigger command ───────────────────────────────────────────────────
120
121pub const L_TRIGGER_UPDATED: &str = "Trigger zaktualizowany";
122pub const L_TRIGGER_KW_NONE: &str = "Brak";
123
124pub fn fmt_trigger_created(name: &str) -> String {
125 format!("Grupa trigger '{name}' utworzona")
126}
127pub fn fmt_trigger_failed(e: &dyn std::fmt::Display) -> String {
128 format!("Nie udało się: {e}")
129}
130pub fn fmt_trigger_deleted(name: &str) -> String {
131 format!("Grupa trigger '{name}' usunięta")
132}
133pub fn fmt_trigger_title(name: &str) -> String {
134 format!("Trigger: {name}")
135}
136
137// ── Utility commands ──────────────────────────────────────────────────
138
139pub const L_UTIL_NEED_INPUT: &str = "Podaj plik z copyparty lub załącznik";
140pub const L_UTIL_PICK_FROM_AUTOCOMPLETE: &str = "Wybierz plik z podpowiedzi";
141pub const L_UTIL_DOWNLOAD_FAILED: &str = "Nie udało się pobrać pliku";
142pub const L_UTIL_UNSUPPORTED_TYPE: &str = "Nieobsługiwany typ pliku";
143pub const L_UTIL_COMPRESS_FAILED: &str = "Kompresja nie powiodła się";
144pub const L_UTIL_CONVERT_FAILED: &str = "Konwersja nie powiodła się";
145pub const L_UTIL_UPLOAD_FAILED: &str = "Nie udało się wysłać na serwer";
146pub const L_UTIL_NO_ATTACHMENTS: &str = "Brak załączników w wiadomości";
147pub const L_SEARCH_NO_RESULTS: &str = "No results found";
148
149pub fn fmt_util_compressed(url: &str) -> String {
150 format!("Skompresowano\n{url}")
151}
152pub fn fmt_util_converted(url: &str) -> String {
153 format!("Skonwertowano\n{url}")
154}
155pub fn fmt_util_convert_err(stderr: &str) -> String {
156 format!("Błąd konwersji: {stderr}")
157}
158
159pub fn fmt_uploaded(url: &str) -> String {
160 format!("Wysłon: {url}")
161}
162pub fn fmt_download_uploaded(url: &str) -> String {
163 format!("pobioron i wysyłon: {url}")
164}
165pub fn fmt_upload_summary(count: usize) -> String {
166 format!("Wysłano {count} plików:\n")
167}
168pub fn fmt_upload_partial_failed(count: usize) -> String {
169 format!("\n\nNie wysłano {count} plików")
170}
171pub fn fmt_message_compressed(made: usize, total: usize) -> String {
172 format!("Skompresowano {made} z {total} plików:")
173}
174pub fn fmt_search_result(name: &str, url: &str) -> String {
175 format!("**{name}**\n{url}")
176}
177pub fn fmt_download_failed(stderr: &str) -> String {
178 format!(
179 "Pobieranie się nie powiodło: {}",
180 translate_ytdlp_stderr(stderr)
181 )
182}
183pub fn fmt_ytdlp_err(e: &dyn std::fmt::Display) -> String {
184 format!("yt-dlp nie mógło się uruchomić: {e}")
185}
186pub fn fmt_ytdlp_stderr(stderr: &str) -> String {
187 translate_ytdlp_stderr(stderr)
188}
189
190/// Translate yt-dlp's verbose stderr into a short, user-friendly Polish
191/// message. yt-dlp emits lots of `[youtube] Extracting URL…`, `WARNING:`
192/// and `ERROR:` noise that is meaningless to a normal Discord user; this
193/// recognises the handful of failure modes users actually hit and falls
194/// back to the first `ERROR:`/`WARNING:` line (stripped of the
195/// `ERROR: [extractor] id: ` prefix) when nothing matches, so we never
196/// show the full multi-line trace.
197pub fn translate_ytdlp_stderr(stderr: &str) -> String {
198 let lower = stderr.to_ascii_lowercase();
199
200 // YouTube's anti-bot sign-in wall. yt-dlp can't bypass this without
201 // browser cookies configured on the host — surface that to the user
202 // rather than the cryptic "Sign in to confirm you're not a bot" line.
203 if lower.contains("sign in to confirm you")
204 || lower.contains("confirm you're not a bot")
205 || lower.contains("confirm you are not a bot")
206 {
207 return "YouTube uznaje nas za bota i blokuje pobieranie. Spróbuj ponownie później albo podaj inny link.".into();
208 }
209
210 // Generic extractor fallback means the URL didn't match any known
211 // site — combined with "Requested format is not available" this is
212 // almost always "the user pasted a non-video URL into /download".
213 if lower.contains("requested format is not available")
214 || lower.contains("url could be a direct video link")
215 || lower.contains("falling back on generic information extractor")
216 {
217 return "Ten link nie wygląda na wideo obsługiwane przez yt-dlp. Upewnij się, że to adres do filmu (YouTube / SoundCloud / Bandcamp / Twitch).".into();
218 }
219
220 if lower.contains("private video") {
221 return "Ten film jest prywatny i nie można go pobrać.".into();
222 }
223 if lower.contains("video unavailable")
224 || lower.contains("video has been removed")
225 || lower.contains("this video is unavailable")
226 {
227 return "Ten film jest niedostępny lub został usunięty.".into();
228 }
229 if lower.contains("members-only")
230 || lower.contains("premium-only")
231 || lower.contains("channel's members")
232 {
233 return "Ten film jest dostępny tylko dla subskrybujących / członków kanału.".into();
234 }
235 if lower.contains("not available in your country")
236 || lower.contains("geo-restricted")
237 || lower.contains("geo restricted")
238 {
239 return "Ten film jest zablokowany regionalnie i nie jest dostępny w naszym regionie."
240 .into();
241 }
242 if lower.contains("age-restricted")
243 || lower.contains("age restricted")
244 || lower.contains("inappropriate for some users")
245 {
246 return "Ten film jest ograniczony wiekowo i wymaga uwierzytelnienia.".into();
247 }
248 if lower.contains("http error 404") || lower.contains(" http error 404 ") {
249 return "Nie znaleziono takiego adresu (404).".into();
250 }
251 if lower.contains("unable to extract") && lower.contains("video data") {
252 return "Nie udało się wyciągnąć danych filmu — strona mogła się zmienić albo link jest niepełny.".into();
253 }
254
255 // Fallback: keep only the first ERROR: line and strip the
256 // `ERROR: [extractor] id: ` prefix so the message is short and
257 // free of yt-dlp internals.
258 for line in stderr.lines() {
259 if let Some(rest) = line.strip_prefix("ERROR:") {
260 let rest = rest.trim();
261 // Drop leading `[extractor] video_id: ` noise if present.
262 let msg = rest
263 .strip_prefix(|c: char| c == '[')
264 .and_then(|r| r.find(']').map(|i| &r[i + 1..]))
265 .map(|s| {
266 s.trim_start_matches(|c: char| {
267 c.is_alphanumeric() || c == '_' || c == '-' || c == ':'
268 })
269 .trim()
270 })
271 .filter(|s| !s.is_empty())
272 .unwrap_or(rest);
273 return format!("Pobieranie się nie powiodło: {msg}");
274 }
275 }
276 for line in stderr.lines() {
277 if let Some(rest) = line.strip_prefix("WARNING:") {
278 return format!("Pobieranie się nie powiodło: {}", rest.trim());
279 }
280 }
281
282 "Pobieranie się nie powiodło.".into()
283}
284
285// ── Web command ───────────────────────────────────────────────────────
286
287pub const L_WEB_NO_RESULTS: &str = "Brak rezultatów";
288
289pub fn fmt_web_title(query: &str) -> String {
290 format!("Wyniki wyszukiwania dla: {query}")
291}
292
293// ── QR command ────────────────────────────────────────────────────────
294
295pub const L_QR_GENERATED: &str = "Kod QR wygenerowany:";
296pub const L_QR_DECODE_FAILED: &str = "Nie udało się zdekodować kodu QR.";
297
298pub fn fmt_qr_decoded(text: &str) -> String {
299 format!("```\n{text}\n```")
300}
301
302// ── Copyparty client ──────────────────────────────────────────────────
303
304pub const L_CP_UPLOAD_NO_URL: &str = "Wysłanie zawiodło, brak URL w odpowiedzi";
305
306pub fn fmt_cp_search_http(status: &dyn std::fmt::Display, snippet: &str) -> String {
307 format!("copyparty zwróciło status HTTP {status}: {snippet}")
308}
309pub fn fmt_cp_invalid_json(e: &dyn std::fmt::Display, snippet: &str) -> String {
310 format!("copyparty zwróciło nielegalny JSON. ({e}): {snippet}")
311}
312
313// ── OpenRouter ────────────────────────────────────────────────────────
314
315pub fn fmt_openrouter_err(status: &dyn std::fmt::Display, text: &str) -> String {
316 format!("Błąd API OpenRouter. {status}: {text}")
317}
318
319// ── Voice / audio decoding ────────────────────────────────────────────
320
321pub const L_VOICE_NO_TRACK: &str = "Brak kawałka";
322pub const L_VOICE_NO_CODEC: &str = "Brak parametrów codec'a audio";
323
324pub fn fmt_voice_remove_failed(e: &dyn std::fmt::Display) -> String {
325 format!("Nie udało się usunąć klienta głosowego: {e}")
326}
327pub fn fmt_voice_probe_failed(e: &dyn std::fmt::Display) -> String {
328 format!("Próbkowanie symphonia zawiodło: {e}")
329}
330pub fn fmt_voice_decoder_failed(e: &dyn std::fmt::Display) -> String {
331 format!("Nie udało się utworzyć dekodera audio: {e}")
332}
333pub fn fmt_voice_ytdlp_launch(e: &dyn std::fmt::Display) -> String {
334 format!("yt-dlp nie mogło się uruchomić: {e}")
335}
336pub fn fmt_voice_ffmpeg_launch(e: &dyn std::fmt::Display) -> String {
337 format!("ffmpeg nie mogło się uruchomić: {e}")
338}
339
340// ── Events ────────────────────────────────────────────────────────────
341
342pub const L_EV_READY: &str = "ŁUKASZ SPERMA AKTYWACJA";
343pub const L_EV_VOICE_STATE_ERROR: &str = "Błąd stanu głosu";
344pub const L_EV_MESSAGE_HANDLER_ERROR: &str = "Błąd ustanawiacza głosu";
345
346// ── Event TTS phrases ─────────────────────────────────────────────────
347
348pub fn fmt_ev_joined(name: &str) -> String {
349 format!("{name} sie zespawnił")
350}
351pub fn fmt_ev_left(name: &str) -> String {
352 format!("{name} jest chujem")
353}
354pub fn fmt_ev_deafened(name: &str) -> String {
355 format!("{name} jest bity przez ojca")
356}
357pub fn fmt_ev_undeafened(name: &str) -> String {
358 format!("{name} uciekł od ojca")
359}
360pub fn fmt_ev_muted(name: &str) -> String {
361 format!("{name} idzie robić jajecznice")
362}
363pub fn fmt_ev_unmuted(name: &str) -> String {
364 format!("{name} zrobił jajecznice")
365}
366
367// ── AI assistant (events/message handler) ─────────────────────────────
368
369pub const ASSISTANT_SYSTEM_PROMPT: &str =
370 "Jesteś asystentem. Odpowiadasz po polsku, jesteś pomocny, nie używasz emoji.";
371
372// ── Convenience helpers for repeated Error::Custom patterns ────────────
373
374use crate::error::Error;
375
376pub fn err_not_in_guild() -> Error {
377 Error::Custom(L_NOT_IN_GUILD.into())
378}
379pub fn err_not_in_voice() -> Error {
380 Error::Custom(L_NOT_IN_VOICE.into())
381}
382pub fn err_trigger_not_found() -> Error {
383 Error::Custom(L_TRIGGER_NOT_FOUND.into())
384}
385pub fn err_bad_channel_type() -> Error {
386 Error::Custom(L_BAD_CHANNEL_TYPE.into())
387}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..4a89315
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,190 @@
1use std::collections::HashMap;
2
3use poise::serenity_prelude as serenity;
4use songbird::SerenityInit;
5use tracing_subscriber::filter::EnvFilter;
6
7mod commands;
8mod config;
9mod copyparty;
10mod database;
11mod error;
12mod events;
13mod lang;
14mod openrouter;
15mod tasks;
16mod voice;
17
18use database::DbPool;
19
20pub struct Data {
21 pub db_pool: DbPool,
22 pub config: config::Config,
23 pub voice_manager: voice::VoiceManager,
24 pub copyparty: copyparty::CopypartyClient,
25 pub http_client: reqwest::Client,
26 pub volume: std::sync::atomic::AtomicU64,
27 pub triggers: tokio::sync::Mutex<Vec<(database::Trigger, Vec<String>, Vec<String>)>>,
28 /// Autocomplete lookup, keyed per-user to avoid cross-command collisions.
29 ///
30 /// Mirrors Python's per-cog `_cached_urls[user_id]` map: when two users
31 /// search the same filename simultaneously, each invocation must resolve its
32 /// own stashed `SearchHit`, so the cache is keyed by `(UserId, filename)`.
33 pub ac_cache: tokio::sync::Mutex<HashMap<(serenity::UserId, String), copyparty::SearchHit>>,
34 /// Active music track per guild (only one music source at a time).
35 pub music_tracks: tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>,
36}
37
38impl Data {
39 pub async fn reload_triggers(&self) {
40 let triggers = database::load_all_triggers_with_keywords_and_responses(&self.db_pool)
41 .await
42 .unwrap_or_default();
43 *self.triggers.lock().await = triggers;
44 }
45
46 pub async fn ac_stash(
47 &self,
48 user_id: serenity::UserId,
49 key: String,
50 hit: copyparty::SearchHit,
51 ) {
52 self.ac_cache.lock().await.insert((user_id, key), hit);
53 }
54
55 pub async fn ac_lookup(
56 &self,
57 user_id: serenity::UserId,
58 key: &str,
59 ) -> Option<copyparty::SearchHit> {
60 self.ac_cache
61 .lock()
62 .await
63 .get(&(user_id, key.to_string()))
64 .cloned()
65 }
66}
67
68#[allow(dead_code)]
69type Error = error::Error;
70
71#[tokio::main]
72async fn main() {
73 let _ = rustls::crypto::ring::default_provider().install_default();
74
75 tracing_subscriber::fmt()
76 .with_env_filter(
77 EnvFilter::builder()
78 .parse(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
79 .unwrap(),
80 )
81 .init();
82
83 dotenvy::dotenv().ok();
84 config::check_env_vars();
85 let config = config::Config::from_env();
86
87 let db_pool = database::init_pool(&config.database_path)
88 .await
89 .expect("Failed to initialize database");
90
91 let http_client = reqwest::Client::new();
92
93 let copyparty = copyparty::CopypartyClient::new(
94 config.copyparty_url.clone(),
95 config.copyparty_url_upload.clone(),
96 config.copyparty_password.clone(),
97 );
98
99 let triggers = database::load_all_triggers_with_keywords_and_responses(&db_pool)
100 .await
101 .unwrap_or_default();
102
103 let commands_list = commands::all_commands();
104 let config_for_client = config.clone();
105
106 let framework = poise::Framework::builder()
107 .options(poise::FrameworkOptions {
108 commands: commands_list,
109 event_handler: |ctx, event, _framework, user_data| {
110 Box::pin(async move {
111 handle_event(ctx, event, user_data).await;
112 Ok(())
113 })
114 },
115 ..Default::default()
116 })
117 .setup(move |ctx, _ready, framework| {
118 let config = config.clone();
119 let db_pool = db_pool.clone();
120 let http_client = http_client.clone();
121 let copyparty = copyparty.clone();
122 let triggers = triggers.clone();
123 Box::pin(async move {
124 let vm = voice::VoiceManager::new(ctx).await;
125
126 let data = Data {
127 config,
128 db_pool,
129 voice_manager: vm,
130 copyparty,
131 http_client,
132 volume: std::sync::atomic::AtomicU64::new(1.0f64.to_bits()),
133 triggers: tokio::sync::Mutex::new(triggers),
134 ac_cache: tokio::sync::Mutex::new(HashMap::new()),
135 music_tracks: tokio::sync::Mutex::new(HashMap::new()),
136 };
137
138 // Spawn cron scheduler loop.
139 {
140 let cron_ctx = crate::tasks::CronContext {
141 pool: data.db_pool.clone(),
142 http: ctx.http.clone(),
143 voice_manager: data.voice_manager.clone(),
144 copyparty: data.copyparty.clone(),
145 };
146 tokio::spawn(async move {
147 crate::tasks::cron_scheduler_loop(cron_ctx).await;
148 });
149 }
150
151 poise::builtins::register_globally(ctx, &framework.options().commands).await?;
152
153 events::handle_ready(ctx).await.map_err(|e| {
154 tracing::error!("on_ready handler: {e}");
155 e
156 })?;
157
158 Ok(data)
159 })
160 })
161 .build();
162
163 let mut client =
164 serenity::ClientBuilder::new(&config_for_client.token, serenity::GatewayIntents::all())
165 .framework(framework)
166 .register_songbird()
167 .await
168 .expect("Error creating client");
169
170 client.start().await.expect("Error starting client");
171}
172
173async fn handle_event(ctx: &serenity::Context, event: &serenity::FullEvent, user_data: &Data) {
174 match event {
175 serenity::FullEvent::Ready {
176 data_about_bot: _, ..
177 } => {}
178 serenity::FullEvent::VoiceStateUpdate { old, new } => {
179 events::handle_voice_state_update(ctx, user_data, old.clone(), new)
180 .await
181 .unwrap_or_else(|e| tracing::error!("{}: {e}", lang::L_EV_VOICE_STATE_ERROR));
182 }
183 serenity::FullEvent::Message { new_message } => {
184 events::handle_message(ctx, user_data, new_message)
185 .await
186 .unwrap_or_else(|e| tracing::error!("{}: {e}", lang::L_EV_MESSAGE_HANDLER_ERROR));
187 }
188 _ => {}
189 }
190}
diff --git a/src/openrouter/mod.rs b/src/openrouter/mod.rs
new file mode 100644
index 0000000..31cee85
--- /dev/null
+++ b/src/openrouter/mod.rs
@@ -0,0 +1,134 @@
1use reqwest::Client;
2use serde::{Deserialize, Serialize};
3
4use crate::error::Result;
5use crate::lang;
6
7pub struct OpenRouterClient {
8 api_key: String,
9 model: String,
10 client: Client,
11}
12
13#[derive(Serialize)]
14struct ChatRequest {
15 model: String,
16 messages: Vec<Message>,
17 max_tokens: u32,
18}
19
20#[derive(Serialize)]
21struct Message {
22 role: String,
23 content: String,
24}
25
26#[derive(Deserialize)]
27struct ChatResponse {
28 choices: Vec<Choice>,
29}
30
31#[derive(Deserialize)]
32struct Choice {
33 message: MessageContent,
34}
35
36#[derive(Deserialize)]
37struct MessageContent {
38 content: String,
39}
40
41impl OpenRouterClient {
42 pub fn new(api_key: String, model: String) -> Self {
43 Self {
44 api_key,
45 model,
46 client: Client::new(),
47 }
48 }
49
50 pub async fn chat(&self, system_prompt: &str, user_message: &str) -> Result<String> {
51 let body = ChatRequest {
52 model: self.model.clone(),
53 messages: vec![
54 Message {
55 role: "system".into(),
56 content: system_prompt.to_string(),
57 },
58 Message {
59 role: "user".into(),
60 content: user_message.to_string(),
61 },
62 ],
63 max_tokens: 500,
64 };
65
66 let resp = self
67 .client
68 .post("https://openrouter.ai/api/v1/chat/completions")
69 .header("Authorization", format!("Bearer {}", self.api_key))
70 .json(&body)
71 .send()
72 .await?;
73
74 if !resp.status().is_success() {
75 let status = resp.status();
76 let text = resp.text().await.unwrap_or_default();
77 return Err(crate::error::Error::Custom(lang::fmt_openrouter_err(
78 &status, &text,
79 )));
80 }
81
82 let chat_resp: ChatResponse = resp.json().await?;
83 Ok(chat_resp
84 .choices
85 .into_iter()
86 .next()
87 .map(|c| c.message.content)
88 .unwrap_or_default())
89 }
90
91 pub async fn chat_with_history(
92 &self,
93 system_prompt: &str,
94 messages: Vec<(String, String)>, // (role, content) pairs
95 ) -> Result<String> {
96 let mut msg_vec = vec![Message {
97 role: "system".into(),
98 content: system_prompt.to_string(),
99 }];
100 for (role, content) in messages {
101 msg_vec.push(Message { role, content });
102 }
103
104 let body = ChatRequest {
105 model: self.model.clone(),
106 messages: msg_vec,
107 max_tokens: 500,
108 };
109
110 let resp = self
111 .client
112 .post("https://openrouter.ai/api/v1/chat/completions")
113 .header("Authorization", format!("Bearer {}", self.api_key))
114 .json(&body)
115 .send()
116 .await?;
117
118 if !resp.status().is_success() {
119 let status = resp.status();
120 let text = resp.text().await.unwrap_or_default();
121 return Err(crate::error::Error::Custom(lang::fmt_openrouter_err(
122 &status, &text,
123 )));
124 }
125
126 let chat_resp: ChatResponse = resp.json().await?;
127 Ok(chat_resp
128 .choices
129 .into_iter()
130 .next()
131 .map(|c| c.message.content)
132 .unwrap_or_default())
133 }
134}
diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs
new file mode 100644
index 0000000..f3f8811
--- /dev/null
+++ b/src/tasks/mod.rs
@@ -0,0 +1,172 @@
1use std::sync::Arc;
2
3use chrono::{Duration, Timelike, Utc};
4use cron::Schedule;
5use poise::serenity_prelude as serenity;
6use std::str::FromStr;
7use tokio::time::sleep;
8use tracing::{error, info};
9
10use crate::copyparty::CopypartyClient;
11use crate::database::{self, DbPool};
12use crate::voice::VoiceManager;
13
14/// Shared resources the cron scheduler needs.
15#[derive(Clone)]
16pub struct CronContext {
17 pub pool: DbPool,
18 pub http: Arc<serenity::Http>,
19 pub voice_manager: VoiceManager,
20 pub copyparty: CopypartyClient,
21}
22
23/// Normalize user-friendly cron expressions to the 7-field format the `cron` crate expects.
24/// Supports: 5-field (min hour dom month dow) and 6-field (sec min hour dom month dow).
25fn parse_cron(raw: &str) -> Result<Schedule, cron::error::Error> {
26 let fields: Vec<&str> = raw.split_whitespace().collect();
27 let normalized = match fields.len() {
28 7 => raw.to_string(),
29 6 => format!("{} *", raw), // sec min hour dom month dow → +year
30 5 => format!("0 {} *", raw), // min hour dom month dow → +sec +year
31 _ => raw.to_string(), // let the crate reject it
32 };
33 Schedule::from_str(&normalized)
34}
35
36/// Check window: look for cron fires in the last WINDOW_SECONDS.
37/// 61s gives 1s overlap with 60s tick to avoid missing exact-boundary fires.
38const WINDOW_SECONDS: i64 = 61;
39/// Tick interval for the scheduler loop.
40const TICK_SECONDS: u64 = 60;
41
42pub async fn check_and_execute_jobs(ctx: &CronContext) {
43 let jobs = match database::list_active_cron_jobs(&ctx.pool).await {
44 Ok(jobs) => jobs,
45 Err(e) => {
46 error!("Failed to load cron jobs: {e}");
47 return;
48 }
49 };
50
51 let now = Utc::now();
52 let window_start = now - Duration::seconds(WINDOW_SECONDS);
53
54 for job in jobs {
55 let schedule = match parse_cron(&job.cron_expression) {
56 Ok(s) => s,
57 Err(e) => {
58 error!("Invalid cron expression '{}': {e}", job.cron_expression);
59 continue;
60 }
61 };
62
63 // Check if any fire time fell within the last WINDOW_SECONDS.
64 let mut has_fired = false;
65 for next_fire in schedule.after(&window_start) {
66 if next_fire > now {
67 break;
68 }
69 has_fired = true;
70 break; // one fire per window is enough
71 }
72
73 if !has_fired {
74 continue;
75 }
76
77 info!("Executing cron job {}", job.id);
78 match job.r#type.as_str() {
79 "audio" => execute_audio_job(ctx, &job).await,
80 "wiadomość" => execute_message_job(ctx, &job).await,
81 _ => {}
82 }
83 }
84}
85
86async fn execute_message_job(ctx: &CronContext, job: &crate::database::CronJob) {
87 let Some(ref msg_text) = job.message else {
88 return;
89 };
90 let channel_id = serenity::ChannelId::new(job.channel_id as u64);
91 if let Err(e) = channel_id.say(&ctx.http, msg_text).await {
92 error!("Cron job {} failed to send message: {e}", job.id);
93 } else {
94 info!("Cron job {} sent message to {}", job.id, job.channel_id);
95 }
96}
97
98async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) {
99 let Some(ref audio_path) = job.audio_filename else {
100 error!("Cron audio job {} has no audio_filename", job.id);
101 return;
102 };
103
104 let channel_id = serenity::ChannelId::new(job.channel_id as u64);
105
106 // Resolve guild + build URL concurrently.
107 let url = ctx.copyparty.build_url(audio_path);
108 info!("Cron audio job {} streaming via ffmpeg: {url}", job.id);
109 let guild_channel_fut = channel_id.to_channel(&ctx.http);
110 let input_fut = crate::voice::ffmpeg_stream_input(&url);
111
112 let (channel_result, input_result) = tokio::join!(guild_channel_fut, input_fut);
113
114 let guild_id = match channel_result {
115 Ok(serenity::Channel::Guild(ch)) => ch.guild_id,
116 other => {
117 error!(
118 "Cron audio job {}: channel {} is not a guild voice channel ({:?})",
119 job.id,
120 job.channel_id,
121 other.map(|_| ())
122 );
123 return;
124 }
125 };
126
127 let input = match input_result {
128 Ok(i) => i,
129 Err(e) => {
130 error!(
131 "Cron audio job {} ffmpeg stream failed ({url}): {e}",
132 job.id
133 );
134 return;
135 }
136 };
137
138 // Connect to voice and play.
139 let call = match ctx.voice_manager.connect(guild_id, channel_id).await {
140 Ok(c) => c,
141 Err(e) => {
142 error!("Cron audio job {} voice connect failed: {e}", job.id);
143 return;
144 }
145 };
146
147 {
148 let mut call_lock = call.lock().await;
149 call_lock.play_input(input);
150 }
151
152 info!(
153 "Cron audio job {} playing '{}' (url: {url}) in guild {}",
154 job.id, audio_path, guild_id
155 );
156}
157
158pub async fn cron_scheduler_loop(ctx: CronContext) {
159 // Align first tick to the start of the next minute, then run every TICK_SECONDS.
160 let now = Utc::now();
161 let next_minute = (now + Duration::minutes(1))
162 .with_second(0)
163 .and_then(|t| t.with_nanosecond(0))
164 .unwrap_or(now);
165 let initial_delay = (next_minute - now).to_std().unwrap_or_default();
166 sleep(initial_delay).await;
167
168 loop {
169 check_and_execute_jobs(&ctx).await;
170 sleep(std::time::Duration::from_secs(TICK_SECONDS)).await;
171 }
172}
diff --git a/src/voice/mod.rs b/src/voice/mod.rs
new file mode 100644
index 0000000..0d5b89e
--- /dev/null
+++ b/src/voice/mod.rs
@@ -0,0 +1,404 @@
1use std::io::Cursor;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::task::{Context, Poll};
5use std::time::Duration;
6
7use serenity::all::{ChannelId, GuildId};
8use songbird::input::{AsyncAdapterStream, AsyncReadOnlySource, RawAdapter};
9use songbird::{Call, Songbird};
10use symphonia::core::audio::sample::Sample;
11use symphonia::core::codecs::audio::AudioDecoderOptions;
12use symphonia::core::formats::probe::Hint;
13use symphonia::core::formats::{FormatOptions, TrackType};
14use symphonia::core::io::MediaSourceStream;
15use symphonia::core::meta::MetadataOptions;
16use tokio::sync::Mutex;
17use tracing::{info, warn};
18
19use crate::error::{Error, Result};
20use crate::lang;
21
22#[derive(Clone)]
23pub struct VoiceManager {
24 pub songbird: Arc<Songbird>,
25}
26
27impl VoiceManager {
28 pub async fn new(ctx: &serenity::client::Context) -> Self {
29 Self {
30 songbird: songbird::get(ctx)
31 .await
32 .expect("Songbird not registered via register_songbird()"),
33 }
34 }
35
36 pub async fn connect(
37 &self,
38 guild_id: GuildId,
39 channel_id: ChannelId,
40 ) -> Result<Arc<Mutex<Call>>> {
41 let mut last_err = None;
42 for attempt in 1..=3 {
43 match self.songbird.join(guild_id, channel_id).await {
44 Ok(call) => {
45 tokio::time::sleep(Duration::from_millis(500)).await;
46 return Ok(call);
47 }
48 Err(e) => {
49 warn!(
50 "Voice join attempt {attempt}/3 failed for guild {}: {e}",
51 guild_id
52 );
53 if let songbird::error::JoinError::Driver(ref driver_err) = e {
54 warn!("Driver error details: {driver_err:?}");
55 }
56 last_err = Some(e);
57 tokio::time::sleep(Duration::from_secs(1)).await;
58 }
59 }
60 }
61 Err(Error::SongbirdJoin(last_err.unwrap()))
62 }
63
64 pub async fn disconnect(&self, guild_id: GuildId) -> Result<()> {
65 self.songbird
66 .remove(guild_id)
67 .await
68 .map_err(|e| Error::Custom(lang::fmt_voice_remove_failed(&e)))?;
69 Ok(())
70 }
71
72 pub fn get_call(&self, guild_id: GuildId) -> Option<Arc<Mutex<Call>>> {
73 self.songbird.get(guild_id)
74 }
75}
76
77// ── Audio decoding ─────────────────────────────────────────────────────
78
79/// Decode an in-memory audio buffer into raw f32 stereo PCM for songbird.
80/// Supports mp3, m4a/aac, flac, ogg, wav, opus via symphonia auto-detection.
81pub fn decode_audio(data: &[u8]) -> Result<songbird::input::Input> {
82 let mss = MediaSourceStream::new(Box::new(Cursor::new(data.to_vec())), Default::default());
83 decode_stream(mss)
84}
85
86fn decode_stream(mss: MediaSourceStream) -> Result<songbird::input::Input> {
87 let hint = Hint::new();
88 let fmt_opts = FormatOptions::default();
89 let meta_opts = MetadataOptions::default();
90
91 let mut format = symphonia::default::get_probe()
92 .probe(&hint, mss, fmt_opts, meta_opts)
93 .map_err(|e| Error::Custom(lang::fmt_voice_probe_failed(&e)))?;
94
95 let track = format
96 .default_track(TrackType::Audio)
97 .ok_or_else(|| Error::Custom(lang::L_VOICE_NO_TRACK.into()))?;
98 let track_id = track.id;
99
100 let codec_params = track
101 .codec_params
102 .as_ref()
103 .and_then(|p| p.audio())
104 .ok_or_else(|| Error::Custom(lang::L_VOICE_NO_CODEC.into()))?;
105
106 let sample_rate = codec_params.sample_rate.unwrap_or(48000);
107 let channels = codec_params
108 .channels
109 .clone()
110 .map(|c| c.count() as u32)
111 .unwrap_or(2);
112
113 let dec_opts = AudioDecoderOptions::default();
114 let mut decoder = symphonia::default::get_codecs()
115 .make_audio_decoder(codec_params, &dec_opts)
116 .map_err(|e| Error::Custom(lang::fmt_voice_decoder_failed(&e)))?;
117
118 let mut pcm_f32: Vec<f32> = Vec::new();
119
120 loop {
121 match format.next_packet() {
122 Ok(Some(packet)) => {
123 if packet.track_id != track_id {
124 continue;
125 }
126 if let Ok(buf) = decoder.decode(&packet) {
127 let n = buf.samples_interleaved();
128 let start = pcm_f32.len();
129 pcm_f32.resize(start + n, f32::MID);
130 buf.copy_to_slice_interleaved(&mut pcm_f32[start..]);
131 }
132 }
133 Ok(None) => break,
134 Err(_) => break,
135 }
136 }
137
138 let pcm_bytes: Vec<u8> = pcm_f32.iter().flat_map(|&s| s.to_le_bytes()).collect();
139 let cursor = Cursor::new(pcm_bytes);
140 let adapter = RawAdapter::new(cursor, sample_rate, channels);
141
142 Ok(adapter.into())
143}
144
145// ── yt-dlp helper ──────────────────────────────────────────────────────
146
147/// Download audio via yt-dlp, collect stdout into memory, return the raw bytes.
148/// Works for both finite media and live streams (yt-dlp handles the difference).
149pub async fn ytdlp_download(url: &str) -> Result<Vec<u8>> {
150 let output = tokio::process::Command::new("yt-dlp")
151 .args(["-f", "bestaudio", "-o", "-", "--no-playlist", url])
152 .stdout(std::process::Stdio::piped())
153 .stderr(std::process::Stdio::piped())
154 .output()
155 .await
156 .map_err(|e| Error::Custom(lang::fmt_voice_ytdlp_launch(&e)))?;
157
158 if !output.status.success() {
159 let stderr = String::from_utf8_lossy(&output.stderr);
160 // Keep the full yt-dlp trace in the logs for debugging — the user
161 // only ever sees the short, translated `fmt_ytdlp_stderr` message.
162 warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end());
163 return Err(Error::Custom(lang::fmt_ytdlp_stderr(&stderr)));
164 }
165
166 Ok(output.stdout)
167}
168
169// ── Compression helpers ───────────────────────────────────────────────
170
171/// Download a URL into memory via reqwest.
172pub async fn download_url(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
173 let resp = client
174 .get(url)
175 .send()
176 .await
177 .map_err(|e| Error::Custom(format!("HTTP GET failed for {url}: {e}")))?;
178 let bytes = resp
179 .bytes()
180 .await
181 .map_err(|e| Error::Custom(format!("Failed to read body for {url}: {e}")))?;
182 Ok(bytes.to_vec())
183}
184
185/// DEBUG: bypass encode/decode — just pipe through ffmpeg as PCM.
186/// If this plays, the streaming pipeline works; bug is in Opus roundtrip.
187pub async fn degrade_audio(input: &[u8]) -> Result<songbird::input::Input> {
188 use std::process::Stdio;
189
190 let mut child = tokio::process::Command::new("ffmpeg")
191 .args([
192 "-y", "-i", "pipe:0", "-vn", "-f", "f32le", "-ac", "2", "-ar", "8000", "-b:a", "500",
193 "pipe:1",
194 ])
195 .stdin(Stdio::piped())
196 .stdout(Stdio::piped())
197 .stderr(Stdio::null())
198 .kill_on_drop(true)
199 .spawn()
200 .map_err(|e| Error::Custom(format!("ffmpeg passthrough spawn: {e}")))?;
201
202 let mut stdin = child.stdin.take().unwrap();
203 let stdout = child
204 .stdout
205 .take()
206 .ok_or_else(|| Error::Custom("stdout not captured".into()))?;
207
208 let owned_input = input.to_vec();
209 tokio::spawn(async move {
210 use tokio::io::AsyncWriteExt;
211 let _ = stdin.write_all(&owned_input).await;
212 });
213
214 let ffmpeg_child = FfmpegChild {
215 _child: child,
216 stdout,
217 };
218
219 let async_source = AsyncReadOnlySource::new(ffmpeg_child);
220 let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024);
221 let raw = RawAdapter::new(adapter, 48000, 2);
222 Ok(raw.into())
223}
224
225// ── HTTP streaming via songbird's built-in HttpRequest (legacy) ─────
226
227/// Create a songbird `Input` that streams audio directly from an HTTP(S) URL.
228/// Works for both finite files and infinite Icecast/Shoutcast live streams.
229///
230/// Superseded by `ffmpeg_stream_input` for the `/play` command (which needs
231/// HLS/playlist/Icecast-metadata handling that symphonia can't provide), but
232/// retained for other callers that want lightweight symphonia-based streaming.
233#[allow(dead_code)]
234pub fn http_stream_input(client: reqwest::Client, url: &str) -> songbird::input::Input {
235 info!(%url, "streaming via HttpRequest");
236 songbird::input::HttpRequest::new(client, url.to_string()).into()
237}
238
239// ── Live streaming via ffmpeg subprocess ─────────────────────────────
240
241/// Adapter holding an ffmpeg child process and its stdout pipe.
242///
243/// Implements `AsyncRead` by delegating to `ChildStdout`. The `Child` is kept
244/// alive for the lifetime of the stream and killed on drop via
245/// `kill_on_drop(true)`, so stopping playback terminates ffmpeg.
246///
247/// `Sync`: the `Child` handle is only touched on `Drop` (a single, one-shot
248/// operation); `ChildStdout` is only read by the single `AsyncAdapterSink`
249/// task spawned inside `AsyncAdapterStream`. No field is accessed concurrently.
250struct FfmpegChild {
251 _child: tokio::process::Child,
252 stdout: tokio::process::ChildStdout,
253}
254
255impl tokio::io::AsyncRead for FfmpegChild {
256 fn poll_read(
257 mut self: Pin<&mut Self>,
258 cx: &mut Context<'_>,
259 buf: &mut tokio::io::ReadBuf<'_>,
260 ) -> Poll<std::io::Result<()>> {
261 Pin::new(&mut self.stdout).poll_read(cx, buf)
262 }
263}
264
265/// Stream an HTTP(S) URL live via an ffmpeg subprocess, producing a songbird
266/// `Input` of raw f32 PCM at 48 kHz stereo — NO full download.
267///
268/// ffmpeg handles every streaming scenario the Python bot relied on it for:
269/// Icecast/Shoutcast radio (incl. inline metadata), HLS `.m3u8` (segment
270/// fetching), `.m3u`/`.pls` playlists, and direct media files. The
271/// `-reconnect` flags let ffmpeg recover from dropped connections.
272///
273/// Byte path: `ffmpeg stdout` → `AsyncReadOnlySource` (unseekable async
274/// media source) → `AsyncAdapterStream` (async→sync ring buffer) →
275/// `RawAdapter` (prepends `SbirdRaw` f32 header) → songbird `Input`.
276pub async fn ffmpeg_stream_input(url: &str) -> Result<songbird::input::Input> {
277 use std::process::Stdio;
278
279 let mut child = tokio::process::Command::new("ffmpeg")
280 .args([
281 "-reconnect",
282 "1",
283 "-reconnect_streamed",
284 "1",
285 "-reconnect_delay_max",
286 "5",
287 "-i",
288 url,
289 "-vn",
290 "-f",
291 "f32le",
292 "-ar",
293 "48000",
294 "-ac",
295 "2",
296 "pipe:1",
297 ])
298 .stdout(Stdio::piped())
299 .stderr(Stdio::piped())
300 .kill_on_drop(true)
301 .spawn()
302 .map_err(|e| Error::Custom(lang::fmt_voice_ffmpeg_launch(&e)))?;
303
304 let stdout = child
305 .stdout
306 .take()
307 .ok_or_else(|| Error::Custom("ffmpeg stdout was not captured".into()))?;
308
309 // Drain stderr in background so ffmpeg doesn't block; log first 4 KiB on drop.
310 if let Some(stderr) = child.stderr.take() {
311 let url_owned = url.to_string();
312 tokio::spawn(async move {
313 use tokio::io::AsyncReadExt;
314 let mut buf = Vec::with_capacity(4096);
315 let mut reader = tokio::io::BufReader::new(stderr);
316 let _ = reader.read_to_end(&mut buf).await;
317 let text = String::from_utf8_lossy(&buf);
318 if !text.is_empty() {
319 warn!(url = %url_owned, "ffmpeg stderr: {}", text.trim_end());
320 }
321 });
322 }
323
324 let ffmpeg_child = FfmpegChild {
325 _child: child,
326 stdout,
327 };
328
329 let async_source = AsyncReadOnlySource::new(ffmpeg_child);
330 let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024);
331 let raw = RawAdapter::new(adapter, 48000, 2);
332 Ok(raw.into())
333}
334
335/// Smoke-test an HTTP stream URL: creates a raw reqwest GET, reads the first
336/// chunk directly from the async bytes stream (bypasses AsyncAdapterStream).
337/// Logs whether data arrives — isolates the hang to songbird's sync adapter.
338#[allow(dead_code)]
339pub async fn smoke_test_stream(client: reqwest::Client, url: String) {
340 use futures_util::StreamExt;
341
342 info!(%url, "smoke_test: sending GET");
343 let resp = match client.get(url.as_str()).send().await {
344 Ok(r) => r,
345 Err(e) => {
346 warn!(%url, error = %e, "smoke_test: GET FAILED");
347 return;
348 }
349 };
350 info!(%url, status = %resp.status(), "smoke_test: response received");
351
352 let mut stream = resp.bytes_stream();
353 info!(%url, "smoke_test: awaiting first chunk");
354 match stream.next().await {
355 Some(Ok(chunk)) => {
356 info!(%url, len = chunk.len(), "smoke_test: first chunk arrived");
357 }
358 Some(Err(e)) => {
359 warn!(%url, error = %e, "smoke_test: chunk error");
360 }
361 None => {
362 info!(%url, "smoke_test: stream ended immediately (0 bytes)");
363 }
364 }
365}
366
367/// Diagnostic: fetch headers and full body for a URL, logging key metrics.
368/// Helps debug symphonia probe EOF issues (Content-Length vs actual bytes).
369///
370/// **Warning**: hangs forever on infinite streams (Icecast). Finite URLs only.
371#[allow(dead_code)]
372pub async fn debug_fetch(client: &reqwest::Client, url: &str) {
373 match client.get(url).send().await {
374 Ok(resp) => {
375 let status = resp.status();
376 let headers = resp.headers().clone();
377 let content_length = headers
378 .get("content-length")
379 .and_then(|v| v.to_str().ok())
380 .and_then(|v| v.parse::<u64>().ok());
381 let content_type = headers.get("content-type").and_then(|v| v.to_str().ok());
382
383 info!(
384 %url, %status,
385 ?content_length, ?content_type,
386 "HTTP response headers"
387 );
388
389 match resp.bytes().await {
390 Ok(body) => {
391 let actual_len = body.len() as u64;
392 let reported_len = content_length.unwrap_or(0);
393 info!(
394 %url, reported = reported_len, actual = actual_len,
395 delta = actual_len as i64 - reported_len as i64,
396 "buffered .bytes(): reported vs actual"
397 );
398 }
399 Err(e) => warn!(%url, error = %e, ".bytes() failed"),
400 }
401 }
402 Err(e) => warn!(%url, error = %e, "HTTP GET failed"),
403 }
404}