aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/commands/ai.rs4
-rw-r--r--src/commands/autocomplete.rs249
-rw-r--r--src/commands/music.rs13
-rw-r--r--src/commands/trigger.rs12
-rw-r--r--src/commands/tts.rs4
-rw-r--r--src/commands/utilities.rs5
-rw-r--r--src/copyparty/mod.rs18
-rw-r--r--src/events/mod.rs24
-rw-r--r--src/lang.rs2
-rw-r--r--src/main.rs57
10 files changed, 259 insertions, 129 deletions
diff --git a/src/commands/ai.rs b/src/commands/ai.rs
index 5ab077e..a7a9a7e 100644
--- a/src/commands/ai.rs
+++ b/src/commands/ai.rs
@@ -18,7 +18,7 @@ pub async fn senator(
18 let response = client.chat(lang::SENATOR_SYSTEM_PROMPT, &text).await?; 18 let response = client.chat(lang::SENATOR_SYSTEM_PROMPT, &text).await?;
19 19
20 if tts_vc.unwrap_or(false) { 20 if tts_vc.unwrap_or(false) {
21 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; 21 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
22 let channel_id = ctx 22 let channel_id = ctx
23 .serenity_context() 23 .serenity_context()
24 .cache 24 .cache
@@ -28,7 +28,7 @@ pub async fn senator(
28 .get(&ctx.author().id) 28 .get(&ctx.author().id)
29 .and_then(|vs| vs.channel_id) 29 .and_then(|vs| vs.channel_id)
30 }) 30 })
31 .ok_or_else(|| lang::err_not_in_voice())?; 31 .ok_or_else(lang::err_not_in_voice)?;
32 32
33 let tts_url = format!( 33 let tts_url = format!(
34 "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", 34 "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob",
diff --git a/src/commands/autocomplete.rs b/src/commands/autocomplete.rs
index 97a18ec..434870c 100644
--- a/src/commands/autocomplete.rs
+++ b/src/commands/autocomplete.rs
@@ -1,13 +1,27 @@
1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
1use poise::serenity_prelude as serenity; 5use poise::serenity_prelude as serenity;
6use tokio::sync::oneshot;
7use tracing::{debug, warn};
2 8
3use crate::Data; 9use crate::copyparty::{self, html_decode, percent_decode};
4use crate::copyparty::{html_decode, percent_decode};
5use crate::error::Error; 10use crate::error::Error;
11use crate::Data;
6 12
7type Context<'a> = poise::Context<'a, Data, Error>; 13type Context<'a> = poise::Context<'a, Data, Error>;
8 14
9const MAX_CHOICE_LEN: usize = 99; 15const MAX_CHOICE_LEN: usize = 99;
10 16
17/// Maximum time we wait for a fresh search before returning a mock entry.
18/// Discord gives 3 seconds; we use 2 to leave room for the API call.
19const AC_SEARCH_TIMEOUT: Duration = Duration::from_secs(2);
20
21/// Value used for the mock "searching" placeholder — recognisable by the
22/// command handler as a no-op.
23const MOCK_SEARCHING: &str = "\u{231B}...";
24
11fn truncate_choice(s: &str) -> String { 25fn truncate_choice(s: &str) -> String {
12 if s.chars().count() <= MAX_CHOICE_LEN { 26 if s.chars().count() <= MAX_CHOICE_LEN {
13 return s.to_string(); 27 return s.to_string();
@@ -26,93 +40,173 @@ fn filename_label(raw_path: &str) -> Option<String> {
26 Some(truncate_choice(&decoded)) 40 Some(truncate_choice(&decoded))
27} 41}
28 42
29pub async fn audio_file<'a>( 43/// Build (label, value) pairs from search hits. Sync because it doesn't stash.
44fn build_choices(hits: &[copyparty::SearchHit]) -> Vec<(String, String)> {
45 let mut out = Vec::new();
46 for hit in hits {
47 let rp = match &hit.rp {
48 Some(p) => p.clone(),
49 None => continue,
50 };
51 let Some(label) = filename_label(&rp) else {
52 continue;
53 };
54 let value = truncate_choice(&rp);
55 out.push((label, value));
56 if out.len() >= 25 {
57 break;
58 }
59 }
60 out
61}
62
63/// Stash choices into the per-user ac_cache, then wrap into AutocompleteChoice.
64async fn stash_and_wrap(
30 ctx: Context<'_>, 65 ctx: Context<'_>,
31 partial: &'a str, 66 hits: &[copyparty::SearchHit],
67 pairs: Vec<(String, String)>,
32) -> Vec<serenity::AutocompleteChoice> { 68) -> Vec<serenity::AutocompleteChoice> {
33 eprintln!("[DEBUG] autocomplete audio_file(partial={partial:?})"); 69 let mut choices = Vec::with_capacity(pairs.len());
34 let results = ctx 70 for (label, value) in pairs {
35 .data() 71 // Re-stash into per-user cache so the command handler can resolve
36 .copyparty 72 let hit = hits
37 .search(partial, 25, &["mp3", "wav", "ogg", "flac", "m4a", "opus"]) 73 .iter()
38 .await; 74 .find(|h| {
39 match results { 75 h.rp
40 Ok(hits) => { 76 .as_deref()
41 eprintln!("[DEBUG] audio_file got {} raw hits", hits.len()); 77 .map(|rp| truncate_choice(rp) == value)
42 let mut choices = Vec::new(); 78 .unwrap_or(false)
43 for (i, hit) in hits.into_iter().enumerate() { 79 })
44 let rp = match &hit.rp { 80 .cloned();
45 Some(p) => p.clone(), 81 if let Some(h) = hit {
46 None => continue, 82 ctx.data()
47 }; 83 .ac_stash(ctx.author().id, value.clone(), h)
48 eprintln!( 84 .await;
49 "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}",
50 hit.url, hit.name, hit.path
51 );
52 let Some(label) = filename_label(&rp) else {
53 continue;
54 };
55
56 let value = truncate_choice(&rp);
57 ctx.data()
58 .ac_stash(ctx.author().id, value.clone(), hit)
59 .await;
60 eprintln!("[DEBUG] -> cached as {value:?}");
61 choices.push(serenity::AutocompleteChoice::new(label, value));
62 if choices.len() >= 25 {
63 break;
64 }
65 }
66 eprintln!("[DEBUG] audio_file returning {} choices", choices.len());
67 choices
68 }
69 Err(e) => {
70 eprintln!("[DEBUG] audio_file search failed: {e}");
71 vec![]
72 } 85 }
86 choices.push(serenity::AutocompleteChoice::new(label, value));
73 } 87 }
88 choices
74} 89}
75 90
76pub async fn any_file<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<serenity::AutocompleteChoice> { 91/// Spawn a single Copyparty search task. Returns a oneshot receiver that will
77 eprintln!("[DEBUG] autocomplete any_file(partial={partial:?})"); 92/// receive the results when the search completes.
78 let results = ctx.data().copyparty.search(partial, 25, &[]).await; 93///
79 match results { 94/// The task always runs to completion even if the receiver is dropped, and
80 Ok(hits) => { 95/// writes results into the shared `ac_search_cache` so *future* autocomplete
81 eprintln!("[DEBUG] any_file got {} raw hits", hits.len()); 96/// invocations can serve from cache.
82 let mut choices = Vec::new(); 97type SearchCache = Arc<
83 for (i, hit) in hits.into_iter().enumerate() { 98 tokio::sync::Mutex<HashMap<String, (Vec<copyparty::SearchHit>, std::time::Instant)>>,
84 let rp = match &hit.rp { 99>;
85 Some(p) => p.clone(), 100
86 None => continue, 101fn spawn_search(
87 }; 102 cache: SearchCache,
88 eprintln!( 103 client: copyparty::CopypartyClient,
89 "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", 104 query: String,
90 hit.url, hit.name, hit.path 105 extensions: Vec<String>,
91 ); 106) -> oneshot::Receiver<Vec<copyparty::SearchHit>> {
92 let Some(label) = filename_label(&rp) else { 107 let (tx, rx) = oneshot::channel();
93 continue; 108
94 }; 109 tokio::spawn(async move {
95 ctx.data() 110 let exts: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect();
96 .ac_stash(ctx.author().id, label.clone(), hit) 111 let result = client.search(&query, 25, &exts).await;
97 .await; 112
98 eprintln!("[DEBUG] -> cached as {label:?}"); 113 match result {
99 choices.push(serenity::AutocompleteChoice::new(label.clone(), label)); 114 Ok(hits) => {
100 if choices.len() >= 25 { 115 debug!(%query, count = hits.len(), "background search completed");
101 break; 116 // Always fill the shared search cache
102 } 117 cache
118 .lock()
119 .await
120 .insert(query, (hits.clone(), std::time::Instant::now()));
121 // Send to foreground (ignores error if receiver was dropped)
122 let _ = tx.send(hits);
123 }
124 Err(e) => {
125 debug!(%query, error = %e, "background search failed");
103 } 126 }
104 eprintln!("[DEBUG] any_file returning {} choices", choices.len());
105 choices
106 } 127 }
107 Err(e) => { 128 });
108 eprintln!("[DEBUG] any_file search failed: {e}"); 129
130 rx
131}
132
133/// Run a Copyparty search with mock fallback and background cache fill.
134///
135/// 1. Check shared search cache → return instantly if fresh and non-empty.
136/// 2. Spawn a *single* search task that always runs to completion.
137/// 3. Wait for it up to 2 seconds.
138/// 4. On success: stash per-user + return real choices.
139/// 5. On timeout: return a mock entry; the background task fills the cache,
140/// so the next keystroke's autocomplete will get real results.
141async fn timed_search(
142 ctx: Context<'_>,
143 partial: &str,
144 extensions: &[&str],
145) -> Vec<serenity::AutocompleteChoice> {
146 // 1. Shared search cache hit?
147 if let Some(hits) = ctx.data().ac_search_cache_lookup(partial).await
148 && !hits.is_empty() {
149 debug!(%partial, count = hits.len(), "autocomplete cache hit");
150 let pairs = build_choices(&hits);
151 return stash_and_wrap(ctx, &hits, pairs).await;
152 }
153
154 // 2. Spawn ONE search task (survives timeout → fills cache)
155 let exts: Vec<String> = extensions.iter().map(|s| s.to_string()).collect();
156 let mut rx = spawn_search(
157 ctx.data().ac_search_cache.clone(),
158 ctx.data().copyparty.clone(),
159 partial.to_string(),
160 exts,
161 );
162
163 // 3. Wait for the task with a timeout
164 match tokio::time::timeout(AC_SEARCH_TIMEOUT, &mut rx).await {
165 Ok(Ok(hits)) => {
166 debug!(%partial, count = hits.len(), "autocomplete got results in time");
167 let pairs = build_choices(&hits);
168 stash_and_wrap(ctx, &hits, pairs).await
169 }
170 Ok(Err(_)) => {
171 warn!(%partial, "autocomplete search task finished without sending (sender dropped)");
109 vec![] 172 vec![]
110 } 173 }
174 Err(_elapsed) => {
175 warn!(
176 %partial,
177 "autocomplete search timed out after {AC_SEARCH_TIMEOUT:?}, returning mock"
178 );
179 // The search task is still running in the background and will fill
180 // the cache when done. Return a mock so the user sees *something*
181 // and is prompted to type more → fresh autocomplete → cache hit.
182 vec![serenity::AutocompleteChoice::new(
183 format!(
184 "\u{231B} Szukanie \u{201E}{}\u{201D}\u{2026}",
185 truncate_choice(partial)
186 ),
187 MOCK_SEARCHING.to_string(),
188 )]
189 }
111 } 190 }
112} 191}
113 192
114pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<String> { 193pub async fn audio_file(
115 eprintln!("[DEBUG] autocomplete trigger_name(partial={partial:?})"); 194 ctx: Context<'_>,
195 partial: &str,
196) -> Vec<serenity::AutocompleteChoice> {
197 timed_search(
198 ctx,
199 partial,
200 &["mp3", "wav", "ogg", "flac", "m4a", "opus"],
201 )
202 .await
203}
204
205pub async fn any_file(ctx: Context<'_>, partial: &str) -> Vec<serenity::AutocompleteChoice> {
206 timed_search(ctx, partial, &[]).await
207}
208
209pub async fn trigger_name(ctx: Context<'_>, partial: &str) -> Vec<String> {
116 let db_pool = &ctx.data().db_pool; 210 let db_pool = &ctx.data().db_pool;
117 match crate::database::list_triggers(db_pool).await { 211 match crate::database::list_triggers(db_pool).await {
118 Ok(triggers) => { 212 Ok(triggers) => {
@@ -122,11 +216,10 @@ pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<String>
122 .filter(|name| partial.is_empty() || name.starts_with(partial)) 216 .filter(|name| partial.is_empty() || name.starts_with(partial))
123 .take(25) 217 .take(25)
124 .collect(); 218 .collect();
125 eprintln!("[DEBUG] trigger_name returning {} names", names.len());
126 names 219 names
127 } 220 }
128 Err(e) => { 221 Err(e) => {
129 eprintln!("[DEBUG] trigger_name query failed: {e}"); 222 warn!(%partial, error = %e, "autocomplete trigger_name failed");
130 vec![] 223 vec![]
131 } 224 }
132 } 225 }
diff --git a/src/commands/music.rs b/src/commands/music.rs
index d2861e4..05abb9c 100644
--- a/src/commands/music.rs
+++ b/src/commands/music.rs
@@ -220,11 +220,10 @@ fn redact_url(url: &str) -> &str {
220/// yt-dlp only needs `?v=ID`; extra `&` params can cause failures. 220/// yt-dlp only needs `?v=ID`; extra `&` params can cause failures.
221pub(crate) fn sanitize_yt_url(url: &str) -> String { 221pub(crate) fn sanitize_yt_url(url: &str) -> String {
222 let lower = url.to_lowercase(); 222 let lower = url.to_lowercase();
223 if lower.contains("youtube.com/") || lower.contains("youtu.be/") { 223 if (lower.contains("youtube.com/") || lower.contains("youtu.be/"))
224 if let Some(pos) = url.find('&') { 224 && let Some(pos) = url.find('&') {
225 return url[..pos].to_string(); 225 return url[..pos].to_string();
226 } 226 }
227 }
228 url.to_string() 227 url.to_string()
229} 228}
230 229
@@ -241,7 +240,7 @@ async fn play_impl(
241) -> Result<()> { 240) -> Result<()> {
242 ctx.defer().await?; 241 ctx.defer().await?;
243 242
244 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; 243 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
245 244
246 let channel_id = ctx 245 let channel_id = ctx
247 .serenity_context() 246 .serenity_context()
@@ -252,7 +251,7 @@ async fn play_impl(
252 .get(&ctx.author().id) 251 .get(&ctx.author().id)
253 .and_then(|vs| vs.channel_id) 252 .and_then(|vs| vs.channel_id)
254 }) 253 })
255 .ok_or_else(|| lang::err_not_in_voice())?; 254 .ok_or_else(lang::err_not_in_voice)?;
256 255
257 let src = resolve_source(ctx, melodia, file).await?; 256 let src = resolve_source(ctx, melodia, file).await?;
258 257
@@ -373,7 +372,7 @@ async fn play_impl(
373/// Stop playback and disconnect from voice 372/// Stop playback and disconnect from voice
374#[poise::command(slash_command)] 373#[poise::command(slash_command)]
375pub async fn stop(ctx: Context<'_>) -> Result<()> { 374pub async fn stop(ctx: Context<'_>) -> Result<()> {
376 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; 375 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
377 ctx.data().voice_manager.disconnect(guild_id).await?; 376 ctx.data().voice_manager.disconnect(guild_id).await?;
378 ctx.data().music_tracks.lock().await.remove(&guild_id.get()); 377 ctx.data().music_tracks.lock().await.remove(&guild_id.get());
379 ctx.send( 378 ctx.send(
@@ -389,7 +388,7 @@ pub async fn stop(ctx: Context<'_>) -> Result<()> {
389#[poise::command(slash_command)] 388#[poise::command(slash_command)]
390pub async fn join(ctx: Context<'_>) -> Result<()> { 389pub async fn join(ctx: Context<'_>) -> Result<()> {
391 ctx.defer().await?; 390 ctx.defer().await?;
392 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; 391 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
393 392
394 let channel_id = ctx.serenity_context().cache.guild(guild_id).and_then(|g| { 393 let channel_id = ctx.serenity_context().cache.guild(guild_id).and_then(|g| {
395 g.voice_states 394 g.voice_states
diff --git a/src/commands/trigger.rs b/src/commands/trigger.rs
index 7453e03..2a33528 100644
--- a/src/commands/trigger.rs
+++ b/src/commands/trigger.rs
@@ -49,7 +49,7 @@ pub async fn add(
49) -> Result<()> { 49) -> Result<()> {
50 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) 50 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
51 .await? 51 .await?
52 .ok_or_else(|| lang::err_trigger_not_found())?; 52 .ok_or_else(lang::err_trigger_not_found)?;
53 53
54 let mut modified = false; 54 let mut modified = false;
55 if let Some(kw) = trigger_keywords { 55 if let Some(kw) = trigger_keywords {
@@ -90,7 +90,7 @@ pub async fn remove(
90) -> Result<()> { 90) -> Result<()> {
91 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) 91 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
92 .await? 92 .await?
93 .ok_or_else(|| lang::err_trigger_not_found())?; 93 .ok_or_else(lang::err_trigger_not_found)?;
94 94
95 let mut modified = false; 95 let mut modified = false;
96 if let Some(kw) = trigger_keyword { 96 if let Some(kw) = trigger_keyword {
@@ -119,7 +119,7 @@ pub async fn delete(
119) -> Result<()> { 119) -> Result<()> {
120 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) 120 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
121 .await? 121 .await?
122 .ok_or_else(|| lang::err_trigger_not_found())?; 122 .ok_or_else(lang::err_trigger_not_found)?;
123 123
124 database::delete_trigger(&ctx.data().db_pool, t.id).await?; 124 database::delete_trigger(&ctx.data().db_pool, t.id).await?;
125 ctx.data().reload_triggers().await; 125 ctx.data().reload_triggers().await;
@@ -137,7 +137,7 @@ pub async fn list(
137) -> Result<()> { 137) -> Result<()> {
138 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) 138 let t = database::get_trigger_by_name(&ctx.data().db_pool, &name)
139 .await? 139 .await?
140 .ok_or_else(|| lang::err_trigger_not_found())?; 140 .ok_or_else(lang::err_trigger_not_found)?;
141 141
142 let keywords = database::get_keywords(&ctx.data().db_pool, t.id).await?; 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?; 143 let responses = database::get_responses(&ctx.data().db_pool, t.id).await?;
@@ -153,7 +153,7 @@ pub async fn list(
153 } else { 153 } else {
154 keywords 154 keywords
155 .iter() 155 .iter()
156 .map(|r| format!("{r}")) 156 .map(|r| r.to_string())
157 .collect::<Vec<_>>() 157 .collect::<Vec<_>>()
158 .join("\n") 158 .join("\n")
159 }, 159 },
@@ -166,7 +166,7 @@ pub async fn list(
166 } else { 166 } else {
167 responses 167 responses
168 .iter() 168 .iter()
169 .map(|r| format!("{r}")) 169 .map(|r| r.to_string())
170 .collect::<Vec<_>>() 170 .collect::<Vec<_>>()
171 .join("\n") 171 .join("\n")
172 }, 172 },
diff --git a/src/commands/tts.rs b/src/commands/tts.rs
index ee13596..4a99539 100644
--- a/src/commands/tts.rs
+++ b/src/commands/tts.rs
@@ -59,7 +59,7 @@ pub async fn tts(
59 .await?; 59 .await?;
60 } 60 }
61 OutputMode::Vc => { 61 OutputMode::Vc => {
62 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; 62 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
63 let channel_id = ctx 63 let channel_id = ctx
64 .serenity_context() 64 .serenity_context()
65 .cache 65 .cache
@@ -69,7 +69,7 @@ pub async fn tts(
69 .get(&ctx.author().id) 69 .get(&ctx.author().id)
70 .and_then(|vs| vs.channel_id) 70 .and_then(|vs| vs.channel_id)
71 }) 71 })
72 .ok_or_else(|| lang::err_not_in_voice())?; 72 .ok_or_else(lang::err_not_in_voice)?;
73 73
74 let call = ctx 74 let call = ctx
75 .data() 75 .data()
diff --git a/src/commands/utilities.rs b/src/commands/utilities.rs
index 10ce403..3f7e812 100644
--- a/src/commands/utilities.rs
+++ b/src/commands/utilities.rs
@@ -329,11 +329,10 @@ async fn find_downloaded_file(dir: &str) -> Option<(String, String)> {
329 Ok(t) => t, 329 Ok(t) => t,
330 Err(_) => continue, 330 Err(_) => continue,
331 }; 331 };
332 if let Some((t, _)) = &newest { 332 if let Some((t, _)) = &newest
333 if *t >= mtime { 333 && *t >= mtime {
334 continue; 334 continue;
335 } 335 }
336 }
337 newest = Some((mtime, entry.path().to_string_lossy().into_owned())); 336 newest = Some((mtime, entry.path().to_string_lossy().into_owned()));
338 } 337 }
339 newest.map(|(_, p)| { 338 newest.map(|(_, p)| {
diff --git a/src/copyparty/mod.rs b/src/copyparty/mod.rs
index 00bfd53..164c9f8 100644
--- a/src/copyparty/mod.rs
+++ b/src/copyparty/mod.rs
@@ -1,3 +1,5 @@
1use std::time::Duration;
2
1use reqwest::Client; 3use reqwest::Client;
2use serde::Deserialize; 4use serde::Deserialize;
3use tracing::warn; 5use tracing::warn;
@@ -5,6 +7,10 @@ use tracing::warn;
5use crate::error::Result; 7use crate::error::Result;
6use crate::lang; 8use crate::lang;
7 9
10/// Timeout for copyparty search requests; autocomplete has a 3s Discord budget,
11/// so this must be comfortably lower.
12const SEARCH_HTTP_TIMEOUT: Duration = Duration::from_secs(4);
13
8#[derive(Clone)] 14#[derive(Clone)]
9pub struct CopypartyClient { 15pub struct CopypartyClient {
10 pub base_url: String, 16 pub base_url: String,
@@ -20,13 +26,12 @@ pub fn percent_decode(s: &str) -> String {
20 let mut i = 0; 26 let mut i = 0;
21 while i < bytes.len() { 27 while i < bytes.len() {
22 let b = bytes[i]; 28 let b = bytes[i];
23 if b == b'%' && i + 2 < bytes.len() { 29 if b == b'%' && i + 2 < bytes.len()
24 if let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) { 30 && let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) {
25 buf.push(hi << 4 | lo); 31 buf.push(hi << 4 | lo);
26 i += 3; 32 i += 3;
27 continue; 33 continue;
28 } 34 }
29 }
30 if b == b'+' { 35 if b == b'+' {
31 buf.push(b' '); 36 buf.push(b' ');
32 } else { 37 } else {
@@ -56,7 +61,7 @@ pub fn html_decode(s: &str) -> String {
56 "&quot;" => buf.extend_from_slice(b"\""), 61 "&quot;" => buf.extend_from_slice(b"\""),
57 "&apos;" => buf.extend_from_slice(b"'"), 62 "&apos;" => buf.extend_from_slice(b"'"),
58 "&nbsp;" => buf.extend_from_slice(b" "), 63 "&nbsp;" => buf.extend_from_slice(b" "),
59 _ if entity.as_bytes().len() > 3 && entity.as_bytes()[1] == b'#' => { 64 _ if entity.len() > 3 && entity.as_bytes()[1] == b'#' => {
60 let (num_str, radix) = 65 let (num_str, radix) =
61 if entity.as_bytes()[2] == b'x' || entity.as_bytes()[2] == b'X' { 66 if entity.as_bytes()[2] == b'x' || entity.as_bytes()[2] == b'X' {
62 (&entity[3..entity.len() - 1], 16u32) 67 (&entity[3..entity.len() - 1], 16u32)
@@ -130,7 +135,7 @@ impl SearchHit {
130 /// Best-effort URL path for building a full download URL. 135 /// Best-effort URL path for building a full download URL.
131 /// Tries `url`, then `rp`. 136 /// Tries `url`, then `rp`.
132 pub fn url_path(&self) -> Option<&str> { 137 pub fn url_path(&self) -> Option<&str> {
133 self.url.as_deref().or_else(|| self.rp.as_deref()) 138 self.url.as_deref().or(self.rp.as_deref())
134 } 139 }
135} 140}
136 141
@@ -203,7 +208,8 @@ impl CopypartyClient {
203 let mut req = self 208 let mut req = self
204 .client 209 .client
205 .post(format!("{}/?srch", self.base_url)) 210 .post(format!("{}/?srch", self.base_url))
206 .json(&payload); 211 .json(&payload)
212 .timeout(SEARCH_HTTP_TIMEOUT);
207 if let Some(pw) = &self.password { 213 if let Some(pw) = &self.password {
208 req = req.header("PW", pw); 214 req = req.header("PW", pw);
209 } 215 }
diff --git a/src/events/mod.rs b/src/events/mod.rs
index 9a193d0..d166993 100644
--- a/src/events/mod.rs
+++ b/src/events/mod.rs
@@ -92,11 +92,10 @@ pub async fn handle_voice_state_update(
92 let member = guild_id 92 let member = guild_id
93 .and_then(|gid| ctx.cache.guild(gid)) 93 .and_then(|gid| ctx.cache.guild(gid))
94 .and_then(|g| g.members.get(&user_id).cloned()); 94 .and_then(|g| g.members.get(&user_id).cloned());
95 if let Some(ref m) = member { 95 if let Some(ref m) = member
96 if m.user.bot { 96 && m.user.bot {
97 return Ok(()); 97 return Ok(());
98 } 98 }
99 }
100 99
101 let member_name = member 100 let member_name = member
102 .as_ref() 101 .as_ref()
@@ -269,40 +268,37 @@ pub async fn handle_voice_state_update(
269 } 268 }
270 269
271 // ── TTS for mute/deafen (deaf takes priority with early return) ── 270 // ── 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 { 271 if old.as_ref().is_some_and(|v| v.self_deaf) != new.self_deaf {
273 let phrase = if new.self_deaf { 272 let phrase = if new.self_deaf {
274 lang::fmt_ev_deafened(&display_name) 273 lang::fmt_ev_deafened(&display_name)
275 } else { 274 } else {
276 lang::fmt_ev_undeafened(&display_name) 275 lang::fmt_ev_undeafened(&display_name)
277 }; 276 };
278 if let Some(gid) = guild_id { 277 if let Some(gid) = guild_id
279 if let Some(cid) = tts_channel.or(new_channel) { 278 && let Some(cid) = tts_channel.or(new_channel) {
280 play_tts_in_channel(ctx, data, gid, cid, &phrase).await; 279 play_tts_in_channel(ctx, data, gid, cid, &phrase).await;
281 } 280 }
282 }
283 return Ok(()); 281 return Ok(());
284 } 282 }
285 283
286 if old.as_ref().map_or(false, |v| v.self_mute) != new.self_mute { 284 if old.as_ref().is_some_and(|v| v.self_mute) != new.self_mute {
287 let phrase = if new.self_mute { 285 let phrase = if new.self_mute {
288 lang::fmt_ev_muted(&display_name) 286 lang::fmt_ev_muted(&display_name)
289 } else { 287 } else {
290 lang::fmt_ev_unmuted(&display_name) 288 lang::fmt_ev_unmuted(&display_name)
291 }; 289 };
292 if let Some(gid) = guild_id { 290 if let Some(gid) = guild_id
293 if let Some(cid) = tts_channel.or(new_channel) { 291 && let Some(cid) = tts_channel.or(new_channel) {
294 play_tts_in_channel(ctx, data, gid, cid, &phrase).await; 292 play_tts_in_channel(ctx, data, gid, cid, &phrase).await;
295 } 293 }
296 }
297 return Ok(()); 294 return Ok(());
298 } 295 }
299 296
300 // ── Play join/leave TTS if no mute/deafen change ── 297 // ── Play join/leave TTS if no mute/deafen change ──
301 if let (Some(text), Some(cid)) = (tts_text, tts_channel) { 298 if let (Some(text), Some(cid)) = (tts_text, tts_channel)
302 if let Some(gid) = guild_id { 299 && let Some(gid) = guild_id {
303 play_tts_in_channel(ctx, data, gid, cid, &text).await; 300 play_tts_in_channel(ctx, data, gid, cid, &text).await;
304 } 301 }
305 }
306 302
307 Ok(()) 303 Ok(())
308} 304}
diff --git a/src/lang.rs b/src/lang.rs
index b1ed088..05e82fd 100644
--- a/src/lang.rs
+++ b/src/lang.rs
@@ -35,8 +35,6 @@ pub const L_TRIGGER_NOT_FOUND: &str = "Grupa triggerów nie znaleziona";
35 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."; 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 37
38pub const L_OPENROUTER_NO_KEY: &str = "Klucz API OpenRouter nie jest skonfigurowany w .env";
39
40// ── Cron command ────────────────────────────────────────────────────── 38// ── Cron command ──────────────────────────────────────────────────────
41 39
42pub const L_CRON_QUEUED: &str = "Praca cron zakolejkowana"; 40pub const L_CRON_QUEUED: &str = "Praca cron zakolejkowana";
diff --git a/src/main.rs b/src/main.rs
index 4a89315..7874c86 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,6 @@
1use std::collections::HashMap; 1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
2 4
3use poise::serenity_prelude as serenity; 5use poise::serenity_prelude as serenity;
4use songbird::SerenityInit; 6use songbird::SerenityInit;
@@ -17,6 +19,17 @@ mod voice;
17 19
18use database::DbPool; 20use database::DbPool;
19 21
22/// How long a search cache entry stays fresh (10 seconds).
23const AC_SEARCH_CACHE_TTL: Duration = Duration::from_secs(10);
24
25/// Per-user autocomplete stash: (UserId, filename) → SearchHit.
26type AcCache = tokio::sync::Mutex<HashMap<(serenity::UserId, String), copyparty::SearchHit>>;
27
28/// Shared autocomplete search cache: query → (hits, cached_at).
29type AcSearchCache = Arc<
30 tokio::sync::Mutex<HashMap<String, (Vec<copyparty::SearchHit>, Instant)>>,
31>;
32
20pub struct Data { 33pub struct Data {
21 pub db_pool: DbPool, 34 pub db_pool: DbPool,
22 pub config: config::Config, 35 pub config: config::Config,
@@ -30,7 +43,12 @@ pub struct Data {
30 /// Mirrors Python's per-cog `_cached_urls[user_id]` map: when two users 43 /// Mirrors Python's per-cog `_cached_urls[user_id]` map: when two users
31 /// search the same filename simultaneously, each invocation must resolve its 44 /// search the same filename simultaneously, each invocation must resolve its
32 /// own stashed `SearchHit`, so the cache is keyed by `(UserId, filename)`. 45 /// own stashed `SearchHit`, so the cache is keyed by `(UserId, filename)`.
33 pub ac_cache: tokio::sync::Mutex<HashMap<(serenity::UserId, String), copyparty::SearchHit>>, 46 pub ac_cache: AcCache,
47 /// Autocomplete search results cache (query → hits).
48 ///
49 /// Populated by background tasks when the first search times out, so the
50 /// next keystroke's autocomplete can serve real results immediately.
51 pub ac_search_cache: AcSearchCache,
34 /// Active music track per guild (only one music source at a time). 52 /// Active music track per guild (only one music source at a time).
35 pub music_tracks: tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>, 53 pub music_tracks: tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>,
36} 54}
@@ -63,13 +81,36 @@ impl Data {
63 .get(&(user_id, key.to_string())) 81 .get(&(user_id, key.to_string()))
64 .cloned() 82 .cloned()
65 } 83 }
84
85 /// Look up search cache for an autocomplete query. Returns `None` if
86 /// uncached or stale.
87 pub async fn ac_search_cache_lookup(
88 &self,
89 query: &str,
90 ) -> Option<Vec<copyparty::SearchHit>> {
91 let cache = self.ac_search_cache.lock().await;
92 let (hits, cached_at) = cache.get(query)?;
93 if cached_at.elapsed() > AC_SEARCH_CACHE_TTL {
94 None
95 } else {
96 Some(hits.clone())
97 }
98 }
99
100 /// Store search results into the shared cache.
101 pub async fn ac_search_cache_store(&self, query: String, hits: Vec<copyparty::SearchHit>) {
102 self.ac_search_cache
103 .lock()
104 .await
105 .insert(query, (hits, Instant::now()));
106 }
66} 107}
67 108
68#[allow(dead_code)] 109#[allow(dead_code)]
69type Error = error::Error; 110type Error = error::Error;
70 111
71#[tokio::main] 112#[tokio::main]
72async fn main() { 113async fn main() -> Result<(), Box<dyn std::error::Error>> {
73 let _ = rustls::crypto::ring::default_provider().install_default(); 114 let _ = rustls::crypto::ring::default_provider().install_default();
74 115
75 tracing_subscriber::fmt() 116 tracing_subscriber::fmt()
@@ -84,10 +125,7 @@ async fn main() {
84 config::check_env_vars(); 125 config::check_env_vars();
85 let config = config::Config::from_env(); 126 let config = config::Config::from_env();
86 127
87 let db_pool = database::init_pool(&config.database_path) 128 let db_pool = database::init_pool(&config.database_path).await?;
88 .await
89 .expect("Failed to initialize database");
90
91 let http_client = reqwest::Client::new(); 129 let http_client = reqwest::Client::new();
92 130
93 let copyparty = copyparty::CopypartyClient::new( 131 let copyparty = copyparty::CopypartyClient::new(
@@ -132,6 +170,7 @@ async fn main() {
132 volume: std::sync::atomic::AtomicU64::new(1.0f64.to_bits()), 170 volume: std::sync::atomic::AtomicU64::new(1.0f64.to_bits()),
133 triggers: tokio::sync::Mutex::new(triggers), 171 triggers: tokio::sync::Mutex::new(triggers),
134 ac_cache: tokio::sync::Mutex::new(HashMap::new()), 172 ac_cache: tokio::sync::Mutex::new(HashMap::new()),
173 ac_search_cache: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
135 music_tracks: tokio::sync::Mutex::new(HashMap::new()), 174 music_tracks: tokio::sync::Mutex::new(HashMap::new()),
136 }; 175 };
137 176
@@ -164,10 +203,10 @@ async fn main() {
164 serenity::ClientBuilder::new(&config_for_client.token, serenity::GatewayIntents::all()) 203 serenity::ClientBuilder::new(&config_for_client.token, serenity::GatewayIntents::all())
165 .framework(framework) 204 .framework(framework)
166 .register_songbird() 205 .register_songbird()
167 .await 206 .await?;
168 .expect("Error creating client");
169 207
170 client.start().await.expect("Error starting client"); 208 client.start().await?;
209 Ok(())
171} 210}
172 211
173async fn handle_event(ctx: &serenity::Context, event: &serenity::FullEvent, user_data: &Data) { 212async fn handle_event(ctx: &serenity::Context, event: &serenity::FullEvent, user_data: &Data) {