From 7a565f377203ba8e01574952014a23dfa8d94fa3 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 30 Jul 2026 15:09:51 +0200 Subject: fix: auto completion timing out before ready + fix preexisting warns --- src/commands/ai.rs | 4 +- src/commands/autocomplete.rs | 249 +++++++++++++++++++++++++++++-------------- src/commands/music.rs | 13 ++- src/commands/trigger.rs | 12 +-- src/commands/tts.rs | 4 +- src/commands/utilities.rs | 5 +- 6 files changed, 189 insertions(+), 98 deletions(-) (limited to 'src/commands') 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( let response = client.chat(lang::SENATOR_SYSTEM_PROMPT, &text).await?; if tts_vc.unwrap_or(false) { - let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; let channel_id = ctx .serenity_context() .cache @@ -28,7 +28,7 @@ pub async fn senator( .get(&ctx.author().id) .and_then(|vs| vs.channel_id) }) - .ok_or_else(|| lang::err_not_in_voice())?; + .ok_or_else(lang::err_not_in_voice)?; let tts_url = format!( "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 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + use poise::serenity_prelude as serenity; +use tokio::sync::oneshot; +use tracing::{debug, warn}; -use crate::Data; -use crate::copyparty::{html_decode, percent_decode}; +use crate::copyparty::{self, html_decode, percent_decode}; use crate::error::Error; +use crate::Data; type Context<'a> = poise::Context<'a, Data, Error>; const MAX_CHOICE_LEN: usize = 99; +/// Maximum time we wait for a fresh search before returning a mock entry. +/// Discord gives 3 seconds; we use 2 to leave room for the API call. +const AC_SEARCH_TIMEOUT: Duration = Duration::from_secs(2); + +/// Value used for the mock "searching" placeholder — recognisable by the +/// command handler as a no-op. +const MOCK_SEARCHING: &str = "\u{231B}..."; + fn truncate_choice(s: &str) -> String { if s.chars().count() <= MAX_CHOICE_LEN { return s.to_string(); @@ -26,93 +40,173 @@ fn filename_label(raw_path: &str) -> Option { Some(truncate_choice(&decoded)) } -pub async fn audio_file<'a>( +/// Build (label, value) pairs from search hits. Sync because it doesn't stash. +fn build_choices(hits: &[copyparty::SearchHit]) -> Vec<(String, String)> { + let mut out = Vec::new(); + for hit in hits { + let rp = match &hit.rp { + Some(p) => p.clone(), + None => continue, + }; + let Some(label) = filename_label(&rp) else { + continue; + }; + let value = truncate_choice(&rp); + out.push((label, value)); + if out.len() >= 25 { + break; + } + } + out +} + +/// Stash choices into the per-user ac_cache, then wrap into AutocompleteChoice. +async fn stash_and_wrap( ctx: Context<'_>, - partial: &'a str, + hits: &[copyparty::SearchHit], + pairs: Vec<(String, String)>, ) -> Vec { - eprintln!("[DEBUG] autocomplete audio_file(partial={partial:?})"); - let results = ctx - .data() - .copyparty - .search(partial, 25, &["mp3", "wav", "ogg", "flac", "m4a", "opus"]) - .await; - match results { - Ok(hits) => { - eprintln!("[DEBUG] audio_file got {} raw hits", hits.len()); - let mut choices = Vec::new(); - for (i, hit) in hits.into_iter().enumerate() { - let rp = match &hit.rp { - Some(p) => p.clone(), - None => continue, - }; - eprintln!( - "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", - hit.url, hit.name, hit.path - ); - let Some(label) = filename_label(&rp) else { - continue; - }; - - let value = truncate_choice(&rp); - ctx.data() - .ac_stash(ctx.author().id, value.clone(), hit) - .await; - eprintln!("[DEBUG] -> cached as {value:?}"); - choices.push(serenity::AutocompleteChoice::new(label, value)); - if choices.len() >= 25 { - break; - } - } - eprintln!("[DEBUG] audio_file returning {} choices", choices.len()); - choices - } - Err(e) => { - eprintln!("[DEBUG] audio_file search failed: {e}"); - vec![] + let mut choices = Vec::with_capacity(pairs.len()); + for (label, value) in pairs { + // Re-stash into per-user cache so the command handler can resolve + let hit = hits + .iter() + .find(|h| { + h.rp + .as_deref() + .map(|rp| truncate_choice(rp) == value) + .unwrap_or(false) + }) + .cloned(); + if let Some(h) = hit { + ctx.data() + .ac_stash(ctx.author().id, value.clone(), h) + .await; } + choices.push(serenity::AutocompleteChoice::new(label, value)); } + choices } -pub async fn any_file<'a>(ctx: Context<'_>, partial: &'a str) -> Vec { - eprintln!("[DEBUG] autocomplete any_file(partial={partial:?})"); - let results = ctx.data().copyparty.search(partial, 25, &[]).await; - match results { - Ok(hits) => { - eprintln!("[DEBUG] any_file got {} raw hits", hits.len()); - let mut choices = Vec::new(); - for (i, hit) in hits.into_iter().enumerate() { - let rp = match &hit.rp { - Some(p) => p.clone(), - None => continue, - }; - eprintln!( - "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", - hit.url, hit.name, hit.path - ); - let Some(label) = filename_label(&rp) else { - continue; - }; - ctx.data() - .ac_stash(ctx.author().id, label.clone(), hit) - .await; - eprintln!("[DEBUG] -> cached as {label:?}"); - choices.push(serenity::AutocompleteChoice::new(label.clone(), label)); - if choices.len() >= 25 { - break; - } +/// Spawn a single Copyparty search task. Returns a oneshot receiver that will +/// receive the results when the search completes. +/// +/// The task always runs to completion even if the receiver is dropped, and +/// writes results into the shared `ac_search_cache` so *future* autocomplete +/// invocations can serve from cache. +type SearchCache = Arc< + tokio::sync::Mutex, std::time::Instant)>>, +>; + +fn spawn_search( + cache: SearchCache, + client: copyparty::CopypartyClient, + query: String, + extensions: Vec, +) -> oneshot::Receiver> { + let (tx, rx) = oneshot::channel(); + + tokio::spawn(async move { + let exts: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect(); + let result = client.search(&query, 25, &exts).await; + + match result { + Ok(hits) => { + debug!(%query, count = hits.len(), "background search completed"); + // Always fill the shared search cache + cache + .lock() + .await + .insert(query, (hits.clone(), std::time::Instant::now())); + // Send to foreground (ignores error if receiver was dropped) + let _ = tx.send(hits); + } + Err(e) => { + debug!(%query, error = %e, "background search failed"); } - eprintln!("[DEBUG] any_file returning {} choices", choices.len()); - choices } - Err(e) => { - eprintln!("[DEBUG] any_file search failed: {e}"); + }); + + rx +} + +/// Run a Copyparty search with mock fallback and background cache fill. +/// +/// 1. Check shared search cache → return instantly if fresh and non-empty. +/// 2. Spawn a *single* search task that always runs to completion. +/// 3. Wait for it up to 2 seconds. +/// 4. On success: stash per-user + return real choices. +/// 5. On timeout: return a mock entry; the background task fills the cache, +/// so the next keystroke's autocomplete will get real results. +async fn timed_search( + ctx: Context<'_>, + partial: &str, + extensions: &[&str], +) -> Vec { + // 1. Shared search cache hit? + if let Some(hits) = ctx.data().ac_search_cache_lookup(partial).await + && !hits.is_empty() { + debug!(%partial, count = hits.len(), "autocomplete cache hit"); + let pairs = build_choices(&hits); + return stash_and_wrap(ctx, &hits, pairs).await; + } + + // 2. Spawn ONE search task (survives timeout → fills cache) + let exts: Vec = extensions.iter().map(|s| s.to_string()).collect(); + let mut rx = spawn_search( + ctx.data().ac_search_cache.clone(), + ctx.data().copyparty.clone(), + partial.to_string(), + exts, + ); + + // 3. Wait for the task with a timeout + match tokio::time::timeout(AC_SEARCH_TIMEOUT, &mut rx).await { + Ok(Ok(hits)) => { + debug!(%partial, count = hits.len(), "autocomplete got results in time"); + let pairs = build_choices(&hits); + stash_and_wrap(ctx, &hits, pairs).await + } + Ok(Err(_)) => { + warn!(%partial, "autocomplete search task finished without sending (sender dropped)"); vec![] } + Err(_elapsed) => { + warn!( + %partial, + "autocomplete search timed out after {AC_SEARCH_TIMEOUT:?}, returning mock" + ); + // The search task is still running in the background and will fill + // the cache when done. Return a mock so the user sees *something* + // and is prompted to type more → fresh autocomplete → cache hit. + vec![serenity::AutocompleteChoice::new( + format!( + "\u{231B} Szukanie \u{201E}{}\u{201D}\u{2026}", + truncate_choice(partial) + ), + MOCK_SEARCHING.to_string(), + )] + } } } -pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec { - eprintln!("[DEBUG] autocomplete trigger_name(partial={partial:?})"); +pub async fn audio_file( + ctx: Context<'_>, + partial: &str, +) -> Vec { + timed_search( + ctx, + partial, + &["mp3", "wav", "ogg", "flac", "m4a", "opus"], + ) + .await +} + +pub async fn any_file(ctx: Context<'_>, partial: &str) -> Vec { + timed_search(ctx, partial, &[]).await +} + +pub async fn trigger_name(ctx: Context<'_>, partial: &str) -> Vec { let db_pool = &ctx.data().db_pool; match crate::database::list_triggers(db_pool).await { Ok(triggers) => { @@ -122,11 +216,10 @@ pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec .filter(|name| partial.is_empty() || name.starts_with(partial)) .take(25) .collect(); - eprintln!("[DEBUG] trigger_name returning {} names", names.len()); names } Err(e) => { - eprintln!("[DEBUG] trigger_name query failed: {e}"); + warn!(%partial, error = %e, "autocomplete trigger_name failed"); vec![] } } 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 { /// yt-dlp only needs `?v=ID`; extra `&` params can cause failures. pub(crate) fn sanitize_yt_url(url: &str) -> String { let lower = url.to_lowercase(); - if lower.contains("youtube.com/") || lower.contains("youtu.be/") { - if let Some(pos) = url.find('&') { + if (lower.contains("youtube.com/") || lower.contains("youtu.be/")) + && let Some(pos) = url.find('&') { return url[..pos].to_string(); } - } url.to_string() } @@ -241,7 +240,7 @@ async fn play_impl( ) -> Result<()> { ctx.defer().await?; - let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; let channel_id = ctx .serenity_context() @@ -252,7 +251,7 @@ async fn play_impl( .get(&ctx.author().id) .and_then(|vs| vs.channel_id) }) - .ok_or_else(|| lang::err_not_in_voice())?; + .ok_or_else(lang::err_not_in_voice)?; let src = resolve_source(ctx, melodia, file).await?; @@ -373,7 +372,7 @@ async fn play_impl( /// Stop playback and disconnect from voice #[poise::command(slash_command)] pub async fn stop(ctx: Context<'_>) -> Result<()> { - let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; ctx.data().voice_manager.disconnect(guild_id).await?; ctx.data().music_tracks.lock().await.remove(&guild_id.get()); ctx.send( @@ -389,7 +388,7 @@ pub async fn stop(ctx: Context<'_>) -> Result<()> { #[poise::command(slash_command)] pub async fn join(ctx: Context<'_>) -> Result<()> { ctx.defer().await?; - let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; let channel_id = ctx.serenity_context().cache.guild(guild_id).and_then(|g| { 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( ) -> Result<()> { let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) .await? - .ok_or_else(|| lang::err_trigger_not_found())?; + .ok_or_else(lang::err_trigger_not_found)?; let mut modified = false; if let Some(kw) = trigger_keywords { @@ -90,7 +90,7 @@ pub async fn remove( ) -> Result<()> { let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) .await? - .ok_or_else(|| lang::err_trigger_not_found())?; + .ok_or_else(lang::err_trigger_not_found)?; let mut modified = false; if let Some(kw) = trigger_keyword { @@ -119,7 +119,7 @@ pub async fn delete( ) -> Result<()> { let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) .await? - .ok_or_else(|| lang::err_trigger_not_found())?; + .ok_or_else(lang::err_trigger_not_found)?; database::delete_trigger(&ctx.data().db_pool, t.id).await?; ctx.data().reload_triggers().await; @@ -137,7 +137,7 @@ pub async fn list( ) -> Result<()> { let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) .await? - .ok_or_else(|| lang::err_trigger_not_found())?; + .ok_or_else(lang::err_trigger_not_found)?; let keywords = database::get_keywords(&ctx.data().db_pool, t.id).await?; let responses = database::get_responses(&ctx.data().db_pool, t.id).await?; @@ -153,7 +153,7 @@ pub async fn list( } else { keywords .iter() - .map(|r| format!("{r}")) + .map(|r| r.to_string()) .collect::>() .join("\n") }, @@ -166,7 +166,7 @@ pub async fn list( } else { responses .iter() - .map(|r| format!("{r}")) + .map(|r| r.to_string()) .collect::>() .join("\n") }, 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( .await?; } OutputMode::Vc => { - let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; let channel_id = ctx .serenity_context() .cache @@ -69,7 +69,7 @@ pub async fn tts( .get(&ctx.author().id) .and_then(|vs| vs.channel_id) }) - .ok_or_else(|| lang::err_not_in_voice())?; + .ok_or_else(lang::err_not_in_voice)?; let call = ctx .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)> { Ok(t) => t, Err(_) => continue, }; - if let Some((t, _)) = &newest { - if *t >= mtime { + if let Some((t, _)) = &newest + && *t >= mtime { continue; } - } newest = Some((mtime, entry.path().to_string_lossy().into_owned())); } newest.map(|(_, p)| { -- cgit v1.2.3