diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-27 18:18:07 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-27 18:18:07 +0100 |
| commit | bab3f614f8b42545fdb0101c4e353c87e0966d47 (patch) | |
| tree | c257bc4712bba088bea2eab42d93b07e9cfed89c | |
| parent | music: add per-guild queue with now/next/last, skip and queue (diff) | |
| download | senator-sperma-bab3f614f8b42545fdb0101c4e353c87e0966d47.tar.gz senator-sperma-bab3f614f8b42545fdb0101c4e353c87e0966d47.zip | |
music: group playback commands under /music
Graduate grajcuj/skip/queue/stop/join into the /music parent and drop the
top-level names. On startup, reconcile global slash commands and delete
any that are no longer registered, since Discord otherwise keeps stale
commands after a rename or move.
Docs updated for the new command layout and queue semantics.
| -rw-r--r-- | AGENTS.md | 8 | ||||
| -rw-r--r-- | CONTEXT.md | 12 | ||||
| -rw-r--r-- | README.md | 10 | ||||
| -rw-r--r-- | src/commands/music.rs | 28 | ||||
| -rw-r--r-- | src/main.rs | 39 |
5 files changed, 66 insertions, 31 deletions
| @@ -7,12 +7,13 @@ src/ | |||
| 7 | ├── main.rs — Entry: init tracing, dotenvy, build Config/Data, start poise+serenity | 7 | ├── main.rs — Entry: init tracing, dotenvy, build Config/Data, start poise+serenity |
| 8 | ├── config.rs — Config struct (env-driven), pub const TEMP_PATH | 8 | ├── config.rs — Config struct (env-driven), pub const TEMP_PATH |
| 9 | ├── error.rs — Unified Error enum + From impls, pub type Result<T> | 9 | ├── error.rs — Unified Error enum + From impls, pub type Result<T> |
| 10 | ├── music.rs — MusicState: per-guild queue, now/next/last, skip, auto-advance | ||
| 10 | ├── commands/ | 11 | ├── commands/ |
| 11 | │ ├── mod.rs — all_commands() aggregates every submodule | 12 | │ ├── mod.rs — all_commands() aggregates every submodule |
| 12 | │ ├── ai.rs — /senator (OpenRouter chat + optional TTS) | 13 | │ ├── ai.rs — /senator (OpenRouter chat + optional TTS) |
| 13 | │ ├── cron.rs — /cron group (6 subcommands) | 14 | │ ├── cron.rs — /cron group (6 subcommands) |
| 14 | │ ├── math.rs — /math (qalc subprocess) | 15 | │ ├── math.rs — /math (qalc subprocess) |
| 15 | │ ├── music.rs — /play (🟡 stub), /stop, /join, /loop_toggle | 16 | │ ├── music.rs — /music group (grajcuj, skip, queue, stop, join) + context menu |
| 16 | │ ├── qr.rs — /qr encode + decode | 17 | │ ├── qr.rs — /qr encode + decode |
| 17 | │ ├── trigger.rs — /trigger group (6 subcommands) | 18 | │ ├── trigger.rs — /trigger group (6 subcommands) |
| 18 | │ ├── tts.rs — /tts (local Piper TTS → file/vc), /log_volume | 19 | │ ├── tts.rs — /tts (local Piper TTS → file/vc), /log_volume |
| @@ -41,12 +42,11 @@ src/ | |||
| 41 | - `build.rs` — compiles C stubs for espeak-rs-sys missing sonic/audio_object symbols | 42 | - `build.rs` — compiles C stubs for espeak-rs-sys missing sonic/audio_object symbols |
| 42 | 43 | ||
| 43 | ## Stubs to Fill (see COMPARISON.md) | 44 | ## Stubs to Fill (see COMPARISON.md) |
| 44 | - `commands/music.rs` — `/play` is placeholder | ||
| 45 | - `commands/utilities.rs` — `/compress`, `/convert`, `/download` upload part | 45 | - `commands/utilities.rs` — `/compress`, `/convert`, `/download` upload part |
| 46 | - `tasks/mod.rs` — cron execution bodies are `info!` only, no actual action | 46 | - `tasks/mod.rs` — cron execution bodies are `info!` only, no actual action |
| 47 | 47 | ||
| 48 | ## Database Tables | 48 | ## Database Tables |
| 49 | `settings` (key/value), `voice_activity`, `cron_jobs`, `triggers`, `trigger_keywords`, `trigger_responses` | 49 | `settings` (key/value), `voice_activity`, `cron_jobs`, `triggers`, `trigger_keywords`, `trigger_responses` |
| 50 | 50 | ||
| 51 | ## All Commands (26 total) | 51 | ## Commands |
| 52 | `/senator`, `/cron` (6), `/math`, `/play`, `/stop`, `/join`, `/loop_toggle`, `/qr` (2), `/trigger` (6), `/tts`, `/log_volume`, `/compress`, `/convert`, `/upload`, `/search`, `/download`, `/say`, `/web` | 52 | `/senator`, `/cron` (6), `/math`, `/music` (5: grajcuj, skip, queue, stop, join), `/qr` (2), `/trigger` (6), `/tts`, `/log_volume`, `/compress`, `/convert`, `/upload`, `/search`, `/download`, `/say`, `/web`, `/record`, `/leaderboard` |
| @@ -50,7 +50,17 @@ holds no implementation decisions or specs (those live in `docs/adr/`). | |||
| 50 | 50 | ||
| 51 | ## Music Playback | 51 | ## Music Playback |
| 52 | 52 | ||
| 53 | - **Compressed playback** — `/grajcuj \w compressed`: downloads audio, runs | 53 | - **Queue** — per-guild, in-memory list of tracks waiting to play. Lost on |
| 54 | restart. `/music queue` shows the current track first, then the pending | ||
| 55 | items in play order. | ||
| 56 | - **Position** — where `/music grajcuj` inserts a track: `now` interrupts the | ||
| 57 | current track, `next` inserts after it, `last` appends (default). When | ||
| 58 | nothing is playing, every position starts playback immediately. | ||
| 59 | - **Skip** — stop the current track and play the next queued one. With an | ||
| 60 | empty queue, playback stops but the bot stays in the voice channel. | ||
| 61 | - **Auto-advance** — when a track ends (or errors), the next queued track is | ||
| 62 | started automatically and announced in the channel. | ||
| 63 | - **Compressed playback** — `/music grajcuj ... compressed`: downloads audio, runs | ||
| 54 | through the /compress pipeline (Opus 500bps, 12 kHz, mono → OGG), then | 64 | through the /compress pipeline (Opus 500bps, 12 kHz, mono → OGG), then |
| 55 | decodes the compressed file via symphonia for playback. Produces properly | 65 | decodes the compressed file via symphonia for playback. Produces properly |
| 56 | destroyed audio via Opus codec, not just raw low-sample-rate PCM. | 66 | destroyed audio via Opus codec, not just raw low-sample-rate PCM. |
| @@ -66,9 +66,11 @@ servers. | |||
| 66 | - `/senator` — chat with the OpenRouter model | 66 | - `/senator` — chat with the OpenRouter model |
| 67 | 67 | ||
| 68 | ### Music (songbird) | 68 | ### Music (songbird) |
| 69 | - `/grajcuj` — play from Copyparty search, a direct URL, or an uploaded file | 69 | - `/music grajcuj` — play from Copyparty search, a direct URL, or an uploaded file; optional `position` (`now`, `next`, `last`, default `last`) |
| 70 | - `/stop` — stop playback and leave voice | 70 | - `/music queue` — show the current track followed by the pending queue |
| 71 | - `/join` — join the caller's voice channel | 71 | - `/music skip` — stop the current track and play the next queued one |
| 72 | - `/music stop` — stop playback, clear the queue, and leave voice | ||
| 73 | - `/music join` — join the caller's voice channel | ||
| 72 | 74 | ||
| 73 | ### TTS | 75 | ### TTS |
| 74 | - `/tts` — local Piper TTS (Polish, Google Translate fallback), sent as a file or played in voice | 76 | - `/tts` — local Piper TTS (Polish, Google Translate fallback), sent as a file or played in voice |
| @@ -112,7 +114,7 @@ servers. | |||
| 112 | 114 | ||
| 113 | The runtime is organised around a shared `Data` struct (poise `user_data`) | 115 | The runtime is organised around a shared `Data` struct (poise `user_data`) |
| 114 | that holds the DB pool, HTTP client, Copyparty client, voice manager, the | 116 | that holds the DB pool, HTTP client, Copyparty client, voice manager, the |
| 115 | in-memory trigger cache, autocomplete cache, and per-guild music track handles. | 117 | in-memory trigger cache, autocomplete cache, and per-guild music queue. |
| 116 | 118 | ||
| 117 | - `src/main.rs` — boot: tracing, dotenvy, Config, DB pool, framework, cron task | 119 | - `src/main.rs` — boot: tracing, dotenvy, Config, DB pool, framework, cron task |
| 118 | - `src/config.rs` — env-driven `Config` + variable validation | 120 | - `src/config.rs` — env-driven `Config` + variable validation |
diff --git a/src/commands/music.rs b/src/commands/music.rs index 8841329..26e655e 100644 --- a/src/commands/music.rs +++ b/src/commands/music.rs | |||
| @@ -15,7 +15,10 @@ type Context<'a> = poise::Context<'a, Data, Error>; | |||
| 15 | // ── Source classification ────────────────────────────────────────── | 15 | // ── Source classification ────────────────────────────────────────── |
| 16 | 16 | ||
| 17 | /// Music playback and queue management. | 17 | /// Music playback and queue management. |
| 18 | #[poise::command(slash_command, subcommands("grajcuj", "skip", "queue"))] | 18 | #[poise::command( |
| 19 | slash_command, | ||
| 20 | subcommands("grajcuj", "skip", "queue", "stop", "join") | ||
| 21 | )] | ||
| 19 | pub async fn music(_ctx: Context<'_>) -> Result<()> { | 22 | pub async fn music(_ctx: Context<'_>) -> Result<()> { |
| 20 | Ok(()) | 23 | Ok(()) |
| 21 | } | 24 | } |
| @@ -29,25 +32,8 @@ pub async fn grajcuj( | |||
| 29 | melodia: Option<String>, | 32 | melodia: Option<String>, |
| 30 | #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>, | 33 | #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>, |
| 31 | #[description = "Chujowa kompresja!?"] compressed: Option<bool>, | 34 | #[description = "Chujowa kompresja!?"] compressed: Option<bool>, |
| 32 | #[description = "Gdzie dodać utwór w kolejce (domyślnie: last)"] position: Option< | 35 | #[description = "Gdzie dodać utwór w kolejce (domyślnie: last)"] |
| 33 | QueuePosition, | 36 | position: Option<QueuePosition>, |
| 34 | >, | ||
| 35 | ) -> Result<()> { | ||
| 36 | play_command(ctx, melodia, file, compressed, position).await | ||
| 37 | } | ||
| 38 | |||
| 39 | /// Play music from Copyparty, URL, or file upload (top-level alias) | ||
| 40 | #[poise::command(slash_command, rename = "grajcuj")] | ||
| 41 | pub async fn grajcuj_top( | ||
| 42 | ctx: Context<'_>, | ||
| 43 | #[description = "Plik do odtworzenia"] | ||
| 44 | #[autocomplete = "audio_file"] | ||
| 45 | melodia: Option<String>, | ||
| 46 | #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>, | ||
| 47 | #[description = "Chujowa kompresja!?"] compressed: Option<bool>, | ||
| 48 | #[description = "Gdzie dodać utwór w kolejce (domyślnie: last)"] position: Option< | ||
| 49 | QueuePosition, | ||
| 50 | >, | ||
| 51 | ) -> Result<()> { | 37 | ) -> Result<()> { |
| 52 | play_command(ctx, melodia, file, compressed, position).await | 38 | play_command(ctx, melodia, file, compressed, position).await |
| 53 | } | 39 | } |
| @@ -404,5 +390,5 @@ pub async fn message_grajcuj(ctx: Context<'_>, message: serenity::Message) -> Re | |||
| 404 | } | 390 | } |
| 405 | 391 | ||
| 406 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | 392 | pub fn commands() -> Vec<poise::Command<Data, Error>> { |
| 407 | vec![music(), grajcuj_top(), stop(), join(), message_grajcuj()] | 393 | vec![music(), message_grajcuj()] |
| 408 | } | 394 | } |
diff --git a/src/main.rs b/src/main.rs index 4e308b8..8d11d76 100644 --- a/src/main.rs +++ b/src/main.rs | |||
| @@ -1,4 +1,4 @@ | |||
| 1 | use std::collections::HashMap; | 1 | use std::collections::{HashMap, HashSet}; |
| 2 | use std::sync::Arc; | 2 | use std::sync::Arc; |
| 3 | use std::time::{Duration, Instant}; | 3 | use std::time::{Duration, Instant}; |
| 4 | 4 | ||
| @@ -236,6 +236,43 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | |||
| 236 | 236 | ||
| 237 | poise::builtins::register_globally(ctx, &framework.options().commands).await?; | 237 | poise::builtins::register_globally(ctx, &framework.options().commands).await?; |
| 238 | 238 | ||
| 239 | // Discord keeps global commands that disappear from the | ||
| 240 | // registration payload. Delete leftovers from renamed or | ||
| 241 | // removed commands so the move under `/music` is visible. | ||
| 242 | let expected: HashSet<String> = framework | ||
| 243 | .options() | ||
| 244 | .commands | ||
| 245 | .iter() | ||
| 246 | .map(|command| { | ||
| 247 | command | ||
| 248 | .context_menu_name | ||
| 249 | .clone() | ||
| 250 | .unwrap_or_else(|| command.name.clone()) | ||
| 251 | }) | ||
| 252 | .collect(); | ||
| 253 | match ctx.http.get_global_commands().await { | ||
| 254 | Ok(existing) => { | ||
| 255 | for command in existing { | ||
| 256 | if !expected.contains(&command.name) { | ||
| 257 | match ctx.http.delete_global_command(command.id).await { | ||
| 258 | Ok(()) => tracing::info!( | ||
| 259 | command = %command.name, | ||
| 260 | "removed stale global command" | ||
| 261 | ), | ||
| 262 | Err(e) => tracing::warn!( | ||
| 263 | command = %command.name, | ||
| 264 | error = %e, | ||
| 265 | "failed to remove stale global command" | ||
| 266 | ), | ||
| 267 | } | ||
| 268 | } | ||
| 269 | } | ||
| 270 | } | ||
| 271 | Err(e) => { | ||
| 272 | tracing::warn!(error = %e, "could not list global commands for cleanup") | ||
| 273 | } | ||
| 274 | } | ||
| 275 | |||
| 239 | events::handle_ready(ctx).await.map_err(|e| { | 276 | events::handle_ready(ctx).await.map_err(|e| { |
| 240 | tracing::error!("on_ready handler: {e}"); | 277 | tracing::error!("on_ready handler: {e}"); |
| 241 | e | 278 | e |
