diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-04 01:33:33 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-04 01:33:33 +0100 |
| commit | 97a26ec4e64a98a24dad7969337d2569339db9a7 (patch) | |
| tree | ada17e2b1f50d053a6bfe4067296e06de848ac87 | |
| parent | config: harden the shared temp directory (diff) | |
| download | senator-sperma-97a26ec4e64a98a24dad7969337d2569339db9a7.tar.gz senator-sperma-97a26ec4e64a98a24dad7969337d2569339db9a7.zip | |
docs: sync with current behavior
| -rw-r--r-- | .env.example | 6 | ||||
| -rw-r--r-- | AGENTS.md | 49 | ||||
| -rw-r--r-- | CONTEXT.md | 13 | ||||
| -rw-r--r-- | README.md | 21 | ||||
| -rw-r--r-- | src/config.rs | 8 |
5 files changed, 57 insertions, 40 deletions
diff --git a/.env.example b/.env.example index e67827d..83d3902 100644 --- a/.env.example +++ b/.env.example | |||
| @@ -22,8 +22,14 @@ LLM_MODEL= | |||
| 22 | OPENROUTER_API_KEY= | 22 | OPENROUTER_API_KEY= |
| 23 | OPENROUTER_MODEL= | 23 | OPENROUTER_MODEL= |
| 24 | 24 | ||
| 25 | # Optional - Voice recording (opt-in; no audio is kept when false) | ||
| 26 | RECORDING_ENABLED=false | ||
| 27 | # Rolling buffer length in seconds (1-3600). | ||
| 28 | RECORDING_WINDOW_SECONDS=900 | ||
| 29 | |||
| 25 | # Optional - Local TTS | 30 | # Optional - Local TTS |
| 26 | # Directory containing <voice>.onnx, or a full path to the .onnx file. | 31 | # Directory containing <voice>.onnx, or a full path to the .onnx file. |
| 32 | # There is no external TTS fallback; leave unset to disable TTS. | ||
| 27 | TTS_MODEL_PATH=/path/to/piper | 33 | TTS_MODEL_PATH=/path/to/piper |
| 28 | TTS_VOICE=pl_PL-bass-high | 34 | TTS_VOICE=pl_PL-bass-high |
| 29 | # Parent directory of espeak-ng-data. Docker overrides this to /usr/share. | 35 | # Parent directory of espeak-ng-data. Docker overrides this to /usr/share. |
| @@ -4,28 +4,33 @@ | |||
| 4 | 4 | ||
| 5 | ``` | 5 | ``` |
| 6 | src/ | 6 | src/ |
| 7 | ├── main.rs — Entry: init tracing, dotenvy, build Config/Data, start poise+serenity | 7 | ├── main.rs — Entry: tracing, dotenvy, temp dir, Config/Data, poise+serenity |
| 8 | ├── config.rs — Config struct (env-driven), pub const TEMP_PATH | 8 | ├── config.rs — Config struct (env-driven), temp dir init/access |
| 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 | ├── net.rs — public-IP check for user-supplied URLs (SSRF guard) | ||
| 10 | ├── music.rs — MusicState: per-guild queue, now/next/last, skip, auto-advance | 11 | ├── music.rs — MusicState: per-guild queue, now/next/last, skip, auto-advance |
| 12 | ├── recording/mod.rs — per-guild rolling PCM recorder for /record | ||
| 13 | ├── tts.rs — TtsState (piper-rs in-process, bounded DashMap cache, resampler, WAV encoder) | ||
| 11 | ├── commands/ | 14 | ├── commands/ |
| 12 | │ ├── mod.rs — all_commands() aggregates every submodule | 15 | │ ├── mod.rs — all_commands() aggregates every submodule |
| 13 | │ ├── ai.rs — /senator (OpenRouter chat + optional TTS) | 16 | │ ├── ai.rs — /senator (LLM chat + optional TTS) |
| 17 | │ ├── autocomplete.rs — Copyparty autocomplete + trigger_name | ||
| 14 | │ ├── cron.rs — /cron group (6 subcommands) | 18 | │ ├── cron.rs — /cron group (6 subcommands) |
| 15 | │ ├── math.rs — /math (qalc subprocess) | 19 | │ ├── leaderboard.rs — /leaderboard group (me/messages/active/afk/total) |
| 20 | │ ├── math.rs — /math (qalc subprocess, 5s timeout) | ||
| 16 | │ ├── music.rs — /music group (grajcuj, skip, queue, stop, join) + context menu | 21 | │ ├── music.rs — /music group (grajcuj, skip, queue, stop, join) + context menu |
| 17 | │ ├── qr.rs — /qr encode + decode | 22 | │ ├── qr.rs — /qr encode + decode (dimension/allocation limits) |
| 23 | │ ├── record.rs — /record (flush this guild's buffer to Copyparty) | ||
| 18 | │ ├── trigger.rs — /trigger group (6 subcommands) | 24 | │ ├── trigger.rs — /trigger group (6 subcommands) |
| 19 | │ ├── tts.rs — /tts (local Piper TTS → file/vc), /log_volume | 25 | │ ├── tts.rs — /tts (local Piper TTS → file/vc) |
| 20 | │ ├── utilities.rs — compress🟡, convert🟡, upload, search, download, say | 26 | │ ├── utilities.rs — compress, convert, upload, search, download, say |
| 21 | │ └── web.rs — /web (DuckDuckGo scrape) | 27 | │ └── web.rs — /web (DuckDuckGo scrape) |
| 22 | ├── copyparty/mod.rs — CopypartyClient (search, upload) | 28 | ├── copyparty/mod.rs — CopypartyClient (search, upload, credential-safe URLs) |
| 23 | ├── database/mod.rs — DbPool, 21 query fns, 6 tables, migration | 29 | ├── database/mod.rs — DbPool, migrations, query helpers (7 tables) |
| 24 | ├── events/mod.rs — handle_ready, handle_voice_state_update, handle_message | 30 | ├── events/mod.rs — handle_ready, voice-state, message (triggers + LLM) |
| 25 | ├── openrouter/mod.rs — OpenRouterClient (chat, chat_with_history) | 31 | ├── openrouter/mod.rs — OpenRouterClient (chat, chat_with_history, timeouts) |
| 26 | ├── tasks/mod.rs — cron_scheduler_loop (30s polling, stub exec bodies) | 32 | ├── tasks/mod.rs — cron_scheduler_loop (60s polling, real execution) |
| 27 | ├── tts.rs — TtsState (piper-rs in-process, DashMap cache, pre-gen, resampler, WAV encoder) | 33 | └── voice/mod.rs — VoiceManager (connect, disconnect, get_call), ffmpeg/symphonia inputs |
| 28 | └── voice/mod.rs — VoiceManager (connect, disconnect, get_call), mp3_to_input | ||
| 29 | ``` | 34 | ``` |
| 30 | 35 | ||
| 31 | ## Key Conventions | 36 | ## Key Conventions |
| @@ -35,18 +40,18 @@ src/ | |||
| 35 | - **Errors**: `crate::error::Error` enum with `?` propagation; use `Error::Custom(string)` for ad-hoc | 40 | - **Errors**: `crate::error::Error` enum with `?` propagation; use `Error::Custom(string)` for ad-hoc |
| 36 | - **Shared state**: `Data` struct in `main.rs` — passed as poise `user_data` | 41 | - **Shared state**: `Data` struct in `main.rs` — passed as poise `user_data` |
| 37 | - **Commands**: each submodule exports `pub fn commands() -> Vec<poise::Command<Data, Error>>` | 42 | - **Commands**: each submodule exports `pub fn commands() -> Vec<poise::Command<Data, Error>>` |
| 38 | - **Temp path**: always use `crate::config::TEMP_PATH` (resolves to `/tmp/senator_sperma`) | 43 | - **Permissions**: admin commands use `required_permissions`; recording/cron/trigger are `guild_only` and scoped by `guild_id` in the DB |
| 39 | - **TTS**: piper-rs in-process (no subprocess). `TtsState` in `Data` holds `OnceLock<Mutex<Piper>>` + `DashMap` cache. Synthesize via `data.tts.synthesize(text)`. Pre-generates 6 event phrases per user on first voice event. No Google TTS fallback. | 44 | - **Temp path**: always use `crate::config::temp_path()` (initialized with mode `0700`; never hardcode `/tmp/senator_sperma`) |
| 45 | - **Outbound URLs**: user-supplied `http(s)` URLs go through `crate::net::ensure_public_http_url` unless they belong to the configured Copyparty origin | ||
| 46 | - **TTS**: piper-rs in-process (no subprocess). `TtsState` holds `OnceLock<Arc<std::sync::Mutex<Piper>>>` + a bounded `DashMap` cache. Synthesize via `data.tts.synthesize(text)`; work runs on `spawn_blocking`. No external TTS fallback. | ||
| 47 | - **Recording**: opt-in (`RECORDING_ENABLED=false` by default), one buffer per guild. | ||
| 40 | 48 | ||
| 41 | ## Build | 49 | ## Build |
| 42 | - `build.rs` — compiles C stubs for espeak-rs-sys missing sonic/audio_object symbols | 50 | - `build.rs` — compiles C stubs for espeak-rs-sys missing sonic/audio_object symbols |
| 43 | 51 | ||
| 44 | ## Stubs to Fill (see COMPARISON.md) | ||
| 45 | - `commands/utilities.rs` — `/compress`, `/convert`, `/download` upload part | ||
| 46 | - `tasks/mod.rs` — cron execution bodies are `info!` only, no actual action | ||
| 47 | |||
| 48 | ## Database Tables | 52 | ## Database Tables |
| 49 | `settings` (key/value), `voice_activity`, `cron_jobs`, `triggers`, `trigger_keywords`, `trigger_responses` | 53 | `settings`, `voice_activity`, `user_stats`, `cron_jobs`, `triggers`, `trigger_keywords`, `trigger_responses` |
| 50 | 54 | ||
| 51 | ## Commands | 55 | ## Commands |
| 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` | 56 | `/senator`, `/cron` (6), `/leaderboard` (5), `/math`, `/music` (5: grajcuj, skip, queue, stop, join), `/qr` (2), `/trigger` (6), `/tts`, `/record`, `/compress`, `/convert`, `/upload`, `/search`, `/download`, `/say`, `/web`. |
| 57 | Context menus: "Odtwórz", "Wyślij załączniki", "kompresja", "Pobierz z linku". | ||
| @@ -10,8 +10,8 @@ holds no implementation decisions or specs (those live in `docs/adr/`). | |||
| 10 | Despite the name, it contains **no audio** — only timestamps and durations. | 10 | Despite the name, it contains **no audio** — only timestamps and durations. |
| 11 | - **Recording** — capturing the *audio* of a voice channel into a rolling | 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* | 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 | 13 | (metadata). Recording is opt-in via `RECORDING_ENABLED` (default false); when |
| 14 | "flushes" on `/record`. | 14 | enabled a bot records continuously while in a VC and "flushes" on `/record`. |
| 15 | - **Window** — the retention horizon of the recording buffer: how many seconds | 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. | 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). | 17 | Configured via `RECORDING_WINDOW_SECONDS` (default 900 = 15 minutes). |
| @@ -40,10 +40,11 @@ holds no implementation decisions or specs (those live in `docs/adr/`). | |||
| 40 | at the model's native sample rate (typically 22050 Hz mono), resampled to | 40 | at the model's native sample rate (typically 22050 Hz mono), resampled to |
| 41 | 48 kHz stereo via inline linear interpolation before playback. Songbird does | 41 | 48 kHz stereo via inline linear interpolation before playback. Songbird does |
| 42 | not resample — playing un-resampled audio results in wrong playback speed. | 42 | not resample — playing un-resampled audio results in wrong playback speed. |
| 43 | - **TtsState** — struct held in poise `Data`: `OnceCell<Mutex<Piper>>` for the | 43 | - **TtsState** — struct held in poise `Data`: `OnceLock<Arc<std::sync::Mutex<Piper>>>` |
| 44 | model instance, `DashMap<String, (Vec<u8>, Instant)>` for the text→audio cache. | 44 | for the model instance, `DashMap<String, (Vec<u8>, Instant)>` for the |
| 45 | Piper loaded lazily on first TTS call. Cache keyed by full phrase string, | 45 | text→audio cache. Piper is loaded lazily on first TTS call, off the async |
| 46 | evicted by TTL. No Google TTS fallback. | 46 | runtime. Cache is keyed by the full phrase, bounded by entries/bytes, and |
| 47 | evicted by TTL. No external TTS fallback. | ||
| 47 | - **Pre-generation** — on first voice event for a user, synthesize all 6 event | 48 | - **Pre-generation** — on first voice event for a user, synthesize all 6 event |
| 48 | phrases (join/leave/deafen/undeafen/mute/unmute) with their display name and | 49 | phrases (join/leave/deafen/undeafen/mute/unmute) with their display name and |
| 49 | cache them. ~120ms first time, instant after. Lazy — no startup cost. | 50 | cache them. ~120ms first time, instant after. Lazy — no startup cost. |
| @@ -24,7 +24,7 @@ The following binaries must be available on `PATH`: | |||
| 24 | - `imagemagick` (`magick`) — image compression and conversion for `/compress` and `/convert` | 24 | - `imagemagick` (`magick`) — image compression and conversion for `/compress` and `/convert` |
| 25 | - `qalc` (Qalculate) — used by `/math` to evaluate expressions | 25 | - `qalc` (Qalculate) — used by `/math` to evaluate expressions |
| 26 | - `yt-dlp` (optional) — used by `/download` | 26 | - `yt-dlp` (optional) — used by `/download` |
| 27 | - `piper` (optional) — local TTS for `/tts` and `/senator ... tts_vc`; falls back to Google Translate TTS if unavailable | 27 | - `piper` model files (optional) — local TTS for `/tts` and `/senator ... tts_vc`; TTS commands fail when unset |
| 28 | 28 | ||
| 29 | Unlike the Python version, QR encode/decode is handled by the pure-Rust | 29 | Unlike the Python version, QR encode/decode is handled by the pure-Rust |
| 30 | `qrcode`/`rxing` crates — no `zbar` system library required. | 30 | `qrcode`/`rxing` crates — no `zbar` system library required. |
| @@ -48,8 +48,8 @@ created automatically on first run. | |||
| 48 | 48 | ||
| 49 | For local TTS, set `TTS_MODEL_PATH` to a Piper `.onnx` model file (or a | 49 | For local TTS, set `TTS_MODEL_PATH` to a Piper `.onnx` model file (or a |
| 50 | directory of models, in which case `TTS_VOICE` selects the model, defaulting to | 50 | directory of models, in which case `TTS_VOICE` selects the model, defaulting to |
| 51 | `pl_PL-bass-high`). When `TTS_MODEL_PATH` is unset or Piper is unavailable, the | 51 | `pl_PL-bass-high`). There is no external TTS fallback: TTS commands fail when |
| 52 | bot falls back to Google Translate TTS. | 52 | the model is missing. |
| 53 | 53 | ||
| 54 | ### 5. Run | 54 | ### 5. Run |
| 55 | 55 | ||
| @@ -132,7 +132,7 @@ servers. | |||
| 132 | - `/music join` — join the caller's voice channel | 132 | - `/music join` — join the caller's voice channel |
| 133 | 133 | ||
| 134 | ### TTS | 134 | ### TTS |
| 135 | - `/tts` — local Piper TTS (Polish, Google Translate fallback), sent as a file or played in voice | 135 | - `/tts` — local Piper TTS (Polish), sent as a file or played in voice |
| 136 | 136 | ||
| 137 | ### Search & Web | 137 | ### Search & Web |
| 138 | - `/web` — DuckDuckGo HTML scrape (soon to use 4get) | 138 | - `/web` — DuckDuckGo HTML scrape (soon to use 4get) |
| @@ -148,6 +148,8 @@ servers. | |||
| 148 | - `/say` — repeat the given text as the bot | 148 | - `/say` — repeat the given text as the bot |
| 149 | - `/math` — evaluate an ASCII math expression through `qalc` | 149 | - `/math` — evaluate an ASCII math expression through `qalc` |
| 150 | - `/qr generowanie` / `/qr dekodowanie` — QR encode / decode | 150 | - `/qr generowanie` / `/qr dekodowanie` — QR encode / decode |
| 151 | - `/record` — upload the last recording window (requires `MANAGE_GUILD`) | ||
| 152 | - `/leaderboard` — `me`, `messages`, `active`, `afk`, `total` | ||
| 151 | 153 | ||
| 152 | ### Triggers | 154 | ### Triggers |
| 153 | - `/trigger create` — new trigger group | 155 | - `/trigger create` — new trigger group |
| @@ -168,6 +170,7 @@ servers. | |||
| 168 | ### Message Context Menu | 170 | ### Message Context Menu |
| 169 | - "Wyślij załączniki" (right-click message) — upload attachments to Copyparty | 171 | - "Wyślij załączniki" (right-click message) — upload attachments to Copyparty |
| 170 | - "kompresja" (right-click message) — compress all attachments | 172 | - "kompresja" (right-click message) — compress all attachments |
| 173 | - "Pobierz z linku" (right-click message) — download every URL in the message | ||
| 171 | 174 | ||
| 172 | ## Architecture | 175 | ## Architecture |
| 173 | 176 | ||
| @@ -181,10 +184,12 @@ in-memory trigger cache, autocomplete cache, and per-guild music queue. | |||
| 181 | - `src/copyparty/` — Copyparty search/upload client | 184 | - `src/copyparty/` — Copyparty search/upload client |
| 182 | - `src/openrouter/` — OpenRouter chat client | 185 | - `src/openrouter/` — OpenRouter chat client |
| 183 | - `src/database/` — sqlx pool, migrations, query helpers (6 tables) | 186 | - `src/database/` — sqlx pool, migrations, query helpers (6 tables) |
| 184 | - `src/voice/` — `VoiceManager`, mp3 → songbird input | 187 | - `src/voice/` — `VoiceManager`, ffmpeg/symphonia → songbird input |
| 188 | - `src/net.rs` — blocks private/link-local hosts for user-supplied URLs | ||
| 189 | - `src/recording/` — per-guild rolling PCM buffer for `/record` | ||
| 185 | - `src/events/` — `handle_ready`, voice-state, message (trigger matching) | 190 | - `src/events/` — `handle_ready`, voice-state, message (trigger matching) |
| 186 | - `src/tasks/` — 30s cron scheduler poll loop | 191 | - `src/tasks/` — 60s cron scheduler poll loop |
| 187 | - `src/lang.rs` — user-facing Polish strings | 192 | - `src/lang.rs` — user-facing Polish strings |
| 188 | 193 | ||
| 189 | All temporary files go through `crate::config::TEMP_PATH` | 194 | All temporary files go through `crate::config::temp_path()` |
| 190 | (`/tmp/senator_sperma`). | 195 | (`$TMPDIR/senator_sperma`, created with mode `0700` at startup). |
diff --git a/src/config.rs b/src/config.rs index c05c3ae..09eabeb 100644 --- a/src/config.rs +++ b/src/config.rs | |||
| @@ -75,7 +75,7 @@ pub struct Config { | |||
| 75 | pub llm_api_url: String, | 75 | pub llm_api_url: String, |
| 76 | pub database_path: String, | 76 | pub database_path: String, |
| 77 | /// Optional path to the Piper TTS `.onnx` model (or a directory of models). | 77 | /// Optional path to the Piper TTS `.onnx` model (or a directory of models). |
| 78 | /// When unset, TTS falls back to Google Translate TTS. | 78 | /// When unset, TTS commands fail; there is no external fallback. |
| 79 | pub tts_model_path: Option<String>, | 79 | pub tts_model_path: Option<String>, |
| 80 | /// Piper voice name, used to resolve the model file when `tts_model_path` | 80 | /// Piper voice name, used to resolve the model file when `tts_model_path` |
| 81 | /// points at a directory. Ignored when it points at a `.onnx` file directly. | 81 | /// points at a directory. Ignored when it points at a `.onnx` file directly. |
| @@ -168,7 +168,7 @@ const ENV_VARS: &[EnvVar] = &[ | |||
| 168 | name: "TTS_MODEL_PATH", | 168 | name: "TTS_MODEL_PATH", |
| 169 | required: false, | 169 | required: false, |
| 170 | default: None, | 170 | default: None, |
| 171 | description: "Path to a Piper TTS .onnx model (or a models directory). Unset => Google TTS fallback", | 171 | description: "Path to a Piper TTS .onnx model (or a models directory); TTS is disabled when unset", |
| 172 | }, | 172 | }, |
| 173 | EnvVar { | 173 | EnvVar { |
| 174 | name: "TTS_VOICE", | 174 | name: "TTS_VOICE", |
| @@ -284,11 +284,11 @@ impl Config { | |||
| 284 | } | 284 | } |
| 285 | } | 285 | } |
| 286 | 286 | ||
| 287 | /// Resolve the Piper model path to pass to the `piper` CLI. | 287 | /// Resolve the Piper model path passed to `piper-rs` (in-process). |
| 288 | /// | 288 | /// |
| 289 | /// - `TTS_MODEL_PATH` set to a `.onnx` file => used verbatim. | 289 | /// - `TTS_MODEL_PATH` set to a `.onnx` file => used verbatim. |
| 290 | /// - `TTS_MODEL_PATH` set to a directory => `{dir}/{TTS_VOICE}.onnx`. | 290 | /// - `TTS_MODEL_PATH` set to a directory => `{dir}/{TTS_VOICE}.onnx`. |
| 291 | /// - `TTS_MODEL_PATH` unset => `None` (caller falls back to Google TTS). | 291 | /// - `TTS_MODEL_PATH` unset => `None` (TTS is unavailable). |
| 292 | pub fn piper_model_path(&self) -> Option<std::path::PathBuf> { | 292 | pub fn piper_model_path(&self) -> Option<std::path::PathBuf> { |
| 293 | let configured = self.tts_model_path.as_ref()?; | 293 | let configured = self.tts_model_path.as_ref()?; |
| 294 | let path = std::path::PathBuf::from(configured); | 294 | let path = std::path::PathBuf::from(configured); |
