aboutsummaryrefslogtreecommitdiffstats
path: root/src/commands
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-30 15:09:51 +0200
committersalmonik <salmonik@tuta.io>2026-07-30 15:09:51 +0200
commit7a565f377203ba8e01574952014a23dfa8d94fa3 (patch)
tree9bf25a5f618fd79b9c9911ea670f9d4b3d2bba99 /src/commands
parentfix assistant and senator (diff)
downloadsenator-sperma-7a565f377203ba8e01574952014a23dfa8d94fa3.tar.gz
senator-sperma-7a565f377203ba8e01574952014a23dfa8d94fa3.zip
fix: auto completion timing out before ready + fix preexisting warns
Diffstat (limited to 'src/commands')
-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
6 files changed, 189 insertions, 98 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)| {