From b572f537db9ac9a5289be904f32d5097792a8db9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 19:25:15 +0000 Subject: [PATCH] Phase 5: cover the booth API, sync the dashboard, and back up local files. Add the named API, settings, Piper, and EventSub tests plus pytest-cov, keep the dashboard checkboxes and errors in step with the server, document the real Twitch scopes and defaults, and zip data, persona, and config without .env. Co-authored-by: Kisahime --- .env.example | 12 +- .gitignore | 3 + PLAN-v2.md | 8 +- README.md | 76 ++++++++-- requirements-dev.txt | 1 + scripts/backup.ps1 | 61 ++++++++ src/hub.py | 3 + src/main.py | 2 + src/orchestrator.py | 24 ++- src/web/static/app.css | 8 + src/web/static/app.js | 162 ++++++++++++-------- src/web/static/index.html | 5 +- tests/test_api.py | 261 +++++++++++++++++++++++++++++++++ tests/test_backup.py | 52 +++++++ tests/test_eventsub_session.py | 178 ++++++++++++++++++++++ tests/test_piper.py | 55 +++++++ tests/test_settings.py | 46 ++++++ 17 files changed, 868 insertions(+), 89 deletions(-) create mode 100644 scripts/backup.ps1 create mode 100644 tests/test_api.py create mode 100644 tests/test_backup.py create mode 100644 tests/test_eventsub_session.py create mode 100644 tests/test_piper.py create mode 100644 tests/test_settings.py diff --git a/.env.example b/.env.example index 77877a4..948c075 100644 --- a/.env.example +++ b/.env.example @@ -3,13 +3,21 @@ TWITCH_NICK=your_bot_or_streamer_login TWITCH_CHANNEL=your_channel # Scopes: chat:read chat:edit. Prefix with oauth: if missing. +# If this account is also the streamer, add the event scopes listed under TWITCH_EVENT_TOKEN. TWITCH_TOKEN=oauth:xxxxxxxx # --- Alerts (EventSub) — separate from IRC --- # Client ID from the SAME generate step as the event token (Token Generator shows it). TWITCH_CLIENT_ID= -# Leave empty if Pixel chats as YOUR streamer account and TWITCH_TOKEN already has: -# chat:read chat:edit moderator:read:followers channel:read:subscriptions +# Leave empty if Pixel chats as YOUR streamer account and TWITCH_TOKEN already has the scopes below. # Fill this only if IRC is a bot: generate a second token while logged in as the streamer. +# Always: moderator:read:followers channel:read:subscriptions +# Optional feeds (off in config.yaml until you turn them on): +# bits:read +# channel:read:redemptions (or channel:manage:redemptions) +# channel:read:hype_train +# channel:read:polls (or channel:manage:polls) +# channel:read:predictions (or channel:manage:predictions) +# Helix actions: moderator:manage:shoutouts (!so) channel:manage:broadcast (stream markers) TWITCH_EVENT_TOKEN= diff --git a/.gitignore b/.gitignore index 6005609..e91129e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ piper/ data/* !data/.gitkeep logs/ +backups/ +.coverage +htmlcov/ diff --git a/PLAN-v2.md b/PLAN-v2.md index c6cc9ec..30e5d6a 100644 --- a/PLAN-v2.md +++ b/PLAN-v2.md @@ -388,28 +388,28 @@ Phases: ### Phase 5: Polish, tests, docs and packaging -- [ ] **V5-01: API and integration tests** +- [x] **V5-01: API and integration tests** - *Why:* routes and wiring are untested. Fixes: N-25. - *Files:* create `tests/test_api.py`, `tests/test_eventsub_session.py`, `tests/test_piper.py`, `tests/test_settings.py` - *Do:* use `TestClient(create_app())` with a fake orchestrator injected (add a factory argument to `create_app`). Test `update_config` round-trips with comments, the mic state machine with synthetic chunks, and `safety.is_blocked` cases. - *Acceptance:* line coverage over 60% for `main.py`, `settings.py`, `memory.py`, `policy.py` and `safety.py` (`pytest --cov`, added to dev requirements). - *Depends on:* V0-02, V2-01 -- [ ] **V5-02: Dashboard state sync and error handling** +- [x] **V5-02: Dashboard state sync and error handling** - *Why:* checkboxes don't reflect the server, and there are unhandled promise rejections. Fixes: N-26. - *Files:* `src/web/static/app.js`, `src/hub.py` (add `post_voice`, `duck_enabled` and `wake` to status), `src/orchestrator.py` - *Do:* include `post_voice` and `duck_enabled` in status and set the checkboxes from it. Wrap every `defPost` call in a shared handler that shows `hint`. Guard `JSON.parse`. Show a "reconnecting…" banner when `/ws` drops. - *Acceptance:* toggling **Post voice** in one tab updates another tab. Stopping the server shows the banner, with no unhandled rejections in the console. - *Depends on:* V2-01 -- [ ] **V5-03: README refresh** +- [x] **V5-03: README refresh** - *Why:* the scope list, scopes table and new controls need documenting. - *Files:* `README.md`, `.env.example` - *Do:* add a per-feature Twitch scopes table (N-04), the security model (localhost only, control token), the mod-only commands, the profiles, `requirements-dev.txt`/tests/CI, and log file location. Fix "Optional, off by default" to match `config.yaml`. - *Acceptance:* a fresh reader can generate a token with the right scopes from the README alone, and the defaults described match `config.yaml`. - *Depends on:* V1-03, V2-01 -- [ ] **V5-04: One-click update and backup script** +- [x] **V5-04: One-click update and backup script** - *Why:* `data/` (memory, booth, identity) and `persona.yaml` are valuable and not in git. - *Files:* create `scripts/backup.ps1`; edit `README.md` - *Do:* zip `data/`, `persona.yaml` and `config.yaml` (never `.env`) to `backups/YYYYMMDD-HHMM.zip` and keep the last 10. Add `backups/` to `.gitignore`. diff --git a/README.md b/README.md index bec5943..7391147 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ Built for an **8 GB NVIDIA GPU**. Default model is `qwen2.5:3b` so Whisper can s - `TWITCH_NICK` — bot account login - `TWITCH_CHANNEL` — the channel to join (your stream, no `#`) - - `TWITCH_TOKEN` — OAuth token with `chat:read` and `chat:edit` - Create one at [Twitch Token Generator](https://twitchtokengenerator.com/) (custom scope: those two) or via the [Twitch CLI](https://dev.twitch.tv/docs/cli/). Prefix with `oauth:` if it is missing. - - Follows / subs / raids: see **IRC vs EventSub** below. + - `TWITCH_TOKEN` — OAuth token. The scopes depend on who the token belongs to. The full list is in **Twitch scopes** below. + Create one at [Twitch Token Generator](https://twitchtokengenerator.com/) (Custom Scope) or via the [Twitch CLI](https://dev.twitch.tv/docs/cli/). Prefix with `oauth:` if it is missing. + - Follows / subs / raids: see **IRC vs EventSub** and **Twitch scopes** below. 3. Double-click `start.bat` (creates `.venv`, installs the pinned runtime deps from `requirements.txt`, starts the app). @@ -62,7 +62,7 @@ These are **not** the same login step. The dashboard **Connect Twitch** button s **If Pixel chats as your streamer account** (`TWITCH_NICK` is the same as `TWITCH_CHANNEL`): -1. Open [Twitch Token Generator](https://twitchtokengenerator.com/), choose **Custom Scope**, tick all four: +1. Open [Twitch Token Generator](https://twitchtokengenerator.com/), choose **Custom Scope**, and tick every scope in the **Twitch scopes** table that you want this account to do. The minimum for chat plus follows and subs is: - `chat:read` - `chat:edit` - `moderator:read:followers` @@ -82,15 +82,29 @@ Chat still works if EventSub fails (wrong Client ID, missing scopes, bot token u `!so` is for mods and the broadcaster. Viewers are ignored. A second shoutout inside 120 seconds is skipped locally. -These optional feeds are **off** in `config.yaml`. Turn one on only when the event token includes its scope. Hype train uses EventSub version 2 (`channel.hype_train.*`); version 1 was withdrawn. +`!pixel mute`, `!pixel unmute`, `!pixel chill`, `!pixel hype`, and `!pixel stop` are also mod and broadcaster only. A second one inside 4 seconds is skipped. `!pixel` with anything else, and `!ask`, are spoken for anyone in chat. -| Feed | Scope | -|---|---| -| Bits | `bits:read` | -| Channel points | `channel:read:redemptions` or `channel:manage:redemptions` | -| Hype train | `channel:read:hype_train` | -| Polls | `channel:read:polls` or `channel:manage:polls` | -| Predictions | `channel:read:predictions` or `channel:manage:predictions` | +## Twitch scopes + +Tick these on [Twitch Token Generator](https://twitchtokengenerator.com/) (Custom Scope) when you create the token. Put chat scopes on the account Pixel types as. Put EventSub and Helix scopes on the **channel owner**. One token can carry all of them when Pixel chats as you. + +| Feature | Scope | Where it goes | +|---|---|---| +| Read chat | `chat:read` | `TWITCH_TOKEN` | +| Send chat | `chat:edit` | `TWITCH_TOKEN` | +| Follows | `moderator:read:followers` | event token (`TWITCH_TOKEN` or `TWITCH_EVENT_TOKEN`) | +| Subs, resubs, gifts | `channel:read:subscriptions` | event token | +| Bits | `bits:read` | event token | +| Channel points | `channel:read:redemptions` or `channel:manage:redemptions` | event token | +| Hype train | `channel:read:hype_train` | event token | +| Polls | `channel:read:polls` or `channel:manage:polls` | event token | +| Predictions | `channel:read:predictions` or `channel:manage:predictions` | event token | +| `!so` / shoutout button | `moderator:manage:shoutouts` | event token | +| Remember this (stream marker) | `channel:manage:broadcast` | event token | + +Copy the page’s Client ID into `TWITCH_CLIENT_ID`. It has to be the app that issued the event token. + +These optional feeds are **off** in the shipped `config.yaml`: bits, channel points, hype train, polls, and predictions. Turn one on only when the event token includes its scope. Hype train uses EventSub version 2 (`channel.hype_train.*`); version 1 was withdrawn. Follows, subs, resubs, gifts, and raids are the feeds that stay available when `events.enabled` is true. ## On stream @@ -155,7 +169,7 @@ Then: `ollama pull qwen2.5:7b-instruct-q4_K_M` `requirements.txt` is the pinned runtime install (`fastapi==…`, not `>=`). `start.bat` installs that file and nothing else. `pytest` and `ruff` are not in it. -`requirements-dev.txt` is those same pins plus `pytest` and `ruff`: +`requirements-dev.txt` is those same pins plus `pytest`, `pytest-cov`, and `ruff`: ```bat .venv\Scripts\python.exe -m pip install -r requirements-dev.txt @@ -163,6 +177,10 @@ Then: `ollama pull qwen2.5:7b-instruct-q4_K_M` .venv\Scripts\python.exe -m ruff check src tests ``` +GitHub Actions runs that lint and the test suite on `windows-latest` with Python 3.11. The tests do not need Twitch credentials, a GPU, models, or an audio device. + +Logs go to `logs/cohost.log` (rotating, about 2 MB, three backups). That directory is gitignored. + Optional extras are not installed by either file. Add them yourself when you turn the feature on: - `pycaw` — Windows endpoint ducking when `audio.duck_enabled: true` @@ -201,9 +219,37 @@ Voice replies can be echoed to chat (`chat.post_voice_replies_to_chat`). Edit `p - **Whisper CUDA failed** / **`cublas64_12.dll` not found**: the NVIDIA *driver* is not the CUDA 12 math libraries. Re-run `start.bat` so it installs `nvidia-cublas-cu12` (and friends) into the venv. If a brand-new GPU (RTX 50-series) still errors, the app will retry Whisper on CPU; you can also set `whisper.device: cpu` in `config.yaml`. - **Piper missing**: delete `models/piper` and restart so it re-downloads. -## Optional, off by default +## What the shipped config turns on + +These match `config.yaml` as it is in the repo: + +| Key | Shipped value | +|---|---| +| `events.enabled`, `events.speak`, `events.post_to_chat` | true | +| `chat.post_voice_replies_to_chat` | true | +| `chat.welcome_first_time` | true | +| `audio.barge_in`, `audio.echo_gate` | true | +| `events.bits`, `events.channel_points`, `events.hype_train`, `events.polls`, `events.predictions` | false | +| `audio.wake_word`, `audio.duck_enabled` | false | +| `safety.allow_links` | false | -In `config.yaml`: `events.bits`, `events.channel_points`, `events.hype_train`, `events.polls`, `events.predictions`, `audio.wake_word`, `audio.duck_enabled`, `safety.allow_links`. Wake word needs `openwakeword` and never replaces hold-to-talk. Channel-point reward titles map to `speak`, `chat`, or `ignore`. Links are stripped from Pixel's chat posts unless `safety.allow_links` is true. +Wake word needs `openwakeword` and never replaces hold-to-talk. Channel-point reward titles map to `speak`, `chat`, or `ignore`. Links are stripped from Pixel's chat posts unless `safety.allow_links` is true. + +## Profiles + +`config.yaml` has two booth presets. **Just chatting** is hype, a short cooldown, events spoken, voice replies posted, 12 replies a minute. **Focus game** is chill, a long cooldown, events typed, voice replies not posted, 3 replies a minute. The dashboard dropdown calls `POST /api/profile` and stores the name in `data/booth.json`. + +## Security + +The booth binds to localhost. `server.allow_remote` stays false unless you set it on purpose. Writes need `Content-Type: application/json` and the `X-Cohost-Token` from `GET /api/session` (same browser tab). A foreign `Origin` is rejected. The OBS overlay does not get that token and cannot change anything. + +## Backup + +`scripts/backup.ps1` zips `data/`, `persona.yaml`, and `config.yaml` into `backups/YYYYMMDD-HHMM.zip` and keeps the last 10. It never puts `.env` in the zip. `backups/` is gitignored. + +```powershell +powershell -File scripts\backup.ps1 +``` `start.bat` prefers `py -3.11`, then `python`, and requires 3.11 or newer. It reads `server.host` and `server.port`, skips pip when `requirements.txt` has not changed, and opens the booth unless you pass `--no-browser`. If creating the venv, installing dependencies, or reading the config fails, the window stays open and the next launch tries the install again. The install stamp is written only after pip succeeds. Binding anything other than localhost requires `server.allow_remote: true`. diff --git a/requirements-dev.txt b/requirements-dev.txt index 739c601..5936c13 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ # Lint and tests. Includes the pinned runtime via -r. -r requirements.txt pytest==9.1.1 +pytest-cov==7.1.0 ruff==0.16.10 diff --git a/scripts/backup.ps1 b/scripts/backup.ps1 new file mode 100644 index 0000000..902d5a5 --- /dev/null +++ b/scripts/backup.ps1 @@ -0,0 +1,61 @@ +# Zip data/, persona.yaml, and config.yaml. Never includes .env. +param( + [string]$Root = "" +) + +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($Root)) { + $Root = Split-Path -Parent $PSScriptRoot +} +if (-not (Test-Path -LiteralPath $Root)) { + throw "Backup root does not exist: $Root" +} +$Root = (Resolve-Path -LiteralPath $Root).Path +$backupDir = Join-Path $Root "backups" +New-Item -ItemType Directory -Force -Path $backupDir | Out-Null + +$stamp = Get-Date -Format "yyyyMMdd-HHmm" +$zipPath = Join-Path $backupDir "$stamp.zip" +if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force +} + +$include = @() +foreach ($name in @("data", "persona.yaml", "config.yaml")) { + $path = Join-Path $Root $name + if (Test-Path -LiteralPath $path) { + $include += $path + } +} +if ($include.Count -eq 0) { + throw "Nothing to back up under $Root" +} + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [System.IO.Compression.ZipFile]::Open($zipPath, [System.IO.Compression.ZipArchiveMode]::Create) +try { + foreach ($path in $include) { + $leaf = Split-Path -Leaf $path + if ((Get-Item -LiteralPath $path).PSIsContainer) { + $files = Get-ChildItem -LiteralPath $path -Recurse -File + foreach ($file in $files) { + if ($file.Name -eq ".env") { continue } + $relative = $file.FullName.Substring($path.Length).TrimStart("\", "/") + $entry = ($leaf + "/" + $relative) -replace "\\", "/" + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $file.FullName, $entry) | Out-Null + } + } else { + if ($leaf -eq ".env") { continue } + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $path, $leaf) | Out-Null + } + } +} finally { + $zip.Dispose() +} + +$existing = @(Get-ChildItem -LiteralPath $backupDir -Filter "*.zip" -File | Sort-Object LastWriteTime -Descending) +if ($existing.Count -gt 10) { + $existing | Select-Object -Skip 10 | Remove-Item -Force +} diff --git a/src/hub.py b/src/hub.py index fb10470..eafa0af 100644 --- a/src/hub.py +++ b/src/hub.py @@ -39,6 +39,9 @@ def __init__(self) -> None: "twitch_error": "", "eventsub_error": "", "duck": "off", + "post_voice": True, + "duck_enabled": False, + "wake": {"enabled": False}, } self._memory_pending: dict[str, Any] | None = None self._memory_task: asyncio.Task | None = None diff --git a/src/main.py b/src/main.py index 10470ce..edd51f3 100644 --- a/src/main.py +++ b/src/main.py @@ -226,6 +226,7 @@ async def mute(body: MuteBody): @app.post("/api/chat/post_voice") async def post_voice(body: FlagBody): app.state.orch.set_post_voice(body.enabled) + await app.state.hub.patch_status(post_voice=bool(body.enabled)) return {"ok": True, "enabled": body.enabled} @app.post("/api/interrupt") @@ -290,6 +291,7 @@ async def audio_devices(body: DeviceBody): @app.post("/api/audio/duck") async def duck(body: FlagBody): await asyncio.to_thread(app.state.orch.set_duck, body.enabled) + await app.state.hub.patch_status(duck_enabled=bool(body.enabled), duck=app.state.orch.duck.mode) return {"ok": True, "mode": app.state.orch.duck.mode, "note": app.state.orch.duck.note} @app.post("/api/stt/language") diff --git a/src/orchestrator.py b/src/orchestrator.py index 48b0d52..c17b242 100644 --- a/src/orchestrator.py +++ b/src/orchestrator.py @@ -256,7 +256,11 @@ def _install_profile(self, name: str, *, persist: bool) -> str: async def apply_profile(self, name: str) -> str: canonical = self._install_profile(name, persist=True) - await self.hub.patch_status(mood=self.mood, profile=canonical) + await self.hub.patch_status( + mood=self.mood, + profile=canonical, + post_voice=bool(self.settings.post_voice_replies_to_chat), + ) return canonical async def start(self, loop: asyncio.AbstractEventLoop) -> None: @@ -271,6 +275,9 @@ async def start(self, loop: asyncio.AbstractEventLoop) -> None: eventsub="disconnected", mood=self.mood, duck=self.duck.mode, + post_voice=bool(self.settings.post_voice_replies_to_chat), + duck_enabled=bool(self.settings.duck_enabled), + wake=self._wake_status(), ) await self._resolve_audio_devices() self._tasks = [ @@ -472,9 +479,20 @@ async def set_mute(self, mute: bool) -> None: await self.hub.patch_status(tts_mute=mute) def set_post_voice(self, enabled: bool) -> None: - self.settings.post_voice_replies_to_chat = enabled + self.settings.post_voice_replies_to_chat = bool(enabled) + self.hub.status["post_voice"] = bool(enabled) self._persist_booth() + def _wake_status(self) -> dict: + wake = getattr(self.mic, "wake", None) + if wake is None or not hasattr(wake, "status"): + return {"enabled": False} + try: + reported = wake.status() + except Exception: + return {"enabled": False} + return reported if isinstance(reported, dict) else {"enabled": False} + async def set_vad(self, silence_ms: int, energy: float, max_seconds: float) -> None: self.settings.silence_ms = int(silence_ms) self.settings.energy_threshold = float(energy) @@ -547,6 +565,8 @@ def progress(message: str) -> None: def set_duck(self, enabled: bool) -> None: self.duck.set_enabled(enabled) + self.hub.status["duck_enabled"] = bool(enabled) + self.hub.status["duck"] = self.duck.mode update_config({"audio.duck_enabled": enabled}) def _bump_voice(self) -> int: diff --git a/src/web/static/app.css b/src/web/static/app.css index da745e4..87cea6d 100644 --- a/src/web/static/app.css +++ b/src/web/static/app.css @@ -56,6 +56,14 @@ button.primary { background: #12352f; border-color: #2a6b5f; } button.active { background: #2a1a12; border-color: var(--warn); color: var(--warn); } button.held { background: #3a2218; border-color: #ffb38a; } .hint { color: var(--muted); min-height: 1.4em; margin: 8px 0 0; font-size: 13px; } +.banner { + margin: 0 0 12px; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--warn); + color: var(--warn); + background: #2a1a12; +} .sys { list-style: none; margin: 0; padding: 0; } .sys li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-bottom: 1px solid #21262d; } .sys span { color: var(--muted); text-align: right; } diff --git a/src/web/static/app.js b/src/web/static/app.js index 7326a4c..9d5b225 100644 --- a/src/web/static/app.js +++ b/src/web/static/app.js @@ -99,6 +99,20 @@ async function defPost(url, body) { return data; } +async function post(url, body) { + try { + return await defPost(url, body); + } catch (e) { + const message = String((e && e.message) || e); + if (hint) hint.textContent = message; + return { ok: false, error: message }; + } +} + +function failed(data) { + return data && data.ok === false; +} + function typingInField() { const tag = (document.activeElement && document.activeElement.tagName) || ""; return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; @@ -106,7 +120,7 @@ function typingInField() { listenBtn.addEventListener("click", async () => { try { - await defPost("/api/listen", { enabled: !listenOn }); + await post("/api/listen", { enabled: !listenOn }); } catch (e) { hint.textContent = String(e.message || e); } @@ -116,7 +130,7 @@ function ptt(down) { if (pttHeld === down) return; pttHeld = down; pttBtn.classList.toggle("held", down); - defPost("/api/ptt", { down }).catch((e) => logLine(eventLog, String(e.message || e), "warn")); + post("/api/ptt", { down }).catch((e) => logLine(eventLog, String(e.message || e), "warn")); } pttBtn.addEventListener("mousedown", () => ptt(true)); pttBtn.addEventListener("mouseup", () => ptt(false)); @@ -141,101 +155,93 @@ window.addEventListener("keyup", (e) => { }); muteBtn.addEventListener("click", async () => { - await defPost("/api/tts/mute", { mute: !muted }); + await post("/api/tts/mute", { mute: !muted }); }); -stopBtn.addEventListener("click", () => defPost("/api/interrupt", {})); +stopBtn.addEventListener("click", () => post("/api/interrupt", {})); twitchBtn.addEventListener("click", async () => { - try { - const on = twitchState === "connected" || twitchState === "connecting" || twitchState === "reconnecting"; - await defPost("/api/twitch/irc", { enabled: !on }); - } catch (e) { - hint.textContent = String(e.message || e); - logLine(eventLog, String(e.message || e), "warn"); - } + const on = twitchState === "connected" || twitchState === "connecting" || twitchState === "reconnecting"; + const data = await post("/api/twitch/irc", { enabled: !on }); + if (failed(data)) logLine(eventLog, data.error, "warn"); }); eventBtn.addEventListener("click", async () => { - try { - const on = eventState === "connected" || eventState === "connecting" || eventState === "reconnecting"; - await defPost("/api/twitch/events", { enabled: !on }); - } catch (e) { - hint.textContent = String(e.message || e); - logLine(eventLog, String(e.message || e), "warn"); - } + const on = eventState === "connected" || eventState === "connecting" || eventState === "reconnecting"; + const data = await post("/api/twitch/events", { enabled: !on }); + if (failed(data)) logLine(eventLog, data.error, "warn"); }); $("shoutoutBtn").addEventListener("click", async () => { - try { - await defPost("/api/twitch/shoutout", {}); - hint.textContent = "Shoutout sent."; - } catch (e) { - hint.textContent = String(e.message || e); - } + const data = await post("/api/twitch/shoutout", {}); + if (failed(data)) return; + hint.textContent = "Shoutout sent."; }); $("markerBtn").addEventListener("click", async () => { - const data = await defPost("/api/marker", { note: "clip moment" }); + const data = await post("/api/marker", { note: "clip moment" }); + if (failed(data)) return; hint.textContent = data.marker_error ? `Noted. Marker: ${data.marker_error}` : "Moment noted."; }); personaBtn.addEventListener("click", async () => { - const data = await defPost("/api/persona/reload", {}); + const data = await post("/api/persona/reload", {}); + if (failed(data)) return; hint.textContent = `Persona reloaded: ${data.name || "ok"}`; await loadPersona(); }); personaSave.addEventListener("click", async () => { - try { - const data = await defPost("/api/persona", { text: $("personaText").value }); - hint.textContent = `Persona saved: ${data.name || "ok"}`; - } catch (e) { - hint.textContent = String(e.message || e); - } + const data = await post("/api/persona", { text: $("personaText").value }); + if (failed(data)) return; + hint.textContent = `Persona saved: ${data.name || "ok"}`; }); clearMemBtn.addEventListener("click", async () => { - await defPost("/api/memory/clear", {}); + const data = await post("/api/memory/clear", {}); + if (failed(data)) return; if (memLog) memLog.textContent = ""; if (peopleList) peopleList.textContent = ""; hint.textContent = "Conversation memory cleared."; }); saveStreamerBtn.addEventListener("click", async () => { - const data = await defPost("/api/streamer", { + const data = await post("/api/streamer", { display_name: streamerName.value.trim(), twitch_login: streamerLogin.value.trim(), }); + if (failed(data)) return; streamerLabel = data.spoken_name || streamerName.value.trim() || "Streamer"; hint.textContent = `Pixel will treat ${streamerLabel} as the streamer.`; }); $("mood").addEventListener("change", async () => { try { - await defPost("/api/mood", { mood: $("mood").value }); + await post("/api/mood", { mood: $("mood").value }); } catch (e) { hint.textContent = String(e.message || e); } }); $("postVoice").addEventListener("change", () => { - defPost("/api/chat/post_voice", { enabled: $("postVoice").checked }).catch((e) => { + post("/api/chat/post_voice", { enabled: $("postVoice").checked }).catch((e) => { hint.textContent = String(e.message || e); }); }); $("sayBtn").addEventListener("click", () => speakBox(false)); $("sayChatBtn").addEventListener("click", () => speakBox(true)); -async function speakBox(post) { +async function speakBox(postChat) { const text = $("sayText").value.trim(); if (!text) return; - await defPost("/api/say", { text, post_chat: post }); + const data = await post("/api/say", { text, post_chat: postChat }); + if (failed(data)) return; $("sayText").value = ""; } $("pretendBtn").addEventListener("click", async () => { const text = $("pretendText").value.trim(); if (!text) return; - await defPost("/api/pretend", { text }); + const data = await post("/api/pretend", { text }); + if (failed(data)) return; $("pretendText").value = ""; }); @@ -274,14 +280,11 @@ function renderPeople(snap) { forget.textContent = "Forget"; forget.addEventListener("click", async (ev) => { ev.stopPropagation(); - try { - await defPost("/api/memory/forget", { login: p.name || "" }); - lastPeople = lastPeople.filter((item) => item.id !== p.id); - if (selectedPerson === p.id) selectedPerson = "streamer"; - renderPeople({ people: lastPeople }); - } catch (e) { - if (hint) hint.textContent = String(e.message || e); - } + const data = await post("/api/memory/forget", { login: p.name || "" }); + if (failed(data)) return; + lastPeople = lastPeople.filter((item) => item.id !== p.id); + if (selectedPerson === p.id) selectedPerson = "streamer"; + renderPeople({ people: lastPeople }); }); row.appendChild(forget); } @@ -430,17 +433,19 @@ function queueButtons(id) { drop.type = "button"; drop.textContent = "Drop"; drop.addEventListener("click", () => { - defPost("/api/queue/drop", { id }).then((data) => { + post("/api/queue/drop", { id }).then((data) => { + if (failed(data)) return; if (data.queue) renderQueue(data.queue); - }).catch((e) => { hint.textContent = String(e.message || e); }); + }); }); const answer = document.createElement("button"); answer.type = "button"; answer.textContent = "Answer"; answer.addEventListener("click", () => { - defPost("/api/queue/answer", { id }).then((data) => { + post("/api/queue/answer", { id }).then((data) => { + if (failed(data)) return; if (data.queue) renderQueue(data.queue); - }).catch((e) => { hint.textContent = String(e.message || e); }); + }); }); return [drop, answer]; } @@ -515,6 +520,14 @@ function applyStatus(st) { } if (st.queue) renderQueue(st.queue); if (st.mood && document.activeElement !== $("mood")) $("mood").value = st.mood; + const postVoice = $("postVoice"); + if (postVoice && document.activeElement !== postVoice && typeof st.post_voice === "boolean") { + postVoice.checked = st.post_voice; + } + const duckBox = $("duck"); + if (duckBox && document.activeElement !== duckBox && typeof st.duck_enabled === "boolean") { + duckBox.checked = st.duck_enabled; + } } function selectedDeviceName(id) { @@ -543,7 +556,7 @@ function applyDeviceList(devices, health) { function saveDevices() { const mic = $("micSelect").value; const spk = $("spkSelect").value; - defPost("/api/audio/devices", { + post("/api/audio/devices", { mic_device: mic === "" ? null : Number(mic), speaker_device: spk === "" ? null : Number(spk), mic_device_name: mic === "" ? null : selectedDeviceName("micSelect"), @@ -571,13 +584,14 @@ $("refreshDevices").addEventListener("click", async () => { $("micSelect").addEventListener("change", saveDevices); $("spkSelect").addEventListener("change", saveDevices); $("langSelect").addEventListener("change", () => { - defPost("/api/stt/language", { language: $("langSelect").value }).catch((e) => { hint.textContent = String(e.message || e); }); + post("/api/stt/language", { language: $("langSelect").value }).catch((e) => { hint.textContent = String(e.message || e); }); }); $("voiceSelect").addEventListener("change", () => { if (!$("voiceSelect").value) return; - defPost("/api/tts/voice", { voice: $("voiceSelect").value }).then(() => { + post("/api/tts/voice", { voice: $("voiceSelect").value }).then((data) => { + if (failed(data)) return; hint.textContent = "Voice updated."; - }).catch((e) => { hint.textContent = String(e.message || e); }); + }); }); let vadTimer = 0; @@ -587,7 +601,7 @@ function queueVad() { $("maxVal").textContent = $("maxUtter").value; clearTimeout(vadTimer); vadTimer = setTimeout(() => { - defPost("/api/audio/vad", { + post("/api/audio/vad", { silence_ms: Number($("silence").value), energy_threshold: Number($("energy").value), max_utterance_seconds: Number($("maxUtter").value), @@ -598,28 +612,32 @@ $("silence").addEventListener("input", queueVad); $("energy").addEventListener("input", queueVad); $("maxUtter").addEventListener("input", queueVad); $("duck").addEventListener("change", async () => { - const data = await defPost("/api/audio/duck", { enabled: $("duck").checked }); + const data = await post("/api/audio/duck", { enabled: $("duck").checked }); + if (failed(data)) return; $("duckNote").textContent = data.note || ""; }); const profileSelect = $("profileSelect"); if (profileSelect) { profileSelect.addEventListener("change", () => { - defPost("/api/profile", { name: profileSelect.value }).then(() => refreshHealth()).catch((e) => { - hint.textContent = String(e.message || e); + post("/api/profile", { name: profileSelect.value }).then((data) => { + if (failed(data)) return; + refreshHealth(); }); }); } const replayBtn = $("replayBtn"); if (replayBtn) { replayBtn.addEventListener("click", () => { - defPost("/api/queue/replay", {}).then(() => { + post("/api/queue/replay", {}).then((data) => { + if (failed(data)) return; hint.textContent = "Replayed the last line."; - }).catch((e) => { hint.textContent = String(e.message || e); }); + }); }); } async function buildRecap(vibe) { - const data = await defPost("/api/recap", { vibe: !!vibe }); + const data = await post("/api/recap", { vibe: !!vibe }); + if (failed(data)) return data; const box = $("recapText"); if (box) { box.value = data.markdown || ""; @@ -667,11 +685,24 @@ async function loadPersona() { } } +function showReconnect(on) { + const banner = $("reconnectBanner"); + if (!banner) return; + banner.hidden = !on; +} + function connectWs() { const proto = location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${proto}://${location.host}/ws`); + ws.onopen = () => showReconnect(false); + ws.onerror = () => showReconnect(true); ws.onmessage = (ev) => { - const msg = JSON.parse(ev.data); + let msg; + try { + msg = JSON.parse(ev.data); + } catch { + return; + } if (msg.type === "status") { applyStatus(msg.data || {}); } else if (msg.type === "level") { @@ -704,7 +735,10 @@ function connectWs() { logLine(eventLog, String(msg.data || ""), "warn"); } }; - ws.onclose = () => setTimeout(connectWs, 1500); + ws.onclose = () => { + showReconnect(true); + setTimeout(connectWs, 1500); + }; } refreshHealth(); diff --git a/src/web/static/index.html b/src/web/static/index.html index 98592af..617052a 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -4,10 +4,11 @@ Local Twitch Cohost - +
+

Local · NVIDIA · no cloud LLM

@@ -189,6 +190,6 @@

Log

- + diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..3ff4eee --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,261 @@ +import asyncio + +import numpy as np +import pytest +from fastapi.testclient import TestClient + +from src.audio.mic import MicListener +from src.main import create_app +from src.policy import chat_action, pixel_control +from src.safety import is_blocked +from src.twitch.chat import ChatLine +from tests.test_orchestrator_policy import PERSONA_FIXTURE, make_orch + + +class _Wake: + def status(self): + return {"enabled": False, "ready": False} + + +def _client(tmp_path, settings): + orch = make_orch(tmp_path, settings) + orch.mic.wake = _Wake() + + async def disconnect(): + return None + + def set_allow_from(_names): + return None + + orch.twitch.disconnect = disconnect + orch.twitch.set_allow_from = set_allow_from + app = create_app(prepared=(settings, orch.hub, orch)) + return TestClient(app, base_url="http://127.0.0.1"), orch + + +def _headers(client: TestClient) -> dict[str, str]: + token = client.get("/api/session").json()["token"] + return {"x-cohost-token": token, "origin": "http://127.0.0.1:8080"} + + +def test_dashboard_routes_round_trip(tmp_path, settings, monkeypatch): + monkeypatch.setattr("src.orchestrator.update_config", lambda *_a, **_k: None) + settings.profiles = { + "Just chatting": { + "mood": "hype", + "cooldown_seconds": 8, + "per_user_cooldown_seconds": 12, + "events_speak": True, + "post_voice_replies_to_chat": True, + "max_replies_per_minute": 12, + } + } + client, orch = _client(tmp_path, settings) + persona = (tmp_path / "persona.yaml") + persona.write_text(PERSONA_FIXTURE.read_text(encoding="utf-8"), encoding="utf-8") + orch.persona_path = persona + orch.tts.ensure_files = lambda: None + + async def irc_ok(): + return None + + async def events_ok(): + return None + + orch._irc_connect = irc_ok + orch._eventsub_connect = events_ok + + with client: + headers = _headers(client) + assert client.get("/").status_code == 200 + assert "Twitch Cohost" in client.get("/").text + assert client.get("/overlay").status_code == 200 + health = client.get("/api/health") + assert health.status_code == 200 + assert health.json()["status"]["post_voice"] is True + assert "oauth:" not in health.text + assert client.get("/api/devices").status_code == 200 + assert client.get("/api/devices?refresh=1").status_code == 200 + assert client.get("/api/queue").json() == {"chat": [], "alerts": []} + assert client.get("/api/tts/voices").json()["current"] == settings.tts_voice + assert client.get("/api/persona").status_code == 200 + assert client.get("/api/streamer").json()["spoken_name"] + assert client.get("/api/memory").status_code == 200 + + assert client.post("/api/listen", json={"enabled": True}, headers=headers).status_code == 400 + assert client.post("/api/listen", json={"enabled": False}, headers=headers).json()["ok"] is True + assert client.post("/api/ptt", json={"down": False}, headers=headers).json()["ptt"] is False + assert client.post("/api/tts/mute", json={"mute": True}, headers=headers).json()["mute"] is True + assert client.post("/api/interrupt", json={}, headers=headers).json()["ok"] is True + assert client.post("/api/say", json={"text": "hello chat", "post_chat": False}, headers=headers).json()["ok"] + assert client.post("/api/pretend", json={"text": "what is up"}, headers=headers).json()["ok"] + assert client.post("/api/mood", json={"mood": "hype"}, headers=headers).json()["mood"] == "hype" + assert client.post("/api/mood", json={"mood": "nope"}, headers=headers).status_code == 400 + saved = client.post("/api/persona", json={"text": persona.read_text(encoding="utf-8")}, headers=headers) + assert saved.status_code == 200 + assert client.post("/api/persona", json={"text": "hello: 1"}, headers=headers).status_code == 400 + assert persona.read_text(encoding="utf-8").startswith("name:") + assert client.post("/api/persona/reload", json={}, headers=headers).json()["name"] + streamer = client.post( + "/api/streamer", + json={"display_name": "Luna", "twitch_login": "luna"}, + headers=headers, + ) + assert streamer.json()["spoken_name"] == "Luna" + assert client.post("/api/memory/clear", json={}, headers=headers).json()["ok"] is True + assert client.post("/api/memory/forget", json={"login": "someone"}, headers=headers).json()["ok"] is True + marker = client.post("/api/marker", json={"note": "clip"}, headers=headers) + assert marker.json()["note"] == "clip" + vad = client.post( + "/api/audio/vad", + json={"silence_ms": 900, "energy_threshold": 0.02, "max_utterance_seconds": 15}, + headers=headers, + ) + assert vad.status_code == 200 + devices = client.post( + "/api/audio/devices", + json={"mic_device": None, "speaker_device": None}, + headers=headers, + ) + assert devices.status_code == 200 + duck = client.post("/api/audio/duck", json={"enabled": False}, headers=headers) + assert duck.status_code == 200 + assert orch.hub.status["duck_enabled"] is False + assert client.post("/api/stt/language", json={"language": "en"}, headers=headers).json()["language"] == "en" + assert client.post("/api/stt/language", json={"language": "xx"}, headers=headers).status_code == 400 + voice = client.post("/api/tts/voice", json={"voice": settings.tts_voice}, headers=headers) + assert voice.status_code == 200 + assert client.post("/api/tts/voice", json={"voice": "missing"}, headers=headers).status_code == 400 + profile = client.post("/api/profile", json={"name": "Just chatting"}, headers=headers) + assert profile.json()["profile"] == "Just chatting" + assert client.post("/api/profile", json={"name": "nope"}, headers=headers).status_code == 400 + assert client.post("/api/queue/drop", json={"id": "missing"}, headers=headers).status_code == 404 + assert client.post("/api/queue/answer", json={"id": "missing"}, headers=headers).status_code == 404 + assert client.post("/api/queue/replay", json={}, headers=headers).json()["ok"] is True + recap = client.post("/api/recap", json={"vibe": False}, headers=headers) + assert recap.status_code == 200 + assert "markdown" in recap.json() + assert client.post("/api/twitch/irc", json={"enabled": True}, headers=headers).json()["ok"] is True + assert client.post("/api/twitch/events", json={"enabled": True}, headers=headers).json()["ok"] is True + assert client.post("/api/twitch/connect", json={}, headers=headers).json()["ok"] is True + assert client.post("/api/twitch/disconnect", json={}, headers=headers).json()["ok"] is True + shout = client.post("/api/twitch/shoutout", json={}, headers=headers) + assert shout.status_code == 400 + + async def boom(): + raise RuntimeError("irc down") + + orch._irc_connect = boom + failed = client.post("/api/twitch/irc", json={"enabled": True}, headers=headers) + assert failed.status_code == 400 + assert "irc down" in failed.json()["error"] + + async def broken_devices(*_a, **_k): + raise RuntimeError("no devices") + + monkeypatch.setattr("src.health.load_devices", broken_devices) + assert client.get("/api/devices?refresh=1").status_code == 500 + + +def test_post_voice_reaches_a_second_websocket(tmp_path, settings): + client, orch = _client(tmp_path, settings) + with client: + headers = _headers(client) + with client.websocket_connect("/ws", headers={"host": "127.0.0.1"}) as left: + with client.websocket_connect("/ws", headers={"host": "127.0.0.1"}) as right: + assert left.receive_json()["type"] == "status" + assert right.receive_json()["type"] == "status" + response = client.post("/api/chat/post_voice", json={"enabled": False}, headers=headers) + assert response.status_code == 200 + assert orch.settings.post_voice_replies_to_chat is False + for sock in (left, right): + message = sock.receive_json() + assert message["type"] == "status" + assert message["data"]["post_voice"] is False + + +def test_is_blocked_and_policy_edges(): + assert is_blocked("you are a retard") is True + assert is_blocked("what meds should i take") is True + assert is_blocked("nice stream") is False + bare = ChatLine(user="mod", text="!pixel", command="!pixel", is_mod=True) + assert pixel_control(bare) == "" + assert chat_action(bare) == "speak" + steered = ChatLine(user="mod", text="!pixel stop", command="!pixel", is_mod=True) + assert pixel_control(steered) == "stop" + assert chat_action(steered) == "control" + other = ChatLine(user="viewer", text="!foo later", command="!foo") + assert chat_action(other) == "command" + + +def test_mic_state_machine_emits_a_loud_chunk(settings, monkeypatch): + heard: list[np.ndarray] = [] + + async def on_utterance(audio): + heard.append(audio) + + settings.sample_rate = 16000 + settings.silence_ms = 100 + settings.min_utterance_ms = 50 + settings.energy_threshold = 0.01 + settings.echo_gate = False + settings.barge_in = False + monkeypatch.setattr("src.audio.mic.has_speech_silero", lambda _audio, _sr: True) + mic = MicListener(settings, on_utterance) + mic.listen_enabled = True + + async def run(): + task = asyncio.create_task(mic._run()) + loud = np.full(480, 0.2, dtype=np.float32) + quiet = np.zeros(480, dtype=np.float32) + for _ in range(3): + mic._enqueue(loud) + for _ in range(6): + mic._enqueue(quiet) + for _ in range(40): + if heard: + break + await asyncio.sleep(0.02) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(run()) + assert heard + assert heard[0].size >= 800 + + +def test_mic_drops_the_oldest_chunk_when_the_queue_is_full(settings): + mic = MicListener(settings, lambda _audio: asyncio.sleep(0)) + for index in range(205): + mic._enqueue(np.array([index], dtype=np.float32)) + assert mic._queue.qsize() == 200 + first = mic._queue.get_nowait() + assert int(first[0]) == 5 + + +def test_mic_ignores_chunks_until_it_is_armed(settings, monkeypatch): + heard: list[np.ndarray] = [] + + async def on_utterance(audio): + heard.append(audio) + + settings.sample_rate = 16000 + settings.silence_ms = 30 + settings.min_utterance_ms = 20 + settings.energy_threshold = 0.01 + monkeypatch.setattr("src.audio.mic.has_speech_silero", lambda _audio, _sr: True) + mic = MicListener(settings, on_utterance) + + async def run(): + task = asyncio.create_task(mic._run()) + loud = np.full(480, 0.4, dtype=np.float32) + for _ in range(8): + mic._enqueue(loud) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(run()) + assert heard == [] diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..f7b2daf --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,52 @@ +import os +import shutil +import subprocess +import zipfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "backup.ps1" + + +def _pwsh() -> str: + found = shutil.which("pwsh") + if not found: + pytest.skip("pwsh is not installed") + return found + + +def test_backup_script_skips_env_and_keeps_ten_zips(tmp_path: Path): + booth = tmp_path / "booth" + (booth / "data").mkdir(parents=True) + (booth / "data" / "memory.json").write_text('{"people": []}\n', encoding="utf-8") + (booth / "data" / ".env").write_text("SECRET=inside-data\n", encoding="utf-8") + (booth / "persona.yaml").write_text("name: Pixel\n", encoding="utf-8") + (booth / "config.yaml").write_text("server:\n host: 127.0.0.1\n", encoding="utf-8") + (booth / ".env").write_text("TWITCH_TOKEN=oauth:do-not-zip\n", encoding="utf-8") + backups = booth / "backups" + backups.mkdir() + for index in range(12): + path = backups / f"20000101-{index:04d}.zip" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("old.txt", "x") + stamp = 1_000_000 + index + os.utime(path, (stamp, stamp)) + + subprocess.run( + [_pwsh(), "-NoProfile", "-File", str(SCRIPT), "-Root", str(booth)], + check=True, + ) + zips = list(backups.glob("*.zip")) + assert len(zips) == 10 + newest = max(zips, key=lambda path: path.stat().st_mtime) + with zipfile.ZipFile(newest) as archive: + names = archive.namelist() + assert any(name.endswith("persona.yaml") for name in names) + assert any(name.endswith("config.yaml") for name in names) + assert any(name.endswith("memory.json") for name in names) + assert not any(name == ".env" or name.endswith("/.env") or name.endswith("\\.env") for name in names) + joined = "\n".join(names) + assert "oauth:do-not-zip" not in joined + assert "SECRET" not in joined diff --git a/tests/test_eventsub_session.py b/tests/test_eventsub_session.py new file mode 100644 index 0000000..78d76c7 --- /dev/null +++ b/tests/test_eventsub_session.py @@ -0,0 +1,178 @@ +import asyncio +import json + +import pytest + +from src.twitch.eventsub import KEEPALIVE_GRACE, EventSubClient + + +def _welcome(sid: str = "sid", keepalive: float = 10): + return { + "metadata": {"message_type": "session_welcome", "message_id": sid}, + "payload": {"session": {"id": sid, "keepalive_timeout_seconds": keepalive}}, + } + + +def _raid(message_id: str = "m1"): + return { + "metadata": { + "message_type": "notification", + "message_id": message_id, + "subscription_type": "channel.raid", + }, + "payload": { + "event": { + "from_broadcaster_user_login": "raider", + "from_broadcaster_user_name": "Raider", + "viewers": 4, + } + }, + } + + +class _Script: + def __init__(self, client, items, events=None, name=""): + self.client = client + self.items = list(items) + self.events = events + self.name = name + self.closed = False + + async def recv(self): + if self.client._stop and not self.items: + await asyncio.Future() + if not self.items: + self.client._stop = True + return "{}" + item = self.items.pop(0) + if item == "HANG": + await asyncio.Future() + if isinstance(item, str): + return item + return json.dumps(item) + + async def close(self): + self.closed = True + if self.events is not None and self.name: + self.events.append(f"close-{self.name}") + + +def _client(on_event, on_status): + client = EventSubClient("oauth:tok", "cid", "chan", on_event, on_status) + + async def subscribe(_sid): + return None + + client._subscribe_all = subscribe + return client + + +def test_session_ignores_bad_json_and_duplicate_ids(): + seen: list[str] = [] + statuses: list[str] = [] + + async def on_event(event): + seen.append(event.user) + + async def on_status(state): + statuses.append(state) + + async def run(): + client = _client(on_event, on_status) + sock = _Script(client, [_welcome(), "not-json{", _raid("same"), _raid("same")]) + await client._read(sock, subscribe=True) + + asyncio.run(run()) + assert seen == ["Raider"] + assert "connected" in statuses + + +def test_session_keepalive_timeout(monkeypatch): + async def on_event(_event): + return None + + async def on_status(_state): + return None + + async def run(): + monkeypatch.setattr("src.twitch.eventsub.KEEPALIVE_GRACE", 0.05) + client = _client(on_event, on_status) + sock = _Script(client, [_welcome(keepalive=0.05), "HANG"]) + with pytest.raises(TimeoutError, match="keepalive timeout"): + await client._read(sock, subscribe=True) + + asyncio.run(run()) + assert KEEPALIVE_GRACE == 3.0 + + +def test_session_revocation_marks_the_feed(): + statuses: list[str] = [] + + async def on_event(_event): + return None + + async def on_status(state): + statuses.append(state) + + async def run(): + client = _client(on_event, on_status) + sock = _Script( + client, + [ + _welcome("rev"), + { + "metadata": {"message_type": "revocation"}, + "payload": {"subscription": {"type": "channel.raid", "status": "user_removed"}}, + }, + ], + ) + await client._read(sock, subscribe=True) + assert client.feed_status["raid"] == "revoked user_removed" + + asyncio.run(run()) + assert "revoked" in statuses + + +def test_reconnect_opens_the_new_socket_before_closing_the_old_one(monkeypatch): + order: list[str] = [] + + async def on_event(_event): + return None + + async def on_status(_state): + return None + + async def run(): + client = _client(on_event, on_status) + + async def connect(url, **_kwargs): + if "rejoin" in url: + order.append("open-new") + sock = _Script(client, [_welcome("new")], order, "new") + + async def recv(): + raw = await _Script.recv(sock) + client._stop = True + return raw + + sock.recv = recv + return sock + order.append("open-old") + return _Script( + client, + [ + _welcome("old"), + { + "metadata": {"message_type": "session_reconnect"}, + "payload": {"session": {"reconnect_url": "wss://example.test/rejoin"}}, + }, + ], + order, + "old", + ) + + monkeypatch.setattr("src.twitch.eventsub.websockets.connect", connect) + await client._run() + assert order.index("open-new") < order.index("close-old") + + asyncio.run(run()) diff --git a/tests/test_piper.py b/tests/test_piper.py new file mode 100644 index 0000000..8aafa52 --- /dev/null +++ b/tests/test_piper.py @@ -0,0 +1,55 @@ +import hashlib +from pathlib import Path + +import pytest + +from src.tts.piper import PIPER_ZIP_SHA256, VOICE_SHA256, _download, _ensure_pinned_file, piper_voice_urls + + +def test_piper_voice_urls_and_pins(): + onnx, meta = piper_voice_urls("en_US-amy-medium") + assert onnx.endswith("/en/en_US/amy/medium/en_US-amy-medium.onnx") + assert meta.endswith("/en/en_US/amy/medium/en_US-amy-medium.onnx.json") + assert len(PIPER_ZIP_SHA256) == 64 + assert "en_US-amy-medium.onnx" in VOICE_SHA256 + + +class _Body: + def __init__(self, data: bytes): + self._data = data + self.headers = {"Content-Length": str(len(data))} + + def read(self, size: int) -> bytes: + chunk, self._data = self._data[:size], self._data[size:] + return chunk + + def __enter__(self): + return self + + def __exit__(self, *_exc): + return False + + +def test_download_checks_the_pin_before_replacing(tmp_path: Path, monkeypatch): + body = b"voice-bytes" + digest = hashlib.sha256(body).hexdigest() + monkeypatch.setattr("src.tts.piper.urlopen", lambda *_a, **_k: _Body(body)) + dest = tmp_path / "voice.onnx" + _download("http://example.test/voice.onnx", dest, expected_sha256=digest) + assert dest.read_bytes() == body + assert not (tmp_path / "voice.onnx.part").exists() + + monkeypatch.setattr("src.tts.piper.urlopen", lambda *_a, **_k: _Body(b"tampered")) + bad = tmp_path / "bad.onnx" + with pytest.raises(RuntimeError, match="checksum mismatch"): + _download("http://example.test/bad.onnx", bad, expected_sha256=digest) + assert not bad.exists() + assert not (tmp_path / "bad.onnx.part").exists() + + +def test_existing_file_with_the_wrong_checksum_is_left_alone(tmp_path: Path): + dest = tmp_path / "en_US-amy-medium.onnx" + dest.write_bytes(b"already here") + with pytest.raises(RuntimeError, match="checksum mismatch"): + _ensure_pinned_file("http://example.test/voice.onnx", dest, "a" * 64) + assert dest.read_bytes() == b"already here" diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..f92374c --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,46 @@ +from pathlib import Path + +from src.settings import update_config + + +def test_update_config_keeps_comments_and_backup(tmp_path: Path): + cfg = tmp_path / "config.yaml" + cfg.write_text( + "# booth header\n" + "audio:\n" + " # silence comment stays on its own line\n" + " silence_ms: 800\n" + " energy_threshold: 0.012\n" + "\n" + "chat:\n" + " cooldown_seconds: 12\n", + encoding="utf-8", + ) + update_config( + { + "audio.silence_ms": 900, + "chat.brand_new": "kept", + "fresh.flag": False, + "skipme": "no dot", + }, + path=cfg, + ) + text = cfg.read_text(encoding="utf-8") + assert "# booth header" in text + assert "# silence comment stays on its own line" in text + assert "silence_ms: 900" in text + assert "energy_threshold: 0.012" in text + assert "brand_new: kept" in text + assert "fresh:" in text + assert "flag: false" in text + assert "skipme" not in text + assert (tmp_path / "config.yaml.bak").read_text(encoding="utf-8").startswith("# booth header") + + +def test_update_config_preserves_crlf(tmp_path: Path): + cfg = tmp_path / "config.yaml" + cfg.write_bytes(b"audio:\r\n silence_ms: 800\r\n") + update_config({"audio.silence_ms": 700}, path=cfg) + raw = cfg.read_bytes() + assert b"\r\n" in raw + assert b"silence_ms: 700" in raw