aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CONTEXT.md20
-rw-r--r--docs/adr/0001-pcm-ring-buffer-over-opus-storage.md54
-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
12 files changed, 333 insertions, 129 deletions
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..f39bb31
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,20 @@
1# CONTEXT
2
3Glossary of terms used in this codebase. This file is a glossary only — it
4holds no implementation decisions or specs (those live in `docs/adr/`).
5
6## Voice
7
8- **Voice activity** — per-user join / leave / AFK *timing metadata*, persisted
9 in the `voice_activity` table and updated by `events::handle_voice_state_update`.
10 Despite the name, it contains **no audio** — only timestamps and durations.
11- **Recording** — capturing the *audio* of a voice channel into a rolling
12 in-memory buffer while the bot is connected. Distinct from *voice activity*
13 (metadata). A bot "records" continuously while in a VC (unless opted out) and
14 "flushes" on `/record`.
15- **Window** — the retention horizon of the recording buffer: how many seconds
16 of the recent past are kept. Older audio is discarded as new audio arrives.
17 Configured via `RECORDING_WINDOW_SECONDS` (default 900 = 15 minutes).
18- **Flush** — the `/record` action: snapshot the current recording buffer to an
19 mp3 and upload it to Copyparty. Non-destructive — the buffer keeps recording
20 after a flush, so successive calls may overlap.
diff --git a/docs/adr/0001-pcm-ring-buffer-over-opus-storage.md b/docs/adr/0001-pcm-ring-buffer-over-opus-storage.md
new file mode 100644
index 0000000..24c9f98
--- /dev/null
+++ b/docs/adr/0001-pcm-ring-buffer-over-opus-storage.md
@@ -0,0 +1,54 @@
1# ADR 0001 — Mixed PCM ring buffer over per-SSRC Opus storage
2
3Date: 2026-07-08
4Status: Accepted
5
6## Context
7
8The voice-recording feature (`/record`) must keep a rolling, fixed-length
9window of everything spoken in the bot's current voice channel, so that a
10flush produces a single mixed audio file of (up to) the window length.
11
12songbird delivers incoming voice as one Opus stream per speaking SSRC. There
13were two viable storage strategies:
14
15- **(A) Mixed PCM ring buffer.** Decode each packet to PCM, sum all speakers
16 into one pre-allocated ring of mono i16 samples. Flush = encode the ring.
17- **(B) Raw Opus packets per SSRC.** Store each SSRC's compressed packets in a
18 timestamped deque. Flush = decode all, mix by timestamp, then encode.
19
20## Decision
21
22Store a single mixed mono-s16 PCM ring buffer (**A**), sized
23`sample_rate × window_seconds` (~86 MB at 48 kHz / 15 min), allocated only
24while the bot is connected and recording is enabled.
25
26## Rationale
27
28- **Matches the stated mental model.** The feature was specified as "a blob in
29 memory that has the newest 15 minutes, deletes the old, appends the new at
30 the end." (A) is the literal implementation of that; (B) stores N separate
31 per-user deques, which contradicts the "single blob, append at end" shape.
32- **Trivial flush.** (A) flushes by handing a known-size s16le buffer to one
33 ffmpeg encode. (B) requires bulk decode + timestamp-aligned mixing at flush
34 time — heavier and more failure-prone (drift, late packets, anchor resets).
35- **songbird already decodes.** With the `receive` feature and
36 `DecodeMode::Decode(Mono, 48kHz)`, songbird's `CoreEvent::VoiceTick` delivers
37 already-decoded, jitter-buffered mono i16 every 20 ms — so (A) needs no
38 per-SSRC Opus decoder, no `opus2` direct dependency, and no f32→s16 step.
39 (B) would re-derive work songbird already does.
40- **The memory cost is opt-in.** `RECORDING_ENABLED` defaults to `true` but a
41 memory-constrained host sets it `false` and pays 0. A host that enables
42 recording accepts the ~86 MB. `RECORDING_WINDOW_SECONDS` lets such a host
43 shrink the ring (e.g. 300 s → ~29 MB) without code changes.
44
45## Consequences
46
47- ~86 MB resident while recording (at the default 15 min / 48 kHz window).
48 Acceptable for a single-guild self-hosted bot; tunable down via the window.
49- Multi-speaker overlap is mixed with `i16::saturating_add`, which can clip
50 slightly on very loud overlaps rather than wrap to garbage. The right trade
51 for a 50 Hz × N-packet hot path.
52- No per-speaker attribution is possible from the stored buffer (the mix is
53 lossy by design). If attribution is ever needed, (B) would have to be
54 revisited — this decision is the main thing that makes that reversal costly.
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) {