aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-04 01:32:06 +0100
committersalmonik <salmonik@tuta.io>2026-11-04 01:32:06 +0100
commit382bb2af15e7076f8dcbbe6cc4a6b135709ef7b9 (patch)
tree5e03e559d05ff2510781d5b760f5737e4ff03580
parentcron: validate expressions and allocate ids atomically (diff)
downloadsenator-sperma-382bb2af15e7076f8dcbbe6cc4a6b135709ef7b9.tar.gz
senator-sperma-382bb2af15e7076f8dcbbe6cc4a6b135709ef7b9.zip
autocomplete: key results by extension filter and reject the placeholder
-rw-r--r--src/commands/autocomplete.rs24
-rw-r--r--src/commands/cron.rs5
-rw-r--r--src/commands/music.rs5
3 files changed, 27 insertions, 7 deletions
diff --git a/src/commands/autocomplete.rs b/src/commands/autocomplete.rs
index d05a48b..4cefd67 100644
--- a/src/commands/autocomplete.rs
+++ b/src/commands/autocomplete.rs
@@ -20,7 +20,7 @@ const AC_SEARCH_TIMEOUT: Duration = Duration::from_secs(2);
20 20
21/// Value used for the mock "searching" placeholder — recognisable by the 21/// Value used for the mock "searching" placeholder — recognisable by the
22/// command handler as a no-op. 22/// command handler as a no-op.
23const MOCK_SEARCHING: &str = "\u{231B}..."; 23pub(crate) const MOCK_SEARCHING: &str = "\u{231B}...";
24 24
25fn truncate_choice(s: &str) -> String { 25fn truncate_choice(s: &str) -> String {
26 if s.chars().count() <= MAX_CHOICE_LEN { 26 if s.chars().count() <= MAX_CHOICE_LEN {
@@ -98,6 +98,12 @@ type SearchCache = Arc<
98 tokio::sync::Mutex<HashMap<String, (Vec<copyparty::SearchHit>, std::time::Instant)>>, 98 tokio::sync::Mutex<HashMap<String, (Vec<copyparty::SearchHit>, std::time::Instant)>>,
99>; 99>;
100 100
101fn search_cache_key(partial: &str, extensions: &[&str]) -> String {
102 // The extension filter changes the result set, so it must be part of the
103 // key; otherwise `/music` and `/upload` poison each other's cache.
104 format!("{partial}\u{1f}{}", extensions.join(","))
105}
106
101fn spawn_search( 107fn spawn_search(
102 cache: SearchCache, 108 cache: SearchCache,
103 client: copyparty::CopypartyClient, 109 client: copyparty::CopypartyClient,
@@ -114,10 +120,10 @@ fn spawn_search(
114 Ok(hits) => { 120 Ok(hits) => {
115 debug!(%query, count = hits.len(), "background search completed"); 121 debug!(%query, count = hits.len(), "background search completed");
116 // Always fill the shared search cache 122 // Always fill the shared search cache
117 cache 123 cache.lock().await.insert(
118 .lock() 124 search_cache_key(&query, &exts),
119 .await 125 (hits.clone(), std::time::Instant::now()),
120 .insert(query, (hits.clone(), std::time::Instant::now())); 126 );
121 // Send to foreground (ignores error if receiver was dropped) 127 // Send to foreground (ignores error if receiver was dropped)
122 let _ = tx.send(hits); 128 let _ = tx.send(hits);
123 } 129 }
@@ -143,8 +149,14 @@ async fn timed_search(
143 partial: &str, 149 partial: &str,
144 extensions: &[&str], 150 extensions: &[&str],
145) -> Vec<serenity::AutocompleteChoice> { 151) -> Vec<serenity::AutocompleteChoice> {
152 // The placeholder is a UI hint, not a real query.
153 if partial == MOCK_SEARCHING {
154 return vec![];
155 }
156
146 // 1. Shared search cache hit? 157 // 1. Shared search cache hit?
147 if let Some(hits) = ctx.data().ac_search_cache_lookup(partial).await 158 let cache_key = search_cache_key(partial, extensions);
159 if let Some(hits) = ctx.data().ac_search_cache_lookup(&cache_key).await
148 && !hits.is_empty() { 160 && !hits.is_empty() {
149 debug!(%partial, count = hits.len(), "autocomplete cache hit"); 161 debug!(%partial, count = hits.len(), "autocomplete cache hit");
150 let pairs = build_choices(&hits); 162 let pairs = build_choices(&hits);
diff --git a/src/commands/cron.rs b/src/commands/cron.rs
index 94cf955..ec5b702 100644
--- a/src/commands/cron.rs
+++ b/src/commands/cron.rs
@@ -34,6 +34,11 @@ pub async fn dodaj_audio(
34 _ => return Err(lang::err_bad_channel_type()), 34 _ => return Err(lang::err_bad_channel_type()),
35 }; 35 };
36 36
37 if audio_filename == crate::commands::autocomplete::MOCK_SEARCHING {
38 ctx.say(lang::L_UTIL_PICK_FROM_AUTOCOMPLETE).await?;
39 return Ok(());
40 }
41
37 // Resolve full path from autocomplete cache (value is truncated to 99 chars by Discord) 42 // Resolve full path from autocomplete cache (value is truncated to 99 chars by Discord)
38 let full_url = if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &audio_filename).await { 43 let full_url = if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &audio_filename).await {
39 let path = hit.url_path().unwrap_or(&audio_filename); 44 let path = hit.url_path().unwrap_or(&audio_filename);
diff --git a/src/commands/music.rs b/src/commands/music.rs
index baa91b1..51bd619 100644
--- a/src/commands/music.rs
+++ b/src/commands/music.rs
@@ -3,7 +3,7 @@ use poise::serenity_prelude as serenity;
3 3
4use tracing::{error, info}; 4use tracing::{error, info};
5 5
6use super::autocomplete::audio_file; 6use super::autocomplete::{MOCK_SEARCHING, audio_file};
7use crate::Data; 7use crate::Data;
8use crate::copyparty::{html_decode, percent_decode}; 8use crate::copyparty::{html_decode, percent_decode};
9use crate::error::{Error, Result}; 9use crate::error::{Error, Result};
@@ -87,6 +87,9 @@ async fn resolve_source(
87 } 87 }
88 88
89 let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?; 89 let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?;
90 if name == MOCK_SEARCHING {
91 return Err(Error::Custom(lang::L_UTIL_PICK_FROM_AUTOCOMPLETE.into()));
92 }
90 93
91 // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─ 94 // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─
92 if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await { 95 if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await {