Compare commits
73 Commits
6b7d733650
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e1c32f22c | ||
|
|
f470d6d958 | ||
|
|
28f4c63e99 | ||
|
|
dd4c6b95b7 | ||
|
|
b658053711 | ||
|
|
20c1d738be | ||
|
|
ecfa7cea39 | ||
|
|
ef18915807 | ||
|
|
69976196cd | ||
|
|
c851e82990 | ||
|
|
ad12843e75 | ||
|
|
62a4191200 | ||
|
|
135a3791e2 | ||
|
|
a87f75adf1 | ||
|
|
da9ed51c74 | ||
|
|
56f6b9822f | ||
|
|
09880624d5 | ||
|
|
3c475107e3 | ||
|
|
b3006b02e2 | ||
|
|
8b504364a9 | ||
|
|
40c6bf8c53 | ||
|
|
a76d46b1de | ||
|
|
0ffddb8e41 | ||
|
|
62b01c76f7 | ||
|
|
e0db0ad567 | ||
|
|
c41035ceca | ||
|
|
cd4124e07a | ||
|
|
717bf59a05 | ||
|
|
5d0e200fbe | ||
|
|
8d54322ce1 | ||
|
|
e920ec5f10 | ||
|
|
c522d30c36 | ||
|
|
068734d931 | ||
|
|
36da191b45 | ||
|
|
6083de13f9 | ||
|
|
6d6b957557 | ||
|
|
f72f55148b | ||
|
|
e9d17e8b00 | ||
|
|
3afeace6e7 | ||
|
|
b88a459142 | ||
|
|
ad1de1653e | ||
|
|
8f1df167b9 | ||
|
|
de2d1fdf15 | ||
|
|
82f5984631 | ||
|
|
1744e7087f | ||
|
|
0c0adef90d | ||
|
|
3dada3fc06 | ||
|
|
6e40daa8a9 | ||
|
|
ba1af461de | ||
|
|
004656a64f | ||
|
|
192ea717a7 | ||
|
|
7a4aa65882 | ||
|
|
2cd1d5efb1 | ||
|
|
95981275b5 | ||
|
|
66116d2caf | ||
|
|
eded764f6a | ||
|
|
9783365b1e | ||
|
|
165938a801 | ||
|
|
221cb1f06b | ||
|
|
c4908f2a63 | ||
|
|
c493583a71 | ||
|
|
7c099d8cf0 | ||
|
|
e127f72660 | ||
|
|
7b9359c152 | ||
|
|
9fbf45f67d | ||
|
|
039f060b50 | ||
|
|
df20c154ca | ||
|
|
ab924444de | ||
|
|
ec55c2aef1 | ||
|
|
f899241d73 | ||
|
|
f189cbd290 | ||
|
|
9d58a5d073 | ||
|
|
e4e1e219f0 |
@@ -4,17 +4,80 @@ on:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: ghcr.io/gitleaks/gitleaks:latest
|
||||
options: --entrypoint ""
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone "$REPO_URL" .
|
||||
git checkout "${{ github.sha }}"
|
||||
- name: Scan for secrets
|
||||
run: gitleaks detect --source . --verbose
|
||||
|
||||
lint:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: python:3.13-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth=1 "$REPO_URL" .
|
||||
git checkout "${{ github.sha }}"
|
||||
- name: Install deps
|
||||
run: pip install -q -r requirements-dev.txt
|
||||
- name: Lint
|
||||
run: ruff check src/ tests/ plugins/
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux
|
||||
needs: [lint]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
container:
|
||||
image: python:${{ matrix.python-version }}-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth=1 "$REPO_URL" .
|
||||
git checkout "${{ github.sha }}"
|
||||
- name: Install system deps
|
||||
run: |
|
||||
apk add --no-cache opus opus-dev
|
||||
ln -sf /usr/lib/libopus.so.0 /usr/lib/libopus.so
|
||||
- name: Install Python deps
|
||||
run: pip install -q -r requirements-dev.txt
|
||||
- name: Patch pymumble/opuslib for musl
|
||||
run: python3 patches/apply_pymumble_ssl.py
|
||||
- name: Test
|
||||
run: pytest -v
|
||||
|
||||
build:
|
||||
runs-on: linux
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
needs: [gitleaks, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -e . && pip install pytest ruff
|
||||
- run: ruff check src/ tests/ plugins/
|
||||
- run: pytest -v
|
||||
- name: Login to Harbor
|
||||
run: >-
|
||||
podman login harbor.mymx.me
|
||||
-u "${{ secrets.HARBOR_USER }}"
|
||||
-p "${{ secrets.HARBOR_PASS }}"
|
||||
- name: Build and push
|
||||
run: |
|
||||
TAG="harbor.mymx.me/library/derp:${GITHUB_SHA::8}"
|
||||
LATEST="harbor.mymx.me/library/derp:latest"
|
||||
podman build -t "$TAG" -t "$LATEST" .
|
||||
podman push "$TAG"
|
||||
podman push "$LATEST"
|
||||
|
||||
2
.gitleaks.toml
Normal file
2
.gitleaks.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[allowlist]
|
||||
paths = ["tests/"]
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM python:3.13-alpine
|
||||
|
||||
RUN apk add --no-cache opus ffmpeg yt-dlp && \
|
||||
RUN apk add --no-cache opus ffmpeg yt-dlp rubberband && \
|
||||
ln -s /usr/lib/libopus.so.0 /usr/lib/libopus.so
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -28,10 +28,13 @@ CLI (argparse) -> Config (TOML) -> Bot (orchestrator)
|
||||
| Category | Plugins | Purpose |
|
||||
|----------|---------|---------|
|
||||
| Core | core | Bot management, help, plugin lifecycle |
|
||||
| OSINT | dns, crtsh | Reconnaissance and enumeration |
|
||||
| OSINT | dns, crtsh, internetdb | Reconnaissance and enumeration |
|
||||
| Red Team | revshell, encode, hash | Offensive tooling |
|
||||
| OPSEC | defang | Safe IOC handling |
|
||||
| Utility | cidr, example | Network tools, demo |
|
||||
| Utility | cidr, rand, timer, remind | Network tools, scheduling |
|
||||
| Music | music, lastfm | Mumble playback, discovery (Last.fm/MB) |
|
||||
| Voice | voice, mumble_admin | STT/TTS, server admin |
|
||||
| Subscriptions | rss, yt, twitch, alert | Feed monitoring, keyword alerts |
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
|
||||
44
ROADMAP.md
44
ROADMAP.md
@@ -159,3 +159,47 @@
|
||||
- [ ] Slack adapter via Socket Mode WebSocket
|
||||
- [ ] Mattermost adapter via WebSocket API
|
||||
- [ ] Bluesky adapter via AT Protocol firehose + REST API
|
||||
|
||||
## v2.3.0 -- Mumble Voice + Multi-Bot (done)
|
||||
|
||||
- [x] pymumble transport rewrite (voice + text)
|
||||
- [x] Music playback: play/stop/skip/prev/queue/np/volume/seek/resume
|
||||
- [x] Voice ducking (auto-lower music on voice activity)
|
||||
- [x] Kept track library with metadata (!keep, !kept, !play #N)
|
||||
- [x] Smooth fade-out on skip/stop/prev, fade-in on resume
|
||||
- [x] In-stream seek with pipeline swap (no task cancellation)
|
||||
- [x] Multi-bot Mumble: extra bots via `[[mumble.extra]]`
|
||||
- [x] Per-bot plugin filtering (only_plugins / except_plugins)
|
||||
- [x] Voice STT (Whisper) + TTS (Piper) plugin
|
||||
- [x] Configurable voice profiles (voice, FX, piper params)
|
||||
- [x] Rubberband pitch-shifting via CLI (Alpine ffmpeg lacks librubberband)
|
||||
- [x] Bot audio ignored in sound callback (no self-ducking, no STT of bots)
|
||||
- [x] Self-mute support (mute on connect, unmute for audio, re-mute after)
|
||||
- [x] Autoplay shuffled kept tracks on reconnect (silence detection)
|
||||
- [x] Alias plugin (!alias add/del/list)
|
||||
- [x] Container management tools (tools/build, start, stop, restart, nuke, logs, status)
|
||||
|
||||
## v2.4.0 -- Music Discovery + Performance (done)
|
||||
|
||||
- [x] Last.fm integration (artist.getSimilar, artist.getTopTags, track.getSimilar)
|
||||
- [x] `!similar` command (find similar artists, optionally queue via YouTube)
|
||||
- [x] `!tags` command (genre/style tags for current track)
|
||||
- [x] MusicBrainz fallback for `!similar` and `!tags` (no API key required)
|
||||
- [x] Auto-discover similar tracks during autoplay via Last.fm/MusicBrainz
|
||||
- [x] Mumble server admin plugin (`!mu` -- kick, ban, mute, move, channels)
|
||||
- [x] Pause/unpause (`!pause` toggle, position tracking, stale re-download)
|
||||
- [x] Autoplay continuous radio (random kept, silence-aware, cooldown between tracks)
|
||||
- [x] Periodic resume persistence (10s interval, survives hard kills)
|
||||
- [x] Track duration in `!np` (elapsed/total via ffprobe)
|
||||
- [x] `!announce` toggle (optional track announcements)
|
||||
- [x] Direct bot addressing (`merlin: say <text>`, TTS via voice peer)
|
||||
- [x] Self-deafen on connect
|
||||
- [x] Fade-out click fix (conditional buffer clear, post-fade drain)
|
||||
- [x] cProfile analysis tool (`tools/profile`)
|
||||
- [x] Mute detection: skip duck silence when all users muted
|
||||
- [x] Autoplay shuffle deck (no repeats until full cycle)
|
||||
- [x] Seek clamp to track duration (prevent seek-past-end stall)
|
||||
- [x] Iterative `_extract_videos` (replace 51K-deep recursion with stack)
|
||||
- [x] Bypass SOCKS5 for local SearXNG (`proxy=False`)
|
||||
- [x] Connection pool: `preload_content=True` for SOCKS connection reuse
|
||||
- [x] Pool tuning: 30 pools / 8 connections (up from 20/4)
|
||||
|
||||
97
TASKS.md
97
TASKS.md
@@ -1,6 +1,96 @@
|
||||
# derp - Tasks
|
||||
|
||||
## Current Sprint -- v2.3.0 Mumble Music Playback (2026-02-21)
|
||||
## Current Sprint -- Discovery Playlists (2026-02-23)
|
||||
|
||||
| Pri | Status | Task |
|
||||
|-----|--------|------|
|
||||
| P0 | [x] | `!similar` default: discover + resolve + play (playlist mode) |
|
||||
| P0 | [x] | `!similar list` subcommand for display-only (old default) |
|
||||
| P0 | [x] | `_search_queries()` normalizes Last.fm/MB results to search strings |
|
||||
| P0 | [x] | `_resolve_playlist()` parallel yt-dlp resolution via ThreadPoolExecutor |
|
||||
| P1 | [x] | Playback transition: fade out, clear queue, load playlist, fade in |
|
||||
| P1 | [x] | Fallback to display when music plugin not loaded |
|
||||
| P1 | [x] | Tests: 11 new cases (81 total in test_lastfm.py, 1949 suite total) |
|
||||
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, TASKS.md) |
|
||||
|
||||
## Previous Sprint -- Enhanced Help with FlaskPaste (2026-02-23)
|
||||
|
||||
| Pri | Status | Task |
|
||||
|-----|--------|------|
|
||||
| P0 | [x] | `!help <cmd>` pastes docstring detail via FlaskPaste, appends URL |
|
||||
| P0 | [x] | `!help <plugin>` pastes all plugin command details |
|
||||
| P0 | [x] | `!help` (no args) pastes full reference grouped by plugin |
|
||||
| P1 | [x] | 3-level hierarchy: plugin (col 0), command (indent 4), docstring (indent 8) |
|
||||
| P1 | [x] | Graceful fallback when FlaskPaste not loaded or paste fails |
|
||||
| P1 | [x] | Helper functions: `_build_cmd_detail(indent=)`, `_paste` |
|
||||
| P1 | [x] | Tests: 7 new cases in test_core.py (11 total) |
|
||||
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, TASKS.md) |
|
||||
|
||||
## Previous Sprint -- MusicBrainz Fallback (2026-02-23)
|
||||
|
||||
| Pri | Status | Task |
|
||||
|-----|--------|------|
|
||||
| P0 | [x] | `!similar` MusicBrainz fallback when no Last.fm API key or empty results |
|
||||
| P0 | [x] | `!tags` MusicBrainz fallback when no Last.fm API key or empty results |
|
||||
| P1 | [x] | `!similar play` works through MB fallback path |
|
||||
| P1 | [x] | Tests: MB fallback for both commands (6 new cases, 70 total in test_lastfm.py) |
|
||||
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, ROADMAP.md, TODO.md, PROJECT.md) |
|
||||
|
||||
## Previous Sprint -- Voice + Music UX (2026-02-22)
|
||||
|
||||
| Pri | Status | Task |
|
||||
|-----|--------|------|
|
||||
| P0 | [x] | Acknowledge tone (880Hz/1320Hz chime) before TTS playback |
|
||||
| P0 | [x] | Duck-before-TTS: 1.5s delay for music to lower before audio starts |
|
||||
| P0 | [x] | Instant packet-based ducking via pymumble sound callback (~20ms) |
|
||||
| P0 | [x] | Duck floor raised to 2% (keep music audible during voice) |
|
||||
| P0 | [x] | Strip leading punctuation from voice trigger remainder |
|
||||
| P0 | [x] | Fix greeting tests: move greet TTS to voice plugin `on_connected` |
|
||||
| P0 | [x] | Whisper `initial_prompt` bias for trigger word recognition |
|
||||
| P1 | [x] | Queue display improvements (`!queue` shows elapsed/duration, totals) |
|
||||
| P1 | [x] | Playlist save/load/list/del (`!playlist save <name>`, etc.) |
|
||||
| P2 | [ ] | Per-channel voice settings (different voice per Mumble channel) |
|
||||
|
||||
## Previous Sprint -- Performance: HTTP + Parsing (2026-02-22)
|
||||
|
||||
| Pri | Status | Task |
|
||||
|-----|--------|------|
|
||||
| P0 | [x] | Rewrite `_extract_videos` as iterative stack-based (51K recursive calls from 4 invocations) |
|
||||
| P0 | [x] | `plugins/searx.py` -- route through `derp.http.urlopen(proxy=False)` |
|
||||
| P1 | [x] | Connection pool: `preload_content=True` + `_PooledResponse` wrapper for connection reuse |
|
||||
| P1 | [x] | Pool tuning: `num_pools=30, maxsize=8` (was 20/4) |
|
||||
| P2 | [x] | Audit remaining plugins for unnecessary proxy routing |
|
||||
|
||||
## Previous Sprint -- Music Discovery via Last.fm (2026-02-22)
|
||||
|
||||
| Pri | Status | Task |
|
||||
|-----|--------|------|
|
||||
| P0 | [x] | `plugins/lastfm.py` -- Last.fm API client (artist.getSimilar, artist.getTopTags, track.getSimilar) |
|
||||
| P0 | [x] | `!similar` command -- show similar artists for current or named track/artist |
|
||||
| P0 | [x] | `!similar play` -- queue a similar track via YouTube search |
|
||||
| P1 | [x] | `!tags` command -- show genre/style tags for current or named track |
|
||||
| P1 | [x] | Config: `[lastfm] api_key` or `LASTFM_API_KEY` env var |
|
||||
| P2 | [x] | Tests: `test_lastfm.py` (50 cases: API helpers, metadata, commands) |
|
||||
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md) |
|
||||
|
||||
## Previous Sprint -- v2.3.0 Mumble Voice + Multi-Bot (2026-02-22)
|
||||
|
||||
| Pri | Status | Task |
|
||||
|-----|--------|------|
|
||||
| P0 | [x] | `src/derp/mumble.py` -- rewrite to pymumble transport (voice + text) |
|
||||
| P0 | [x] | `plugins/music.py` -- play/stop/skip/queue/np/volume/seek/resume |
|
||||
| P0 | [x] | `plugins/voice.py` -- STT (Whisper) + TTS (Piper), voice profiles |
|
||||
| P0 | [x] | Container patches for pymumble ssl + opuslib musl |
|
||||
| P0 | [x] | Multi-bot Mumble (`[[mumble.extra]]`), per-bot plugin filtering |
|
||||
| P0 | [x] | Rubberband pitch-shifting via CLI (Containerfile + FX chain split) |
|
||||
| P0 | [x] | Bot audio ignored in sound callback (no self-ducking/STT of bots) |
|
||||
| P0 | [x] | Self-mute support (mute on join, unmute for audio, re-mute after) |
|
||||
| P1 | [x] | `plugins/alias.py` -- command aliases (add/del/list) |
|
||||
| P1 | [x] | Container management tools (`tools/build,start,stop,restart,nuke,logs,status`) |
|
||||
| P1 | [x] | Tests: `test_mumble.py`, `test_music.py`, `test_alias.py`, `test_core.py` |
|
||||
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, ROADMAP.md) |
|
||||
|
||||
## Previous Sprint -- v2.3.0 Mumble Music Playback (2026-02-21)
|
||||
|
||||
| Pri | Status | Task |
|
||||
|-----|--------|------|
|
||||
@@ -242,6 +332,11 @@
|
||||
|
||||
| Date | Task |
|
||||
|------|------|
|
||||
| 2026-02-23 | `!similar` discovery playlists (parallel resolve, fade transition, list subcommand) |
|
||||
| 2026-02-23 | Enhanced `!help` with FlaskPaste detail pages (docstrings, grouped reference) |
|
||||
| 2026-02-23 | MusicBrainz fallback for `!similar` and `!tags` (no Last.fm key required) |
|
||||
| 2026-02-22 | v2.3.0 (voice profiles, rubberband FX, multi-bot, self-mute, container tools) |
|
||||
| 2026-02-21 | v2.3.0 (pymumble rewrite, music playback, fades, seek, kept library) |
|
||||
| 2026-02-17 | v1.2.3 (paste overflow with FlaskPaste integration) |
|
||||
| 2026-02-17 | v1.2.1 (HTTP opener cache, alert perf, concurrent multi-instance, tracemalloc) |
|
||||
| 2026-02-16 | v1.2.0 (subscriptions, alerts, proxy, reminders) |
|
||||
|
||||
34
TODO.md
34
TODO.md
@@ -130,6 +130,17 @@ is preserved in git history for reference.
|
||||
- [ ] SASL authentication
|
||||
- [ ] TLS/STARTTLS connection
|
||||
|
||||
## Performance
|
||||
|
||||
- [x] Iterative `_extract_videos` in alert.py (replaced 51K-deep recursion)
|
||||
- [x] Bypass SOCKS5 for local services (SearXNG `proxy=False`)
|
||||
- [x] Connection pool tuning (30 pools / 8 connections)
|
||||
- [ ] Async HTTP client (aiohttp + aiohttp-socks) to avoid blocking executors
|
||||
- [x] Connection pooling via urllib3 SOCKSProxyManager
|
||||
- [x] Batch OG fetch via ThreadPoolExecutor
|
||||
- [x] HTTP opener caching at module level
|
||||
- [x] Per-backend error tracking with exponential backoff
|
||||
|
||||
## Mumble
|
||||
|
||||
- [x] Mumble adapter via TCP/TLS + protobuf control channel (no SDK)
|
||||
@@ -137,6 +148,29 @@ is preserved in git history for reference.
|
||||
- [x] Text chat only (no voice)
|
||||
- [x] Channel-based messaging
|
||||
- [x] Minimal protobuf encoder/decoder (no protobuf dep)
|
||||
- [x] pymumble transport rewrite (voice + text)
|
||||
- [x] Music playback (yt-dlp + ffmpeg + Opus)
|
||||
- [x] Voice STT/TTS (Whisper + Piper)
|
||||
- [x] Multi-bot with per-bot plugin filtering
|
||||
- [x] Configurable voice profiles (voice, FX chain)
|
||||
- [x] Self-mute support (auto mute/unmute around audio)
|
||||
- [x] Bot audio isolation (ignore own bots in sound callback)
|
||||
- [x] Pause/unpause with position tracking, stale stream re-download, rewind + fade-in
|
||||
- [x] Autoplay continuous radio (random kept track, silence-aware, configurable cooldown)
|
||||
- [x] Periodic resume state persistence (survives hard kills)
|
||||
- [x] Track duration in `!np` (ffprobe), optional `!announce` toggle
|
||||
- [x] Direct bot addressing (`merlin: say <text>`)
|
||||
- [x] Self-deafen on connect
|
||||
- [ ] Per-channel voice settings (different voice per channel)
|
||||
- [ ] Voice activity log (who spoke, duration, transcript)
|
||||
|
||||
## Music Discovery
|
||||
|
||||
- [x] Last.fm integration (API key, free tier)
|
||||
- [x] `!similar` command -- find similar artists/tracks via Last.fm
|
||||
- [x] `!tags` command -- show genre/style tags for current track
|
||||
- [x] Auto-discover similar tracks during autoplay via Last.fm/MusicBrainz
|
||||
- [x] MusicBrainz fallback for `!similar` and `!tags` (no API key required)
|
||||
|
||||
## Slack
|
||||
|
||||
|
||||
@@ -16,4 +16,6 @@ services:
|
||||
- ./config/derp.toml:/app/config/derp.toml:ro,Z
|
||||
- ./data:/app/data:Z
|
||||
- ./secrets:/app/secrets:ro,Z
|
||||
environment:
|
||||
- OPENROUTER_API_KEY
|
||||
command: ["--verbose"]
|
||||
|
||||
333
docs/AUDIO.md
Normal file
333
docs/AUDIO.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Audio Engine -- Issues, Fixes, and Consolidation Notes
|
||||
|
||||
Technical reference for the Mumble audio pipeline: known issues,
|
||||
applied fixes, architectural decisions, and areas for future work.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
yt-dlp -> ffmpeg (decode to s16le 48kHz mono) -> PCM frames (20ms)
|
||||
-> volume ramp/scale -> pymumble sound_output -> Opus encode -> Mumble
|
||||
```
|
||||
|
||||
Key components:
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/derp/mumble.py` | `stream_audio()` -- PCM feed loop, volume ramp, seek |
|
||||
| `plugins/music.py` | Queue, play loop, fade orchestration, duck monitor |
|
||||
|
||||
### Volume control layers (evaluated per-frame, highest priority first)
|
||||
|
||||
1. **fade_vol** -- active during fade-out (skip/stop/pause); set to 0 as target
|
||||
2. **duck_vol** -- voice-activated ducking; snap to floor, linear restore
|
||||
3. **volume** -- user-set level (0-100)
|
||||
|
||||
The play loop passes a lambda to `stream_audio`:
|
||||
|
||||
```python
|
||||
volume=lambda: (
|
||||
ps["fade_vol"] if ps["fade_vol"] is not None else
|
||||
ps["duck_vol"] if ps["duck_vol"] is not None else
|
||||
ps["volume"]
|
||||
) / 100.0
|
||||
```
|
||||
|
||||
### Per-frame volume ramping
|
||||
|
||||
`stream_audio` never jumps to the target volume. Each 20ms frame is
|
||||
ramped from `_cur_vol` toward `target` by at most `step`:
|
||||
|
||||
- **_max_step** = 0.005 (~4s full ramp) -- ceiling for normal changes
|
||||
- **fade_in_step** -- computed from fade-in duration (default 5s)
|
||||
- **fade_step** -- override from plugin (fade-out on skip/stop/pause)
|
||||
|
||||
When `abs(diff) < 0.0001`, flat scaling is used (avoids ramp artifacts
|
||||
on steady-state frames). Otherwise, `_scale_pcm_ramp()` linearly
|
||||
interpolates across all 960 samples in the frame.
|
||||
|
||||
---
|
||||
|
||||
## Issues and Fixes
|
||||
|
||||
### 1. Alpine ffmpeg lacks librubberband
|
||||
|
||||
**Symptom:** 13/15 voice audition samples failed. `rubberband` audio
|
||||
filter unavailable in ffmpeg.
|
||||
|
||||
**Root cause:** Alpine's ffmpeg package is compiled without
|
||||
`--enable-librubberband`.
|
||||
|
||||
**Fix:** Added `rubberband` CLI package to `Containerfile`. Created
|
||||
`_split_fx()` in `plugins/voice.py` to parse FX chains: pitch-shifting
|
||||
goes through the `rubberband` CLI binary, remaining filters (bass, echo)
|
||||
through ffmpeg. Two-stage pipeline.
|
||||
|
||||
**Files:** `Containerfile`, `plugins/voice.py`
|
||||
|
||||
---
|
||||
|
||||
### 2. Self-ducking between bots
|
||||
|
||||
**Symptom:** derp's music volume dropped when merlin spoke (TTS).
|
||||
|
||||
**Root cause:** merlin's TTS output triggered `_on_sound_received`,
|
||||
which updated the shared `registry._voice_ts` timestamp. derp's duck
|
||||
monitor saw recent voice activity and ducked.
|
||||
|
||||
**Fix:** `_on_sound_received` checks `registry._bots` and returns early
|
||||
for any bot username -- no timestamp update, no listener dispatch.
|
||||
|
||||
```python
|
||||
def _on_sound_received(self, user, sound_chunk) -> None:
|
||||
name = user["name"] if isinstance(user, dict) else None
|
||||
bots = getattr(self.registry, "_bots", {})
|
||||
if name and name in bots:
|
||||
return # ignore audio from bots entirely
|
||||
```
|
||||
|
||||
**Files:** `src/derp/mumble.py`
|
||||
|
||||
---
|
||||
|
||||
### 3. Click/pop on skip/stop (fade-out cancellation)
|
||||
|
||||
**Symptom:** Audible glitch at the end of fade-out when skipping or
|
||||
stopping a track.
|
||||
|
||||
**Root cause:** `_fade_and_cancel()` fades volume to 0 over ~3s, then
|
||||
calls `task.cancel()`. In `stream_audio`, `CancelledError` triggers
|
||||
`clear_buffer()`, which drops any frames still queued in pymumble's
|
||||
output -- including frames that were encoded at non-zero amplitude a
|
||||
few frames earlier. The sudden buffer wipe produces a click.
|
||||
|
||||
**Fix (two-part):**
|
||||
|
||||
1. **Plugin side** (`music.py`): Added 150ms post-fade drain before
|
||||
cancel, giving pymumble time to flush remaining silent frames.
|
||||
|
||||
2. **Engine side** (`mumble.py`): `CancelledError` handler only calls
|
||||
`clear_buffer()` if `_cur_vol > 0.01`. When a fade-out has already
|
||||
driven volume to ~0, the remaining buffer frames are silent and
|
||||
clearing them is unnecessary.
|
||||
|
||||
```python
|
||||
# mumble.py -- CancelledError handler
|
||||
if _cur_vol > 0.01:
|
||||
self._mumble.sound_output.clear_buffer()
|
||||
```
|
||||
|
||||
```python
|
||||
# music.py -- _fade_and_cancel()
|
||||
await asyncio.sleep(duration)
|
||||
await asyncio.sleep(0.15) # drain window
|
||||
task.cancel()
|
||||
```
|
||||
|
||||
**Files:** `src/derp/mumble.py`, `plugins/music.py`
|
||||
|
||||
---
|
||||
|
||||
### 4. Fade-out math
|
||||
|
||||
**How it works:** `_fade_and_cancel(duration=3.0)` computes the
|
||||
per-frame step from the current effective volume:
|
||||
|
||||
```python
|
||||
cur_vol = (duck_vol or volume) / 100.0
|
||||
n_frames = duration / 0.02 # 150 frames for 3s
|
||||
step = cur_vol / n_frames
|
||||
```
|
||||
|
||||
The play loop sets `ps["fade_vol"] = 0` (the target) and
|
||||
`ps["fade_step"] = step` (the rate). `stream_audio` ramps `_cur_vol`
|
||||
toward 0 at `step` per frame. At 50% volume: step = 0.0033, reaching
|
||||
zero in exactly 150 frames (3.0s).
|
||||
|
||||
**Note:** `fade_vol` is set to 0 immediately, making the volume lambda
|
||||
return 0 as the target. The ramp code smoothly transitions -- there is
|
||||
no abrupt jump because `_cur_vol` tracks actual output level, not the
|
||||
target.
|
||||
|
||||
---
|
||||
|
||||
### 5. Self-mute lifecycle
|
||||
|
||||
**Requirement:** merlin mutes on connect, unmutes only when emitting
|
||||
audio (TTS), re-mutes after a delay.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```
|
||||
connect -> mute()
|
||||
stream_audio start -> cancel pending mute task, unmute()
|
||||
stream_audio finally -> spawn _delayed_mute(3.0)
|
||||
```
|
||||
|
||||
The 3-second delay prevents rapid mute/unmute flicker on back-to-back
|
||||
TTS. The mute task is cancelled if new audio starts before it fires.
|
||||
|
||||
**Config:** `self_mute = true` in `[[mumble.extra]]`
|
||||
|
||||
**Files:** `src/derp/mumble.py`
|
||||
|
||||
---
|
||||
|
||||
### 6. Self-deafen on connect
|
||||
|
||||
**Requirement:** merlin deafens on connect (no audio reception needed).
|
||||
|
||||
**Implementation:** `self_deaf = true` config flag, calls
|
||||
`self._mumble.users.myself.deafen()` in `_on_connected`.
|
||||
|
||||
**Files:** `src/derp/mumble.py`, `config/derp.toml`
|
||||
|
||||
---
|
||||
|
||||
## Pause/Resume
|
||||
|
||||
### Design
|
||||
|
||||
`!pause` toggles between paused and playing states:
|
||||
|
||||
**Pause:** Captures current track + elapsed position + monotonic
|
||||
timestamp. Fades out, cancels play loop. Queue is preserved.
|
||||
|
||||
**Unpause:** Re-inserts track at queue front, starts play loop with
|
||||
seek. Two special behaviors:
|
||||
|
||||
1. **Rewind:** 3s rewind on unpause for continuity (only if paused >= 3s
|
||||
to prevent anti-flood: rapid toggle doesn't compound the rewind).
|
||||
|
||||
2. **Stale stream:** If paused > 45s, cached stream files (in
|
||||
`data/music/cache/`) are deleted so the play loop re-downloads.
|
||||
Kept files (`data/music/`) are never deleted. Stream URLs from
|
||||
YouTube et al. expire within minutes.
|
||||
|
||||
3. **Fade-in:** Unpause always uses `fade_in=True` (5s ramp from 0).
|
||||
|
||||
**State cleanup:** `!stop` clears `ps["paused"]`. The play loop's
|
||||
`finally` block skips `_cleanup_track` when paused (preserves the file).
|
||||
|
||||
---
|
||||
|
||||
## Autoplay
|
||||
|
||||
### Design
|
||||
|
||||
When `autoplay = true` (config), the play loop stays alive after the
|
||||
queue empties:
|
||||
|
||||
1. Waits for silence (duck_silence threshold, default 15s)
|
||||
2. Picks one random kept track
|
||||
3. Plays it
|
||||
4. On completion, loops back to step 1
|
||||
|
||||
This replaces the previous bulk-queue approach (shuffle all kept tracks
|
||||
at once). Benefits: no large upfront queue, silence-aware gaps between
|
||||
tracks, indefinite looping.
|
||||
|
||||
### Resume persistence
|
||||
|
||||
A background task saves track URL + elapsed position to the state DB
|
||||
every 10 seconds during playback:
|
||||
|
||||
```python
|
||||
async def _periodic_save():
|
||||
while True:
|
||||
await asyncio.sleep(10)
|
||||
el = cur_seek + progress[0] * 0.02
|
||||
if el > 1.0:
|
||||
_save_resume(bot, track, el)
|
||||
```
|
||||
|
||||
On hard kill: resumes from at most ~10s behind. On normal track
|
||||
completion: `_clear_resume()` wipes the state.
|
||||
|
||||
---
|
||||
|
||||
## Voice Ducking
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
voice detected -> duck_vol = floor (instant)
|
||||
silence > duck_silence -> linear restore over duck_restore seconds
|
||||
```
|
||||
|
||||
The duck monitor runs as a background task alongside the play loop.
|
||||
It updates `ps["duck_vol"]` which the volume lambda reads per-frame.
|
||||
|
||||
### Restore ramp
|
||||
|
||||
Restoration is linear from floor to user volume. The per-frame ramp in
|
||||
`stream_audio` further smooths each 1-second update from the monitor,
|
||||
eliminating audible steps.
|
||||
|
||||
### Bot audio isolation
|
||||
|
||||
Bot usernames (from `registry._bots`) are excluded from
|
||||
`_on_sound_received` entirely -- no timestamp update, no listener
|
||||
dispatch. This prevents self-ducking between derp and merlin.
|
||||
|
||||
---
|
||||
|
||||
## Seek (in-stream pipeline swap)
|
||||
|
||||
### Design
|
||||
|
||||
Seek rebuilds the ffmpeg pipeline at the new position without cancelling
|
||||
the play loop task. This avoids the overhead of re-downloading.
|
||||
|
||||
1. Set `_seek_fading = True`, `_seek_fade_out = 10` (0.2s ramp-down)
|
||||
2. Continue reading frames, scaling by decreasing ratio
|
||||
3. At fade-out = 0: kill ffmpeg, clear buffer, spawn new pipeline
|
||||
4. 0.5s fade-in on the new pipeline
|
||||
|
||||
### Consolidation note
|
||||
|
||||
Seek fade-out (10 frames / 0.2s) is much shorter than skip/stop
|
||||
fade-out (3s). This is intentional -- seek should feel responsive.
|
||||
The mechanisms are separate: seek uses frame-counting in
|
||||
`stream_audio`, skip/stop uses `_fade_and_cancel` in the plugin.
|
||||
|
||||
---
|
||||
|
||||
## Consolidation Opportunities
|
||||
|
||||
### Volume control unification
|
||||
|
||||
Three volume layers (fade_vol, duck_vol, volume) evaluated in a lambda
|
||||
per-frame. Works but the priority logic is implicit. A future refactor
|
||||
could use a single `effective_volume()` method that explicitly resolves
|
||||
priority and makes the per-frame cost clearer.
|
||||
|
||||
### Fade-out ownership
|
||||
|
||||
Skip/stop/pause all route through `_fade_and_cancel()` -- good. But the
|
||||
fade target is communicated indirectly via `ps["fade_vol"] = 0` and
|
||||
`ps["fade_step"]`, read by a lambda in the play loop, evaluated in
|
||||
`stream_audio`. A more explicit signal (e.g. an asyncio.Event or a
|
||||
dedicated fade state machine in `stream_audio`) could simplify reasoning
|
||||
about timing.
|
||||
|
||||
### Buffer drain timing
|
||||
|
||||
The 150ms post-fade drain is empirical. A more robust approach would be
|
||||
to query `sound_output.get_buffer_size()` and wait for it to drop below
|
||||
a threshold before cancelling. This would adapt to varying network
|
||||
conditions and pymumble buffer sizes.
|
||||
|
||||
### Track duration
|
||||
|
||||
Duration is probed via `ffprobe` after download (blocking, run in
|
||||
executor). For kept tracks, it's stored in state metadata. This is
|
||||
duplicated -- kept track metadata already has duration from
|
||||
`_fetch_metadata` (yt-dlp). The `ffprobe` path is the fallback for
|
||||
non-kept tracks. Could unify by always probing locally.
|
||||
|
||||
### Periodic resume save interval
|
||||
|
||||
Currently 10s fixed. Could be adaptive -- save more frequently near
|
||||
the start of a track (where losing position is more noticeable) and
|
||||
less frequently later. Marginal benefit vs. complexity though.
|
||||
@@ -53,28 +53,53 @@ format = "json" # JSONL output (default: "text")
|
||||
## Container
|
||||
|
||||
```bash
|
||||
make build # Build image (only for dep changes)
|
||||
make up # Start (podman-compose)
|
||||
make down # Stop
|
||||
make logs # Follow logs
|
||||
tools/build # Build image
|
||||
tools/build --no-cache # Rebuild from scratch
|
||||
tools/start # Start (builds if no image)
|
||||
tools/stop # Stop and remove container
|
||||
tools/restart # Stop + rebuild + start
|
||||
tools/restart --no-cache # Full clean restart
|
||||
tools/logs # Tail logs (default 30 lines)
|
||||
tools/logs 100 # Tail last 100 lines
|
||||
tools/status # Container, image, mount state
|
||||
tools/nuke # Full teardown (container + image)
|
||||
```
|
||||
|
||||
Code, plugins, config, and data are bind-mounted. No rebuild needed for
|
||||
code changes -- restart the container or use `!reload` for plugins.
|
||||
Rebuild only when `requirements.txt` or `Containerfile` change.
|
||||
|
||||
## Profiling
|
||||
|
||||
```bash
|
||||
tools/profile # Top 30 by cumulative time
|
||||
tools/profile -s tottime -n 20 # Top 20 by total time
|
||||
tools/profile -f mumble # Filter to mumble functions
|
||||
tools/profile -c -f stream_audio # Who calls stream_audio
|
||||
tools/profile data/old.prof # Analyze a specific file
|
||||
```
|
||||
|
||||
Sort keys: `cumtime`, `tottime`, `calls`, `name`.
|
||||
Profile data written on graceful shutdown when bot runs with `--cprofile`.
|
||||
|
||||
## Bot Commands
|
||||
|
||||
```
|
||||
!ping # Pong
|
||||
!help # List commands
|
||||
!help <cmd> # Command help
|
||||
!help <plugin> # Plugin description + commands
|
||||
!help # List commands + paste full reference
|
||||
!help <cmd> # Command help + paste docstring detail
|
||||
!help <plugin> # Plugin info + paste command details
|
||||
!version # Bot version
|
||||
!uptime # Bot uptime
|
||||
!echo <text> # Echo text back
|
||||
!h # Shorthand (any unambiguous prefix works)
|
||||
```
|
||||
|
||||
Detailed help is pasted to FlaskPaste and appended as a URL. Paste
|
||||
layout uses a 3-level hierarchy: `[plugin]` at column 0, `!command`
|
||||
at indent 4, docstring body at indent 8. Falls back gracefully if
|
||||
FlaskPaste is not loaded.
|
||||
|
||||
## Permission Tiers
|
||||
|
||||
```
|
||||
@@ -548,20 +573,78 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops.
|
||||
```
|
||||
!play <url|playlist> # Play audio (YouTube, SoundCloud, etc.)
|
||||
!play <playlist-url> # Playlist tracks expanded into queue
|
||||
!stop # Stop playback, clear queue
|
||||
!skip # Skip current track
|
||||
!queue # Show queue
|
||||
!play classical music # YouTube search, random pick from top 10
|
||||
!stop # Stop playback, clear queue (fades out)
|
||||
!skip # Skip current track (fades out)
|
||||
!prev # Go back to previous track (fades out)
|
||||
!seek 1:30 # Seek to position (also +30, -30)
|
||||
!resume # Resume last stopped/skipped track
|
||||
!queue # Show queue (with durations + totals)
|
||||
!queue <url> # Add to queue (alias for !play)
|
||||
!np # Now playing
|
||||
!volume # Show current volume
|
||||
!volume 75 # Set volume (0-100, default 50)
|
||||
!keep # Keep current file + save metadata
|
||||
!kept # List kept files with metadata
|
||||
!kept rm <id> # Remove a single kept track
|
||||
!kept clear # Delete all kept files + metadata
|
||||
!kept repair # Re-download missing kept files
|
||||
!duck # Show ducking status
|
||||
!duck on # Enable voice ducking
|
||||
!duck off # Disable voice ducking
|
||||
!duck floor 5 # Set duck floor volume (0-100, default 2)
|
||||
!duck silence 20 # Set silence timeout seconds (default 15)
|
||||
!duck restore 45 # Set restore ramp duration seconds (default 30)
|
||||
!playlist save <name> # Save current + queued tracks as named playlist
|
||||
!playlist load <name> # Append saved playlist to queue, start if idle
|
||||
!playlist list # Show saved playlists with track counts
|
||||
!playlist del <name> # Delete a saved playlist
|
||||
```
|
||||
|
||||
Requires: `yt-dlp`, `ffmpeg`, `libopus` on the host.
|
||||
Max 50 tracks in queue. Playlists auto-expand; excess truncated at limit.
|
||||
Volume ramps smoothly over ~200ms (no abrupt jumps mid-playback).
|
||||
Skip/stop/prev/seek fade out smoothly (~0.8s); volume ramps over ~1s.
|
||||
`!prev` pops from a 10-track history stack (populated on skip/finish).
|
||||
`!keep` fetches title/artist/duration via yt-dlp and stores in `bot.state`.
|
||||
`!resume` restores position across restarts (persisted via `bot.state`).
|
||||
Auto-resumes on reconnect if channel is silent (waits up to 60s for silence).
|
||||
Mumble-only: `!play` replies with error on other adapters, others silently no-op.
|
||||
|
||||
## Music Discovery
|
||||
|
||||
```
|
||||
!similar # Discover + play similar to current track
|
||||
!similar <artist> # Discover + play similar to named artist
|
||||
!similar list # Show similar (display only)
|
||||
!similar list <artist># Show similar for named artist
|
||||
!tags # Genre tags for current artist
|
||||
!tags <artist> # Genre tags for named artist
|
||||
```
|
||||
|
||||
Default `!similar` builds a playlist: discovers similar artists, resolves
|
||||
via YouTube in parallel, fades out current, plays the new playlist.
|
||||
`!similar list` shows results without playing.
|
||||
Uses Last.fm when API key is set; falls back to MusicBrainz automatically.
|
||||
Config: `[lastfm] api_key` or `LASTFM_API_KEY` env var.
|
||||
|
||||
## Mumble Admin (admin)
|
||||
|
||||
```
|
||||
!mu kick <user> [reason] # Kick user
|
||||
!mu ban <user> [reason] # Ban user
|
||||
!mu mute <user> # Server-mute
|
||||
!mu unmute <user> # Remove server-mute
|
||||
!mu deafen <user> # Server-deafen
|
||||
!mu undeafen <user> # Remove server-deafen
|
||||
!mu move <user> <channel> # Move user to channel
|
||||
!mu users # List connected users
|
||||
!mu channels # List channels
|
||||
!mu mkchan <name> [parent] # Create channel
|
||||
!mu rmchan <name> # Remove empty channel
|
||||
!mu rename <old> <new> # Rename channel
|
||||
!mu desc <channel> <text> # Set channel description
|
||||
```
|
||||
|
||||
## Plugin Template
|
||||
|
||||
```python
|
||||
|
||||
385
docs/USAGE.md
385
docs/USAGE.md
@@ -124,9 +124,9 @@ unchanged. The server name is derived from the hostname automatically.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!ping` | Bot responds with "pong" |
|
||||
| `!help` | List all available commands |
|
||||
| `!help <cmd>` | Show help for a specific command |
|
||||
| `!help <plugin>` | Show plugin description and its commands |
|
||||
| `!help` | List all commands + paste full reference |
|
||||
| `!help <cmd>` | Show help + paste detailed docstring |
|
||||
| `!help <plugin>` | Show plugin description + paste command details |
|
||||
| `!version` | Show bot version |
|
||||
| `!uptime` | Show how long the bot has been running |
|
||||
| `!echo <text>` | Echo back text (example plugin) |
|
||||
@@ -187,6 +187,8 @@ unchanged. The server name is derived from the hostname automatically.
|
||||
| `!username list` | Show available services by category |
|
||||
| `!alert <add\|del\|list\|check\|info\|history>` | Keyword alert subscriptions across platforms |
|
||||
| `!searx <query>` | Search SearXNG and show top results |
|
||||
| `!ask <question>` | Single-shot LLM question via OpenRouter |
|
||||
| `!chat <msg\|clear\|model\|models>` | Conversational LLM chat with history |
|
||||
| `!jwt <token>` | Decode JWT header, claims, and flag issues |
|
||||
| `!mac <address\|random\|update>` | MAC OUI vendor lookup / random MAC |
|
||||
| `!abuse <ip> [ip2 ...]` | AbuseIPDB reputation check |
|
||||
@@ -204,6 +206,30 @@ unchanged. The server name is derived from the hostname automatically.
|
||||
| `!cron <add\|del\|list>` | Scheduled command execution (admin) |
|
||||
| `!webhook` | Show webhook listener status (admin) |
|
||||
|
||||
### Detailed Help (FlaskPaste)
|
||||
|
||||
`!help` pastes detailed reference output to FlaskPaste and appends the
|
||||
URL. The paste uses a 3-level indentation hierarchy:
|
||||
|
||||
```
|
||||
[plugin-name]
|
||||
Plugin description.
|
||||
|
||||
!command -- short help
|
||||
Full docstring with usage, subcommands,
|
||||
and examples.
|
||||
|
||||
!other -- another command
|
||||
Its docstring here.
|
||||
```
|
||||
|
||||
- `!help` (no args) -- pastes the full reference grouped by plugin
|
||||
- `!help <cmd>` -- pastes the command's docstring (command at column 0)
|
||||
- `!help <plugin>` -- pastes all commands under the plugin header
|
||||
|
||||
If FlaskPaste is not loaded or the paste fails, the short IRC reply
|
||||
still works -- no regression.
|
||||
|
||||
### Command Shorthand
|
||||
|
||||
Commands can be abbreviated to any unambiguous prefix:
|
||||
@@ -815,6 +841,55 @@ Title Two -- https://example.com/page2
|
||||
Title Three -- https://example.com/page3
|
||||
```
|
||||
|
||||
### `!ask` / `!chat` -- LLM Chat (OpenRouter)
|
||||
|
||||
Chat with large language models via [OpenRouter](https://openrouter.ai/)'s
|
||||
API. `!ask` is stateless (single question), `!chat` maintains per-user
|
||||
conversation history.
|
||||
|
||||
```
|
||||
!ask <question> Single-shot question (no history)
|
||||
!chat <message> Chat with conversation history
|
||||
!chat clear Clear your history
|
||||
!chat model Show current model
|
||||
!chat model <name> Switch model
|
||||
!chat models List suggested free models
|
||||
```
|
||||
|
||||
Output format:
|
||||
|
||||
```
|
||||
<alice> !ask what is DNS
|
||||
<derp> DNS (Domain Name System) translates domain names to IP addresses...
|
||||
|
||||
<alice> !chat explain TCP
|
||||
<derp> TCP is a connection-oriented transport protocol...
|
||||
<alice> !chat how does the handshake work
|
||||
<derp> The TCP three-way handshake: SYN, SYN-ACK, ACK...
|
||||
```
|
||||
|
||||
- Open to all users, works in channels and PMs
|
||||
- Per-user cooldown: 5 seconds between requests
|
||||
- Conversation history capped at 20 messages per user (ephemeral, not
|
||||
persisted across restarts)
|
||||
- Responses truncated to 400 characters; multi-line replies use paste overflow
|
||||
- Default model: `openrouter/auto` (auto-routes to best available free model)
|
||||
- Reasoning models (DeepSeek R1) are handled transparently -- falls back to
|
||||
the `reasoning` field when `content` is empty
|
||||
- Rate limit errors (HTTP 429) produce a clear user-facing message
|
||||
|
||||
Configuration:
|
||||
|
||||
```toml
|
||||
[openrouter]
|
||||
api_key = "" # or set OPENROUTER_API_KEY env var
|
||||
model = "openrouter/auto" # default model
|
||||
system_prompt = "You are a helpful IRC bot assistant. Keep responses concise and under 200 words."
|
||||
```
|
||||
|
||||
API key: set `OPENROUTER_API_KEY` env var (preferred) or `api_key` under
|
||||
`[openrouter]` in config. The env var takes precedence.
|
||||
|
||||
### `!alert` -- Keyword Alert Subscriptions
|
||||
|
||||
Search keywords across 27 platforms and announce new results. Unlike
|
||||
@@ -1566,23 +1641,313 @@ and voice transmission.
|
||||
|
||||
```
|
||||
!play <url|playlist> Play audio or add to queue (playlists expanded)
|
||||
!stop Stop playback, clear queue
|
||||
!skip Skip current track
|
||||
!queue Show queue
|
||||
!play <query> Search YouTube, play a random result
|
||||
!stop Stop playback, clear queue (fade-out)
|
||||
!skip Skip current track (fade-out)
|
||||
!prev Go back to the previous track (fade-out)
|
||||
!seek <offset> Seek to position (1:30, 90, +30, -30)
|
||||
!resume Resume last stopped/skipped track from saved position
|
||||
!queue Show queue (with durations + totals)
|
||||
!queue <url> Add to queue (alias for !play)
|
||||
!np Now playing
|
||||
!volume [0-100] Get/set volume
|
||||
!volume [0-100] Get/set volume (persisted across restarts)
|
||||
!keep Keep current track's audio file (with metadata)
|
||||
!kept [rm <id>|clear|repair] List, remove, clear, or repair kept files
|
||||
!testtone Play 3-second 440Hz test tone
|
||||
!playlist save <name> Save current + queued tracks as named playlist
|
||||
!playlist load <name> Append saved playlist to queue, start if idle
|
||||
!playlist list Show saved playlists with track counts
|
||||
!playlist del <name> Delete a saved playlist
|
||||
```
|
||||
|
||||
- Queue holds up to 50 tracks
|
||||
- Non-URL input is treated as a YouTube search; 10 results are fetched
|
||||
and one is picked randomly
|
||||
- Playlists are expanded into individual tracks; excess tracks are
|
||||
truncated at the queue limit
|
||||
- Volume changes ramp smoothly over ~200ms (no abrupt jumps)
|
||||
- Default volume: 50%
|
||||
- `!skip`, `!stop`, `!prev`, and `!seek` fade out smoothly (~0.8s) before
|
||||
switching tracks; volume changes ramp smoothly over ~1s (no abrupt jumps)
|
||||
- Default volume: 50%; persisted via `bot.state` across restarts
|
||||
- Titles resolved via `yt-dlp --flat-playlist` before playback
|
||||
- Audio pipeline: `yt-dlp | ffmpeg` subprocess, PCM fed to pymumble
|
||||
- Audio is downloaded before playback (`data/music/`); files are deleted
|
||||
after playback unless `!keep` is used. Falls back to streaming on
|
||||
download failure.
|
||||
- Audio pipeline: `ffmpeg` subprocess for local files, `yt-dlp | ffmpeg`
|
||||
for streaming fallback, PCM fed to pymumble
|
||||
- Commands are Mumble-only; `!play` on other adapters replies with an error,
|
||||
other music commands silently no-op
|
||||
- Playback runs as an asyncio background task; the bot remains responsive
|
||||
to text commands during streaming
|
||||
- `!prev` returns to the last-played track; up to 10 tracks are kept in a
|
||||
per-session history stack (populated on skip and natural track completion)
|
||||
- `!resume` continues from where playback was interrupted (`!stop`/`!skip`);
|
||||
position is persisted via `bot.state` and survives bot restarts
|
||||
|
||||
### Auto-Resume on Reconnect
|
||||
|
||||
If the bot disconnects while music is playing (network hiccup, server
|
||||
restart), it saves the current track and position. On reconnect, it
|
||||
automatically resumes playback -- but only after the channel is silent
|
||||
(using the same silence threshold as voice ducking, default 15s).
|
||||
|
||||
- Resume state is saved on both explicit stop/skip and on stream errors
|
||||
(disconnect)
|
||||
- Works across container restarts (cold boot) and network reconnections
|
||||
- The bot waits up to 60s for silence; if the channel stays active, it
|
||||
aborts and the saved state remains for manual `!resume`
|
||||
- Chat messages announce resume intentions and abort reasons
|
||||
- The reconnect watcher starts via the `on_connected` plugin lifecycle hook
|
||||
|
||||
### Seeking
|
||||
|
||||
Fast-forward or rewind within the currently playing track.
|
||||
|
||||
```
|
||||
!seek 1:30 Seek to 1 minute 30 seconds
|
||||
!seek 90 Seek to 90 seconds
|
||||
!seek +30 Jump forward 30 seconds
|
||||
!seek -30 Jump backward 30 seconds
|
||||
!seek +1:00 Jump forward 1 minute
|
||||
```
|
||||
|
||||
- Absolute offsets (`1:30`, `90`) seek to that position from the start
|
||||
- Relative offsets (`+30`, `-1:00`) jump from the current position
|
||||
- Negative seeks are clamped to the start of the track
|
||||
- Seeking restarts the audio pipeline at the new position
|
||||
|
||||
### Disconnect-Resilient Streaming
|
||||
|
||||
During brief network disconnects (~5-15s), the audio stream stays alive.
|
||||
The ffmpeg pipeline keeps running; PCM frames are read at real-time pace
|
||||
but dropped while pymumble reconnects. Once the connection re-establishes
|
||||
and the codec is negotiated, audio feeding resumes automatically. The
|
||||
listener hears a brief silence instead of a 30+ second restart with URL
|
||||
re-resolution.
|
||||
|
||||
- The `_is_audio_ready()` guard checks: mumble connected, sound_output
|
||||
exists, Opus encoder initialized
|
||||
- Frames are counted even during disconnect, so position tracking remains
|
||||
accurate
|
||||
- State transitions (connected/disconnected) are logged for diagnostics
|
||||
|
||||
### Voice Ducking
|
||||
|
||||
When other users speak in the Mumble channel, the music volume automatically
|
||||
ducks (lowers) to a configurable floor. After a configurable silence period,
|
||||
volume gradually restores to the user-set level in small steps.
|
||||
|
||||
```
|
||||
!duck Show ducking status and settings
|
||||
!duck on Enable voice ducking
|
||||
!duck off Disable voice ducking
|
||||
!duck floor <0-100> Set floor volume % (default: 2)
|
||||
!duck silence <sec> Set silence timeout in seconds (default: 15)
|
||||
!duck restore <sec> Set restore ramp duration in seconds (default: 30)
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Enabled by default; voice is detected via pymumble's sound callback
|
||||
- When someone speaks, volume drops immediately to the floor value
|
||||
- After `silence` seconds of no voice, volume restores via a single
|
||||
smooth linear ramp over `restore` seconds (default 30s)
|
||||
- The per-frame volume ramp in `stream_audio` further smooths the
|
||||
transition, eliminating audible steps
|
||||
- Ducking resets when playback stops, skips, or the queue empties
|
||||
|
||||
Configuration (optional):
|
||||
|
||||
```toml
|
||||
[music]
|
||||
duck_enabled = true # Enable voice ducking (default: true)
|
||||
duck_floor = 1 # Floor volume % during ducking (default: 1)
|
||||
duck_silence = 15 # Seconds of silence before restoring (default: 15)
|
||||
duck_restore = 30 # Seconds for smooth volume restore (default: 30)
|
||||
```
|
||||
|
||||
### Download-First Playback
|
||||
|
||||
Audio is downloaded to `data/music/` before playback begins. This
|
||||
eliminates CDN hiccups mid-stream and enables instant seeking. Files
|
||||
are identified by a hash of the URL so the same URL reuses the same
|
||||
file (natural dedup).
|
||||
|
||||
- If download fails, playback falls back to streaming (`yt-dlp | ffmpeg`)
|
||||
- After a track finishes, the local file is automatically deleted
|
||||
- Use `!keep` during playback to preserve the file; metadata (title, artist,
|
||||
duration) is fetched via yt-dlp and stored in `bot.state`
|
||||
- Use `!kept` to list preserved files with metadata (title, artist, duration,
|
||||
file size)
|
||||
- Use `!kept rm <id>` to remove a single kept track (file + metadata)
|
||||
- Use `!kept clear` to delete all preserved files and their metadata
|
||||
- Use `!kept repair` to re-download any kept tracks whose local files are
|
||||
missing (e.g. after a cleanup or volume mount issue)
|
||||
- On cancel/error, files are not deleted (needed for `!resume`)
|
||||
|
||||
### Music Discovery
|
||||
|
||||
Find similar music and genre tags for artists. Uses Last.fm when an API
|
||||
key is configured; falls back to MusicBrainz automatically (no key
|
||||
required).
|
||||
|
||||
```
|
||||
!similar Discover + play similar to current track
|
||||
!similar <artist> Discover + play similar to named artist
|
||||
!similar list Show similar (display only)
|
||||
!similar list <artist> Show similar for named artist
|
||||
!tags Genre tags for currently playing artist
|
||||
!tags <artist> Genre tags for named artist
|
||||
```
|
||||
|
||||
- Default `!similar` builds a discovery playlist: finds similar artists/tracks,
|
||||
resolves each against YouTube in parallel, fades out current playback, and
|
||||
starts the new playlist
|
||||
- `!similar list` shows results without playing (old default behavior)
|
||||
- When an API key is set, Last.fm is tried first for richer results
|
||||
- When no API key is set (or Last.fm returns empty), MusicBrainz is
|
||||
used as a fallback (artist search -> tags -> similar recordings)
|
||||
- Without the music plugin loaded, `!similar` falls back to display mode
|
||||
- MusicBrainz rate limit: 1 request/second (handled automatically)
|
||||
|
||||
Configuration (optional):
|
||||
|
||||
```toml
|
||||
[lastfm]
|
||||
api_key = "" # Last.fm API key (or set LASTFM_API_KEY env var)
|
||||
```
|
||||
|
||||
### Autoplay Discovery
|
||||
|
||||
During autoplay, the bot periodically discovers new tracks instead of
|
||||
only playing from the kept library. Every Nth autoplay pick (configurable
|
||||
via `discover_ratio`), it queries Last.fm or MusicBrainz for a track
|
||||
similar to the last-played one. Discovered tracks are searched on YouTube
|
||||
and queued automatically.
|
||||
|
||||
Configuration (optional):
|
||||
|
||||
```toml
|
||||
[music]
|
||||
autoplay = true # Enable autoplay (default: true)
|
||||
autoplay_cooldown = 30 # Seconds between autoplay tracks (default: 30)
|
||||
discover = true # Enable discovery during autoplay (default: true)
|
||||
discover_ratio = 3 # Discover every Nth pick (default: 3)
|
||||
```
|
||||
|
||||
### Extra Mumble Bots
|
||||
|
||||
Run additional bot identities on the same Mumble server. Each extra bot
|
||||
inherits the main `[mumble]` connection settings and overrides only what
|
||||
differs (username, certificates, greeting). Extra bots share the plugin
|
||||
registry but get their own state DB and do **not** run the voice trigger
|
||||
by default (prevents double-processing).
|
||||
|
||||
```toml
|
||||
[[mumble.extra]]
|
||||
username = "merlin"
|
||||
certfile = "secrets/mumble/merlin.crt"
|
||||
keyfile = "secrets/mumble/merlin.key"
|
||||
greet = "The sorcerer has arrived."
|
||||
```
|
||||
|
||||
- `username`, `certfile`, `keyfile` -- identity overrides
|
||||
- `greet` -- TTS message spoken on first connect (optional)
|
||||
- All other `[mumble]` keys (host, port, password, admins, etc.) are inherited
|
||||
- Voice trigger is disabled unless the extra entry includes a `voice` key
|
||||
|
||||
### Voice STT/TTS
|
||||
|
||||
Transcribe voice from Mumble users via Whisper STT and speak text aloud
|
||||
via Piper TTS. Requires local Whisper and Piper services.
|
||||
|
||||
```
|
||||
!listen [on|off] Toggle voice-to-text transcription (admin)
|
||||
!listen Show current listen status
|
||||
!say <text> Speak text aloud via TTS (max 500 chars)
|
||||
```
|
||||
|
||||
STT behavior:
|
||||
|
||||
- When enabled, the bot buffers incoming voice PCM per user
|
||||
- After a configurable silence gap (default 1.5s), the buffer is
|
||||
transcribed via Whisper and posted as an action message
|
||||
- Utterances shorter than 0.5s are discarded (noise filter)
|
||||
- Utterances are capped at 30s to bound memory and latency
|
||||
- Transcription results are posted as: `* derp heard Alice say: hello`
|
||||
- The listener survives reconnects when `!listen` is on
|
||||
|
||||
TTS behavior:
|
||||
|
||||
- `!say` fetches WAV from Piper and plays it via `stream_audio()`
|
||||
- Piper outputs 22050Hz WAV; ffmpeg resamples to 48kHz automatically
|
||||
- TTS shares the audio output with music playback
|
||||
- Text is limited to 500 characters
|
||||
- Set `greet` in `[mumble]` or `[[mumble.extra]]` for automatic TTS on first connect
|
||||
|
||||
### Always-On Trigger Mode
|
||||
|
||||
Set a trigger word to enable always-on voice listening. The bot
|
||||
continuously transcribes voice and watches for the trigger word. When
|
||||
detected, the text after the trigger is spoken back via TTS. No
|
||||
`!listen` command needed.
|
||||
|
||||
```toml
|
||||
[voice]
|
||||
trigger = "claude"
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Listener starts automatically on connect (no `!listen on` required)
|
||||
- All speech is transcribed and checked for the trigger word
|
||||
- Trigger match is case-insensitive: "Claude", "CLAUDE", "claude" all work
|
||||
- On match, the trigger word is stripped and the remainder is sent to TTS
|
||||
- Non-triggered speech is silently discarded (unless `!listen` is also on)
|
||||
- When both trigger and `!listen` are active, triggered speech goes to
|
||||
TTS and all other speech is posted as the usual "heard X say: ..."
|
||||
- `!listen` status shows trigger configuration when set
|
||||
|
||||
Configuration (optional):
|
||||
|
||||
```toml
|
||||
[voice]
|
||||
whisper_url = "http://192.168.129.9:8080/inference"
|
||||
piper_url = "http://192.168.129.9:5100/"
|
||||
silence_gap = 1.5
|
||||
trigger = ""
|
||||
```
|
||||
|
||||
Piper TTS accepts POST with JSON body `{"text": "..."}` and returns
|
||||
22050 Hz 16-bit PCM mono WAV. Default voice: `en_US-lessac-medium`.
|
||||
|
||||
Available voices:
|
||||
|
||||
| Region | Voices |
|
||||
|--------|--------|
|
||||
| en_US | lessac-medium/high, amy-medium, ryan-medium/high, joe-medium, john-medium, kristin-medium, danny-low, ljspeech-high |
|
||||
| en_GB | alba-medium, cori-medium/high, jenny_dioco-medium, northern_english_male-medium, southern_english_female-low |
|
||||
| fr_FR | siwis-low/medium, gilles-low, tom-medium, mls-medium, mls_1840-low, upmc-medium |
|
||||
|
||||
To switch the active voice, set `piper_tts_voice` (e.g.
|
||||
`fr_FR-siwis-medium`) and redeploy the TTS service.
|
||||
|
||||
### Mumble Server Admin (admin)
|
||||
|
||||
Manage Mumble users and channels via chat commands. All subcommands
|
||||
require admin tier. Mumble-only (no-op on other adapters).
|
||||
|
||||
```
|
||||
!mu kick <user> [reason] Kick user from server
|
||||
!mu ban <user> [reason] Ban user from server
|
||||
!mu mute <user> Server-mute user
|
||||
!mu unmute <user> Remove server-mute
|
||||
!mu deafen <user> Server-deafen user
|
||||
!mu undeafen <user> Remove server-deafen
|
||||
!mu move <user> <channel> Move user to channel
|
||||
!mu users List connected users
|
||||
!mu channels List server channels
|
||||
!mu mkchan <name> [parent] Create channel (under parent or root)
|
||||
!mu rmchan <name> Remove empty channel
|
||||
!mu rename <old> <new> Rename channel
|
||||
!mu desc <channel> <text> Set channel description
|
||||
```
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
1. pymumble: ssl.wrap_socket was removed in 3.13
|
||||
2. opuslib: ctypes.util.find_library fails on musl-based distros
|
||||
3. pymumble: close stale socket on reconnect
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
@@ -43,3 +44,30 @@ new_opus = "lib_location = find_library('opus') or 'libopus.so.0'"
|
||||
assert old_opus in src, "opuslib find_library patch target not found"
|
||||
p.write_text(src.replace(old_opus, new_opus))
|
||||
print("opuslib musl patch applied")
|
||||
|
||||
# -- pymumble: close old socket before reconnecting --
|
||||
# init_connection() drops control_socket reference without closing it.
|
||||
# The lingering TCP connection causes Murmur to kick the new session
|
||||
# with "You connected to the server from another device".
|
||||
p = pathlib.Path(f"{site}/pymumble_py3/mumble.py")
|
||||
src = p.read_text()
|
||||
|
||||
old_init = """\
|
||||
self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED
|
||||
self.control_socket = None"""
|
||||
|
||||
new_init = """\
|
||||
self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED
|
||||
if getattr(self, 'control_socket', None) is not None:
|
||||
try:
|
||||
self.control_socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.control_socket = None"""
|
||||
|
||||
assert old_init in src, "pymumble init_connection socket patch target not found"
|
||||
src = src.replace(old_init, new_init)
|
||||
print("pymumble reconnect socket patch applied")
|
||||
|
||||
p.write_text(src)
|
||||
|
||||
|
||||
118
plugins/_musicbrainz.py
Normal file
118
plugins/_musicbrainz.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""MusicBrainz API helper for music discovery fallback.
|
||||
|
||||
Private module (underscore prefix) -- plugin loader skips it.
|
||||
All functions are blocking; callers should run them in an executor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from urllib.request import Request
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BASE = "https://musicbrainz.org/ws/2"
|
||||
_UA = "derp-bot/2.0.0 (https://git.mymx.me/username/derp)"
|
||||
|
||||
# Rate limit: MusicBrainz requires max 1 request/second.
|
||||
# We use 1.1s between calls to stay well within limits.
|
||||
_RATE_INTERVAL = 1.1
|
||||
_last_request: float = 0.0
|
||||
|
||||
|
||||
def _mb_request(path: str, params: dict | None = None) -> dict:
|
||||
"""Rate-limited GET to MusicBrainz API. Blocking."""
|
||||
global _last_request
|
||||
from derp.http import urlopen
|
||||
|
||||
elapsed = time.monotonic() - _last_request
|
||||
if elapsed < _RATE_INTERVAL:
|
||||
time.sleep(_RATE_INTERVAL - elapsed)
|
||||
|
||||
qs = "&".join(f"{k}={v}" for k, v in (params or {}).items())
|
||||
url = f"{_BASE}/{path}?fmt=json&{qs}" if qs else f"{_BASE}/{path}?fmt=json"
|
||||
req = Request(url, headers={"User-Agent": _UA})
|
||||
|
||||
try:
|
||||
resp = urlopen(req, timeout=10, proxy=False)
|
||||
_last_request = time.monotonic()
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
_last_request = time.monotonic()
|
||||
log.warning("musicbrainz: request failed: %s", path, exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
def mb_search_artist(name: str) -> str | None:
|
||||
"""Search for an artist by name, return MBID or None."""
|
||||
from urllib.parse import quote
|
||||
|
||||
data = _mb_request("artist", {"query": quote(name), "limit": "1"})
|
||||
artists = data.get("artists", [])
|
||||
if not artists:
|
||||
return None
|
||||
# Require a reasonable score to avoid false matches
|
||||
score = artists[0].get("score", 0)
|
||||
if score < 50:
|
||||
return None
|
||||
return artists[0].get("id")
|
||||
|
||||
|
||||
def mb_artist_tags(mbid: str) -> list[str]:
|
||||
"""Fetch top 5 tags for an artist by MBID."""
|
||||
data = _mb_request(f"artist/{mbid}", {"inc": "tags"})
|
||||
tags = data.get("tags", [])
|
||||
if not tags:
|
||||
return []
|
||||
# Sort by count descending, take top 5
|
||||
sorted_tags = sorted(tags, key=lambda t: t.get("count", 0), reverse=True)
|
||||
return [t["name"] for t in sorted_tags[:5] if t.get("name")]
|
||||
|
||||
|
||||
def mb_find_similar_recordings(artist: str, tags: list[str],
|
||||
limit: int = 10) -> list[dict]:
|
||||
"""Find recordings by other artists sharing top tags.
|
||||
|
||||
Searches MusicBrainz for recordings tagged with the top 2 tags,
|
||||
excluding the original artist. Returns [{"artist": str, "title": str}].
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
if not tags:
|
||||
return []
|
||||
|
||||
# Use top 2 tags for the query
|
||||
tag_query = " AND ".join(f'tag:"{t}"' for t in tags[:2])
|
||||
query = f'({tag_query}) AND NOT artist:"{artist}"'
|
||||
|
||||
data = _mb_request("recording", {
|
||||
"query": quote(query),
|
||||
"limit": str(limit),
|
||||
})
|
||||
recordings = data.get("recordings", [])
|
||||
if not recordings:
|
||||
return []
|
||||
|
||||
seen = set()
|
||||
results = []
|
||||
for rec in recordings:
|
||||
title = rec.get("title", "")
|
||||
credits = rec.get("artist-credit", [])
|
||||
if not credits or not title:
|
||||
continue
|
||||
rec_artist = credits[0].get("name", "") if credits else ""
|
||||
if not rec_artist:
|
||||
continue
|
||||
# Skip the original artist (case-insensitive)
|
||||
if rec_artist.lower() == artist.lower():
|
||||
continue
|
||||
# Deduplicate by artist+title
|
||||
key = f"{rec_artist.lower()}:{title.lower()}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
results.append({"artist": rec_artist, "title": title})
|
||||
|
||||
return results
|
||||
@@ -368,45 +368,56 @@ def _fetch_og_batch(urls: list[str]) -> dict[str, tuple[str, str, str]]:
|
||||
# -- YouTube InnerTube search (blocking) ------------------------------------
|
||||
|
||||
def _extract_videos(obj: object, depth: int = 0) -> list[dict]:
|
||||
"""Recursively walk YouTube JSON to find video results.
|
||||
"""Walk YouTube JSON to find video results (iterative).
|
||||
|
||||
Finds all objects containing both 'videoId' and 'title' keys.
|
||||
Resilient to YouTube rearranging wrapper layers.
|
||||
Uses an explicit stack instead of recursion to avoid 50K+ call
|
||||
overhead on deeply nested InnerTube responses.
|
||||
"""
|
||||
if depth > 20:
|
||||
return []
|
||||
results = []
|
||||
if isinstance(obj, dict):
|
||||
video_id = obj.get("videoId")
|
||||
title_obj = obj.get("title")
|
||||
if isinstance(video_id, str) and video_id and title_obj is not None:
|
||||
if isinstance(title_obj, dict):
|
||||
runs = title_obj.get("runs", [])
|
||||
title = "".join(r.get("text", "") for r in runs if isinstance(r, dict))
|
||||
elif isinstance(title_obj, str):
|
||||
title = title_obj
|
||||
else:
|
||||
title = ""
|
||||
if title:
|
||||
# Extract relative publish time (e.g. "2 days ago")
|
||||
pub_obj = obj.get("publishedTimeText")
|
||||
date = ""
|
||||
if isinstance(pub_obj, dict):
|
||||
date = pub_obj.get("simpleText", "")
|
||||
elif isinstance(pub_obj, str):
|
||||
date = pub_obj
|
||||
results.append({
|
||||
"id": video_id,
|
||||
"title": title,
|
||||
"url": f"https://www.youtube.com/watch?v={video_id}",
|
||||
"date": date,
|
||||
"extra": "",
|
||||
})
|
||||
for val in obj.values():
|
||||
results.extend(_extract_videos(val, depth + 1))
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
results.extend(_extract_videos(item, depth + 1))
|
||||
_MAX_DEPTH = 20
|
||||
results: list[dict] = []
|
||||
# Stack of (node, depth) tuples
|
||||
stack: list[tuple[object, int]] = [(obj, 0)]
|
||||
while stack:
|
||||
node, d = stack.pop()
|
||||
if d > _MAX_DEPTH:
|
||||
continue
|
||||
if isinstance(node, dict):
|
||||
video_id = node.get("videoId")
|
||||
title_obj = node.get("title")
|
||||
if isinstance(video_id, str) and video_id and title_obj is not None:
|
||||
if isinstance(title_obj, dict):
|
||||
runs = title_obj.get("runs", [])
|
||||
title = "".join(
|
||||
r.get("text", "") for r in runs if isinstance(r, dict)
|
||||
)
|
||||
elif isinstance(title_obj, str):
|
||||
title = title_obj
|
||||
else:
|
||||
title = ""
|
||||
if title:
|
||||
pub_obj = node.get("publishedTimeText")
|
||||
date = ""
|
||||
if isinstance(pub_obj, dict):
|
||||
date = pub_obj.get("simpleText", "")
|
||||
elif isinstance(pub_obj, str):
|
||||
date = pub_obj
|
||||
results.append({
|
||||
"id": video_id,
|
||||
"title": title,
|
||||
"url": f"https://www.youtube.com/watch?v={video_id}",
|
||||
"date": date,
|
||||
"extra": "",
|
||||
})
|
||||
# Reverse to preserve original traversal order (stack is LIFO)
|
||||
children = [v for v in node.values() if isinstance(v, (dict, list))]
|
||||
for val in reversed(children):
|
||||
stack.append((val, d + 1))
|
||||
elif isinstance(node, list):
|
||||
for item in reversed(node):
|
||||
if isinstance(item, (dict, list)):
|
||||
stack.append((item, d + 1))
|
||||
return results
|
||||
|
||||
|
||||
@@ -425,7 +436,7 @@ def _search_youtube(keyword: str) -> list[dict]:
|
||||
req = urllib.request.Request(_YT_SEARCH_URL, data=payload, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
resp = urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT)
|
||||
resp = _urlopen(req, timeout=_FETCH_TIMEOUT)
|
||||
raw = resp.read()
|
||||
resp.close()
|
||||
|
||||
@@ -534,7 +545,7 @@ def _search_searx(keyword: str) -> list[dict]:
|
||||
})
|
||||
req = urllib.request.Request(f"{_SEARX_URL}?{params}", method="GET")
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT)
|
||||
resp = _urlopen(req, timeout=_FETCH_TIMEOUT, proxy=False)
|
||||
raw = resp.read()
|
||||
resp.close()
|
||||
except Exception as exc:
|
||||
|
||||
85
plugins/alias.py
Normal file
85
plugins/alias.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Plugin: user-defined command aliases (persistent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from derp.plugin import command
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_NS = "alias"
|
||||
|
||||
|
||||
@command("alias", help="Aliases: !alias add|del|list|clear")
|
||||
async def cmd_alias(bot, message):
|
||||
"""Create short aliases for existing bot commands.
|
||||
|
||||
Usage:
|
||||
!alias add <name> <target> Create alias (e.g. !alias add s skip)
|
||||
!alias del <name> Remove alias
|
||||
!alias list Show all aliases
|
||||
!alias clear Remove all aliases (admin only)
|
||||
"""
|
||||
parts = message.text.split(None, 3)
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !alias <add|del|list|clear> [args]")
|
||||
return
|
||||
|
||||
sub = parts[1].lower()
|
||||
|
||||
if sub == "add":
|
||||
if len(parts) < 4:
|
||||
await bot.reply(message, "Usage: !alias add <name> <target>")
|
||||
return
|
||||
name = parts[2].lower()
|
||||
target = parts[3].lower()
|
||||
|
||||
# Cannot shadow an existing registered command
|
||||
if name in bot.registry.commands:
|
||||
await bot.reply(message, f"'{name}' is already a registered command")
|
||||
return
|
||||
|
||||
# Cannot alias to another alias (single-level only)
|
||||
if bot.state.get(_NS, target) is not None:
|
||||
await bot.reply(message, f"'{target}' is itself an alias; no chaining")
|
||||
return
|
||||
|
||||
# Target must resolve to a real command
|
||||
if target not in bot.registry.commands:
|
||||
await bot.reply(message, f"unknown command: {target}")
|
||||
return
|
||||
|
||||
bot.state.set(_NS, name, target)
|
||||
await bot.reply(message, f"alias: {name} -> {target}")
|
||||
|
||||
elif sub == "del":
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !alias del <name>")
|
||||
return
|
||||
name = parts[2].lower()
|
||||
if bot.state.delete(_NS, name):
|
||||
await bot.reply(message, f"alias removed: {name}")
|
||||
else:
|
||||
await bot.reply(message, f"no alias: {name}")
|
||||
|
||||
elif sub == "list":
|
||||
keys = bot.state.keys(_NS)
|
||||
if not keys:
|
||||
await bot.reply(message, "No aliases defined")
|
||||
return
|
||||
entries = []
|
||||
for key in sorted(keys):
|
||||
target = bot.state.get(_NS, key)
|
||||
entries.append(f"{key} -> {target}")
|
||||
await bot.reply(message, "Aliases: " + ", ".join(entries))
|
||||
|
||||
elif sub == "clear":
|
||||
if not bot._is_admin(message):
|
||||
await bot.reply(message, "Permission denied: clear requires admin")
|
||||
return
|
||||
count = bot.state.clear(_NS)
|
||||
await bot.reply(message, f"Cleared {count} alias(es)")
|
||||
|
||||
else:
|
||||
await bot.reply(message, "Usage: !alias <add|del|list|clear> [args]")
|
||||
123
plugins/core.py
123
plugins/core.py
@@ -1,11 +1,42 @@
|
||||
"""Core plugin: ping, help, version, plugin management."""
|
||||
|
||||
import asyncio
|
||||
import textwrap
|
||||
from collections import Counter
|
||||
|
||||
from derp import __version__
|
||||
from derp.plugin import command
|
||||
|
||||
|
||||
def _build_cmd_detail(handler, prefix: str, indent: int = 0) -> str:
|
||||
"""Format command header + docstring at the given indent level.
|
||||
|
||||
Command name sits at *indent*, docstring body at *indent + 4*.
|
||||
Returns just the header line when no docstring exists.
|
||||
"""
|
||||
pad = " " * indent
|
||||
header = f"{pad}{prefix}{handler.name}"
|
||||
if handler.help:
|
||||
header += f" -- {handler.help}"
|
||||
doc = textwrap.dedent(handler.callback.__doc__ or "").strip()
|
||||
if not doc:
|
||||
return header
|
||||
indented = textwrap.indent(doc, " " * (indent + 4))
|
||||
return f"{header}\n{indented}"
|
||||
|
||||
|
||||
async def _paste(bot, text: str) -> str | None:
|
||||
"""Create a paste via FlaskPaste. Returns URL or None."""
|
||||
fp = bot.registry._modules.get("flaskpaste")
|
||||
if not fp:
|
||||
return None
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
return await loop.run_in_executor(None, fp.create_paste, bot, text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@command("ping", help="Check if the bot is alive")
|
||||
async def cmd_ping(bot, message):
|
||||
"""Respond with pong."""
|
||||
@@ -27,7 +58,13 @@ async def cmd_help(bot, message):
|
||||
handler = bot.registry.commands.get(name)
|
||||
if handler and bot._plugin_allowed(handler.plugin, channel):
|
||||
help_text = handler.help or "No help available."
|
||||
await bot.reply(message, f"{bot.prefix}{name} -- {help_text}")
|
||||
reply = f"{bot.prefix}{name} -- {help_text}"
|
||||
if (handler.callback.__doc__ or "").strip():
|
||||
detail = _build_cmd_detail(handler, bot.prefix)
|
||||
url = await _paste(bot, detail)
|
||||
if url:
|
||||
reply += f" | {url}"
|
||||
await bot.reply(message, reply)
|
||||
return
|
||||
|
||||
# Check plugin
|
||||
@@ -41,7 +78,24 @@ async def cmd_help(bot, message):
|
||||
lines = [f"{name} -- {desc}" if desc else name]
|
||||
if cmds:
|
||||
lines.append(f"Commands: {', '.join(bot.prefix + c for c in cmds)}")
|
||||
await bot.reply(message, " | ".join(lines))
|
||||
reply = " | ".join(lines)
|
||||
# Build detail: plugin header + indented commands
|
||||
section_lines = [f"[{name}]"]
|
||||
if desc:
|
||||
section_lines.append(f" {desc}")
|
||||
section_lines.append("")
|
||||
has_detail = False
|
||||
for cmd_name in cmds:
|
||||
h = bot.registry.commands[cmd_name]
|
||||
section_lines.append(_build_cmd_detail(h, bot.prefix, indent=4))
|
||||
section_lines.append("")
|
||||
if (h.callback.__doc__ or "").strip():
|
||||
has_detail = True
|
||||
if has_detail:
|
||||
url = await _paste(bot, "\n".join(section_lines).rstrip())
|
||||
if url:
|
||||
reply += f" | {url}"
|
||||
await bot.reply(message, reply)
|
||||
return
|
||||
|
||||
await bot.reply(message, f"Unknown command or plugin: {name}")
|
||||
@@ -52,7 +106,31 @@ async def cmd_help(bot, message):
|
||||
k for k, v in bot.registry.commands.items()
|
||||
if bot._plugin_allowed(v.plugin, channel)
|
||||
)
|
||||
await bot.reply(message, ", ".join(names))
|
||||
reply = ", ".join(names)
|
||||
|
||||
# Build full reference grouped by plugin
|
||||
plugins: dict[str, list[str]] = {}
|
||||
for cmd_name in names:
|
||||
h = bot.registry.commands[cmd_name]
|
||||
plugins.setdefault(h.plugin, []).append(cmd_name)
|
||||
sections = []
|
||||
for plugin_name in sorted(plugins):
|
||||
mod = bot.registry._modules.get(plugin_name)
|
||||
desc = (getattr(mod, "__doc__", "") or "").split("\n")[0].strip() if mod else ""
|
||||
section_lines = [f"[{plugin_name}]"]
|
||||
if desc:
|
||||
section_lines.append(f" {desc}")
|
||||
section_lines.append("")
|
||||
for cmd_name in plugins[plugin_name]:
|
||||
h = bot.registry.commands[cmd_name]
|
||||
section_lines.append(_build_cmd_detail(h, bot.prefix, indent=4))
|
||||
section_lines.append("")
|
||||
sections.append("\n".join(section_lines).rstrip())
|
||||
if sections:
|
||||
url = await _paste(bot, "\n\n".join(sections))
|
||||
if url:
|
||||
reply += f" | {url}"
|
||||
await bot.reply(message, reply)
|
||||
|
||||
|
||||
@command("version", help="Show bot version")
|
||||
@@ -145,7 +223,8 @@ async def cmd_whoami(bot, message):
|
||||
prefix = message.prefix or "unknown"
|
||||
tier = bot._get_tier(message)
|
||||
tags = [tier]
|
||||
if message.prefix and message.prefix in bot._opers:
|
||||
opers = getattr(bot, "_opers", set())
|
||||
if message.prefix and message.prefix in opers:
|
||||
tags.append("IRCOP")
|
||||
await bot.reply(message, f"{prefix} [{', '.join(tags)}]")
|
||||
|
||||
@@ -158,17 +237,49 @@ async def cmd_admins(bot, message):
|
||||
parts.append(f"Admin: {', '.join(bot._admins)}")
|
||||
else:
|
||||
parts.append("Admin: (none)")
|
||||
sorcerers = getattr(bot, "_sorcerers", [])
|
||||
if sorcerers:
|
||||
parts.append(f"Sorcerer: {', '.join(sorcerers)}")
|
||||
if bot._operators:
|
||||
parts.append(f"Oper: {', '.join(bot._operators)}")
|
||||
if bot._trusted:
|
||||
parts.append(f"Trusted: {', '.join(bot._trusted)}")
|
||||
if bot._opers:
|
||||
parts.append(f"IRCOPs: {', '.join(sorted(bot._opers))}")
|
||||
opers = getattr(bot, "_opers", set())
|
||||
if opers:
|
||||
parts.append(f"IRCOPs: {', '.join(sorted(opers))}")
|
||||
else:
|
||||
parts.append("IRCOPs: (none)")
|
||||
await bot.reply(message, " | ".join(parts))
|
||||
|
||||
|
||||
@command("deaf", help="Toggle voice listener deaf on Mumble")
|
||||
async def cmd_deaf(bot, message):
|
||||
"""Toggle the voice listener's deaf state on Mumble.
|
||||
|
||||
Targets the bot with ``receive_sound = true`` (merlin) so that
|
||||
deafening stops ducking without affecting the music bot's playback.
|
||||
"""
|
||||
# Find the listener bot (receive_sound=true) among registered peers
|
||||
listener = None
|
||||
bots = getattr(bot.registry, "_bots", {})
|
||||
for peer in bots.values():
|
||||
if getattr(peer, "_receive_sound", False):
|
||||
listener = peer
|
||||
break
|
||||
mumble = getattr(listener or bot, "_mumble", None)
|
||||
if mumble is None:
|
||||
return
|
||||
myself = mumble.users.myself
|
||||
name = getattr(listener, "nick", "bot")
|
||||
if myself.get("self_deaf", False):
|
||||
myself.undeafen()
|
||||
myself.unmute()
|
||||
await bot.reply(message, f"{name}: undeafened")
|
||||
else:
|
||||
myself.deafen()
|
||||
await bot.reply(message, f"{name}: deafened")
|
||||
|
||||
|
||||
@command("state", help="Inspect plugin state: !state <list|get|del|clear> ...", admin=True)
|
||||
async def cmd_state(bot, message):
|
||||
"""Manage the plugin state store.
|
||||
|
||||
@@ -114,7 +114,7 @@ def _create_paste(base_url: str, content: str) -> str:
|
||||
body = json.loads(resp.read())
|
||||
paste_id = body.get("id", "")
|
||||
if paste_id:
|
||||
return f"{base_url}/{paste_id}"
|
||||
return f"{base_url}/{paste_id}/raw"
|
||||
return body.get("url", "")
|
||||
|
||||
|
||||
|
||||
492
plugins/lastfm.py
Normal file
492
plugins/lastfm.py
Normal file
@@ -0,0 +1,492 @@
|
||||
"""Plugin: music discovery via Last.fm API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from derp.plugin import command
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BASE = "https://ws.audioscrobbler.com/2.0/"
|
||||
|
||||
|
||||
# -- Config ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_api_key(bot) -> str:
|
||||
"""Resolve Last.fm API key from env or config."""
|
||||
return (os.environ.get("LASTFM_API_KEY", "")
|
||||
or bot.config.get("lastfm", {}).get("api_key", ""))
|
||||
|
||||
|
||||
# -- API helpers -------------------------------------------------------------
|
||||
|
||||
|
||||
def _api_call(api_key: str, method: str, **params) -> dict:
|
||||
"""Blocking Last.fm API call. Run in executor."""
|
||||
from derp.http import urlopen
|
||||
|
||||
qs = urlencode({
|
||||
"method": method,
|
||||
"api_key": api_key,
|
||||
"format": "json",
|
||||
**params,
|
||||
})
|
||||
url = f"{_BASE}?{qs}"
|
||||
try:
|
||||
resp = urlopen(url, timeout=10)
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
log.exception("lastfm: API call failed: %s", method)
|
||||
return {}
|
||||
|
||||
|
||||
def _get_similar_artists(api_key: str, artist: str,
|
||||
limit: int = 10) -> list[dict]:
|
||||
"""Fetch similar artists for a given artist name."""
|
||||
data = _api_call(api_key, "artist.getSimilar",
|
||||
artist=artist, limit=str(limit))
|
||||
artists = data.get("similarartists", {}).get("artist", [])
|
||||
if isinstance(artists, dict):
|
||||
artists = [artists]
|
||||
return artists
|
||||
|
||||
|
||||
def _get_top_tags(api_key: str, artist: str) -> list[dict]:
|
||||
"""Fetch top tags for an artist."""
|
||||
data = _api_call(api_key, "artist.getTopTags", artist=artist)
|
||||
tags = data.get("toptags", {}).get("tag", [])
|
||||
if isinstance(tags, dict):
|
||||
tags = [tags]
|
||||
return tags
|
||||
|
||||
|
||||
def _get_similar_tracks(api_key: str, artist: str, track: str,
|
||||
limit: int = 10) -> list[dict]:
|
||||
"""Fetch similar tracks for a given artist + track."""
|
||||
data = _api_call(api_key, "track.getSimilar",
|
||||
artist=artist, track=track, limit=str(limit))
|
||||
tracks = data.get("similartracks", {}).get("track", [])
|
||||
if isinstance(tracks, dict):
|
||||
tracks = [tracks]
|
||||
return tracks
|
||||
|
||||
|
||||
def _search_track(api_key: str, query: str,
|
||||
limit: int = 5) -> list[dict]:
|
||||
"""Search Last.fm for tracks matching a query."""
|
||||
data = _api_call(api_key, "track.search",
|
||||
track=query, limit=str(limit))
|
||||
results = data.get("results", {}).get("trackmatches", {}).get("track", [])
|
||||
if isinstance(results, dict):
|
||||
results = [results]
|
||||
return results
|
||||
|
||||
|
||||
# -- Metadata extraction -----------------------------------------------------
|
||||
|
||||
|
||||
def _parse_title(raw_title: str) -> tuple[str, str]:
|
||||
"""Split a raw track title into (artist, title).
|
||||
|
||||
Tries common separators: `` - ``, `` -- ``, `` | ``, `` ~ ``.
|
||||
Returns ``("", raw_title)`` if no separator is found.
|
||||
"""
|
||||
for sep in (" - ", " -- ", " | ", " ~ "):
|
||||
if sep in raw_title:
|
||||
parts = raw_title.split(sep, 1)
|
||||
return (parts[0].strip(), parts[1].strip())
|
||||
return ("", raw_title)
|
||||
|
||||
|
||||
def _music_bot(bot):
|
||||
"""Return the bot instance that owns music playback.
|
||||
|
||||
Checks the calling bot first, then peer bots via the shared registry.
|
||||
Returns the first bot with an active music state, or ``bot`` as fallback.
|
||||
"""
|
||||
candidates = [bot]
|
||||
for peer in getattr(getattr(bot, "registry", None), "_bots", {}).values():
|
||||
if peer is not bot:
|
||||
candidates.append(peer)
|
||||
for b in candidates:
|
||||
music_ps = getattr(b, "_pstate", {}).get("music", {})
|
||||
if music_ps.get("current") is not None or music_ps.get("queue"):
|
||||
return b
|
||||
# No active music state -- prefer a bot that allows the music plugin
|
||||
for b in candidates:
|
||||
only = getattr(b, "_only_plugins", None)
|
||||
if only is not None and "music" in only:
|
||||
return b
|
||||
return bot
|
||||
|
||||
|
||||
def _current_meta(bot) -> tuple[str, str]:
|
||||
"""Extract artist and title from the currently playing track.
|
||||
|
||||
Returns (artist, title). Either or both may be empty.
|
||||
Checks the music bot (via ``_music_bot``) for now-playing metadata.
|
||||
"""
|
||||
mb = _music_bot(bot)
|
||||
music_ps = getattr(mb, "_pstate", {}).get("music", {})
|
||||
current = music_ps.get("current")
|
||||
if current is not None:
|
||||
raw_title = current.title or ""
|
||||
if raw_title:
|
||||
return _parse_title(raw_title)
|
||||
return ("", "")
|
||||
|
||||
|
||||
# -- Discovery orchestrator --------------------------------------------------
|
||||
|
||||
|
||||
async def discover_similar(bot, last_track_title: str) -> tuple[str, str] | None:
|
||||
"""Find a similar track via Last.fm or MusicBrainz fallback.
|
||||
|
||||
Returns ``(artist, title)`` or ``None`` if nothing found.
|
||||
"""
|
||||
artist, title = _parse_title(last_track_title)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# -- Last.fm path --
|
||||
api_key = _get_api_key(bot)
|
||||
if api_key and artist:
|
||||
try:
|
||||
similar = await loop.run_in_executor(
|
||||
None, _get_similar_tracks, api_key, artist, title, 20,
|
||||
)
|
||||
if similar:
|
||||
pick = random.choice(similar)
|
||||
pick_artist = pick.get("artist", {}).get("name", "")
|
||||
pick_title = pick.get("name", "")
|
||||
if pick_artist and pick_title:
|
||||
return (pick_artist, pick_title)
|
||||
except Exception:
|
||||
log.warning("lastfm: discover via Last.fm failed", exc_info=True)
|
||||
|
||||
# -- MusicBrainz fallback --
|
||||
if artist:
|
||||
try:
|
||||
from plugins._musicbrainz import (
|
||||
mb_artist_tags,
|
||||
mb_find_similar_recordings,
|
||||
mb_search_artist,
|
||||
)
|
||||
|
||||
mbid = await loop.run_in_executor(None, mb_search_artist, artist)
|
||||
if mbid:
|
||||
tags = await loop.run_in_executor(None, mb_artist_tags, mbid)
|
||||
if tags:
|
||||
picks = await loop.run_in_executor(
|
||||
None, mb_find_similar_recordings, artist, tags, 20,
|
||||
)
|
||||
if picks:
|
||||
pick = random.choice(picks)
|
||||
return (pick["artist"], pick["title"])
|
||||
except Exception:
|
||||
log.warning("lastfm: discover via MusicBrainz failed",
|
||||
exc_info=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# -- Formatting --------------------------------------------------------------
|
||||
|
||||
|
||||
def _fmt_match(m: float | str) -> str:
|
||||
"""Format a Last.fm match score as a percentage."""
|
||||
try:
|
||||
return f"{float(m) * 100:.0f}%"
|
||||
except (ValueError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
# -- Playlist helpers --------------------------------------------------------
|
||||
|
||||
|
||||
def _search_queries(similar: list[dict], similar_artists: list[dict],
|
||||
mb_results: list[dict], limit: int = 10) -> list[str]:
|
||||
"""Normalize discovery results into YouTube search strings.
|
||||
|
||||
Processes track results (``{artist: {name}, name}``), artist results
|
||||
(``{name}``), and MusicBrainz results (``{artist, title}``) into a
|
||||
flat list of search query strings, up to *limit*.
|
||||
"""
|
||||
queries: list[str] = []
|
||||
for t in similar:
|
||||
a = t.get("artist", {}).get("name", "")
|
||||
n = t.get("name", "")
|
||||
q = f"{a} {n}".strip()
|
||||
if q:
|
||||
queries.append(q)
|
||||
for a in similar_artists:
|
||||
name = a.get("name", "")
|
||||
if name:
|
||||
queries.append(name)
|
||||
for r in mb_results:
|
||||
q = f"{r.get('artist', '')} {r.get('title', '')}".strip()
|
||||
if q:
|
||||
queries.append(q)
|
||||
return queries[:limit]
|
||||
|
||||
|
||||
async def _resolve_playlist(bot, queries: list[str],
|
||||
requester: str) -> list:
|
||||
"""Resolve search queries to Track objects via yt-dlp in parallel.
|
||||
|
||||
Uses the music plugin's ``_resolve_tracks`` and ``_Track`` to build
|
||||
a playlist. Returns a list of ``_Track`` objects (empty on failure).
|
||||
"""
|
||||
music_mod = bot.registry._modules.get("music")
|
||||
if not music_mod:
|
||||
return []
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
resolve = music_mod._resolve_tracks
|
||||
Track = music_mod._Track
|
||||
|
||||
pool = _get_yt_pool()
|
||||
|
||||
async def _resolve_one(query: str):
|
||||
try:
|
||||
pairs = await loop.run_in_executor(
|
||||
pool, resolve, f"ytsearch1:{query}", 1,
|
||||
)
|
||||
if pairs:
|
||||
url, title = pairs[0]
|
||||
return Track(url=url, title=title, requester=requester)
|
||||
except Exception:
|
||||
log.debug("lastfm: resolve failed for %r", query)
|
||||
return None
|
||||
|
||||
tasks = [_resolve_one(q) for q in queries]
|
||||
results = await asyncio.gather(*tasks)
|
||||
return [t for t in results if t is not None]
|
||||
|
||||
|
||||
_yt_pool = None
|
||||
|
||||
|
||||
def _get_yt_pool():
|
||||
"""Lazy-init a shared ThreadPoolExecutor for yt-dlp resolution."""
|
||||
global _yt_pool
|
||||
if _yt_pool is None:
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
_yt_pool = ThreadPoolExecutor(max_workers=4)
|
||||
return _yt_pool
|
||||
|
||||
|
||||
async def _display_results(bot, message, similar: list[dict],
|
||||
similar_artists: list[dict],
|
||||
mb_results: list[dict],
|
||||
artist: str, title: str) -> None:
|
||||
"""Format and display discovery results (list mode)."""
|
||||
if similar:
|
||||
lines = [f"Similar to {artist} - {title}:"]
|
||||
for t in similar[:8]:
|
||||
t_artist = t.get("artist", {}).get("name", "")
|
||||
t_name = t.get("name", "?")
|
||||
match = _fmt_match(t.get("match", ""))
|
||||
suffix = f" ({match})" if match else ""
|
||||
lines.append(f" {t_artist} - {t_name}{suffix}")
|
||||
await bot.long_reply(message, lines, label="similar tracks")
|
||||
return
|
||||
|
||||
search_artist = artist or title
|
||||
if similar_artists:
|
||||
lines = [f"Similar to {search_artist}:"]
|
||||
for a in similar_artists[:8]:
|
||||
name = a.get("name", "?")
|
||||
match = _fmt_match(a.get("match", ""))
|
||||
suffix = f" ({match})" if match else ""
|
||||
lines.append(f" {name}{suffix}")
|
||||
await bot.long_reply(message, lines, label="similar artists")
|
||||
return
|
||||
|
||||
if mb_results:
|
||||
lines = [f"Similar to {search_artist}:"]
|
||||
for r in mb_results[:8]:
|
||||
lines.append(f" {r['artist']} - {r['title']}")
|
||||
await bot.long_reply(message, lines, label="similar tracks")
|
||||
return
|
||||
|
||||
await bot.reply(message, f"No similar artists found for '{search_artist}'")
|
||||
|
||||
|
||||
# -- Commands ----------------------------------------------------------------
|
||||
|
||||
|
||||
@command("similar", help="Music: !similar [list] [artist] -- discover & play similar music")
|
||||
async def cmd_similar(bot, message):
|
||||
"""Discover and play similar music.
|
||||
|
||||
Usage:
|
||||
!similar Discover + play similar to current track
|
||||
!similar <artist> Discover + play similar to named artist
|
||||
!similar list Show similar (display only)
|
||||
!similar list <artist> Show similar for named artist
|
||||
"""
|
||||
api_key = _get_api_key(bot)
|
||||
|
||||
parts = message.text.split(None, 2)
|
||||
# !similar list [artist]
|
||||
list_mode = len(parts) >= 2 and parts[1].lower() == "list"
|
||||
if list_mode:
|
||||
query = parts[2].strip() if len(parts) > 2 else ""
|
||||
else:
|
||||
query = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Resolve artist from query or current track
|
||||
if query:
|
||||
artist = query
|
||||
title = ""
|
||||
else:
|
||||
artist, title = _current_meta(bot)
|
||||
if not artist and not title:
|
||||
await bot.reply(message, "Nothing playing and no artist given")
|
||||
return
|
||||
|
||||
# -- Last.fm path --
|
||||
similar: list[dict] = []
|
||||
similar_artists: list[dict] = []
|
||||
if api_key:
|
||||
# Try track-level similarity first if we have both artist + title
|
||||
if artist and title:
|
||||
similar = await loop.run_in_executor(
|
||||
None, _get_similar_tracks, api_key, artist, title,
|
||||
)
|
||||
# Fall back to artist-level similarity
|
||||
if not similar:
|
||||
search_artist = artist or title
|
||||
similar_artists = await loop.run_in_executor(
|
||||
None, _get_similar_artists, api_key, search_artist,
|
||||
)
|
||||
|
||||
# -- MusicBrainz fallback --
|
||||
mb_results: list[dict] = []
|
||||
if not similar and not similar_artists:
|
||||
search_artist = artist or title
|
||||
try:
|
||||
from plugins._musicbrainz import (
|
||||
mb_artist_tags,
|
||||
mb_find_similar_recordings,
|
||||
mb_search_artist,
|
||||
)
|
||||
mbid = await loop.run_in_executor(
|
||||
None, mb_search_artist, search_artist,
|
||||
)
|
||||
if mbid:
|
||||
tags = await loop.run_in_executor(None, mb_artist_tags, mbid)
|
||||
if tags:
|
||||
mb_results = await loop.run_in_executor(
|
||||
None, mb_find_similar_recordings, search_artist,
|
||||
tags, 20,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("lastfm: MusicBrainz fallback failed", exc_info=True)
|
||||
|
||||
# Nothing found at all
|
||||
if not similar and not similar_artists and not mb_results:
|
||||
search_artist = artist or title
|
||||
await bot.reply(message, f"No similar artists found for '{search_artist}'")
|
||||
return
|
||||
|
||||
# -- List mode (display only) --
|
||||
if list_mode:
|
||||
await _display_results(bot, message, similar, similar_artists,
|
||||
mb_results, artist, title)
|
||||
return
|
||||
|
||||
# -- Play mode (default): build playlist and transition --
|
||||
search_artist = artist or title
|
||||
queries = _search_queries(similar, similar_artists, mb_results, limit=10)
|
||||
if not queries:
|
||||
await bot.reply(message, f"No similar artists found for '{search_artist}'")
|
||||
return
|
||||
|
||||
music_mod = bot.registry._modules.get("music")
|
||||
if not music_mod:
|
||||
# No music plugin -- fall back to display
|
||||
await _display_results(bot, message, similar, similar_artists,
|
||||
mb_results, artist, title)
|
||||
return
|
||||
|
||||
await bot.reply(message, f"Discovering similar to {search_artist}...")
|
||||
tracks = await _resolve_playlist(bot, queries, message.nick)
|
||||
if not tracks:
|
||||
await bot.reply(message, "No playable tracks resolved")
|
||||
return
|
||||
|
||||
# Transition on the music bot (derp), not the calling bot (may be merlin)
|
||||
dj = _music_bot(bot)
|
||||
ps = music_mod._ps(dj)
|
||||
await music_mod._fade_and_cancel(dj, duration=3.0)
|
||||
ps["queue"].clear()
|
||||
ps["current"] = None
|
||||
ps["queue"] = list(tracks)
|
||||
music_mod._ensure_loop(dj, fade_in=True)
|
||||
await bot.reply(message, f"Playing {len(tracks)} similar tracks for {search_artist}")
|
||||
|
||||
|
||||
@command("tags", help="Music: !tags [artist] -- show genre tags")
|
||||
async def cmd_tags(bot, message):
|
||||
"""Show genre/style tags for an artist.
|
||||
|
||||
Usage:
|
||||
!tags Tags for currently playing artist
|
||||
!tags <artist> Tags for named artist
|
||||
"""
|
||||
api_key = _get_api_key(bot)
|
||||
|
||||
parts = message.text.split(None, 1)
|
||||
query = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
if query:
|
||||
artist = query
|
||||
else:
|
||||
artist, title = _current_meta(bot)
|
||||
artist = artist or title
|
||||
if not artist:
|
||||
await bot.reply(message, "Nothing playing and no artist given")
|
||||
return
|
||||
|
||||
# -- Last.fm path --
|
||||
tags = []
|
||||
if api_key:
|
||||
tags = await loop.run_in_executor(
|
||||
None, _get_top_tags, api_key, artist,
|
||||
)
|
||||
|
||||
# -- MusicBrainz fallback --
|
||||
if not tags:
|
||||
try:
|
||||
from plugins._musicbrainz import mb_artist_tags, mb_search_artist
|
||||
mbid = await loop.run_in_executor(None, mb_search_artist, artist)
|
||||
if mbid:
|
||||
mb_tags = await loop.run_in_executor(
|
||||
None, mb_artist_tags, mbid,
|
||||
)
|
||||
if mb_tags:
|
||||
await bot.reply(message, f"{artist}: {', '.join(mb_tags)}")
|
||||
return
|
||||
except Exception:
|
||||
log.warning("lastfm: MusicBrainz tag fallback failed",
|
||||
exc_info=True)
|
||||
|
||||
if not tags:
|
||||
await bot.reply(message, f"No tags found for '{artist}'")
|
||||
return
|
||||
|
||||
# Show top tags with counts
|
||||
tag_names = [t.get("name", "?") for t in tags[:10] if t.get("name")]
|
||||
await bot.reply(message, f"{artist}: {', '.join(tag_names)}")
|
||||
298
plugins/llm.py
Normal file
298
plugins/llm.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""Plugin: LLM chat via OpenRouter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from derp.http import urlopen as _urlopen
|
||||
from derp.plugin import command
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# -- Constants ---------------------------------------------------------------
|
||||
|
||||
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
_DEFAULT_MODEL = "openrouter/auto"
|
||||
_TIMEOUT = 30
|
||||
_MAX_HISTORY = 20
|
||||
_MAX_REPLY_LEN = 400
|
||||
_COOLDOWN = 5
|
||||
|
||||
_DEFAULT_SYSTEM = (
|
||||
"You are a helpful IRC bot assistant. Keep responses concise and under 200 words."
|
||||
)
|
||||
|
||||
|
||||
# -- Per-bot runtime state ---------------------------------------------------
|
||||
|
||||
def _ps(bot):
|
||||
"""Per-bot plugin runtime state."""
|
||||
return bot._pstate.setdefault("llm", {
|
||||
"histories": {}, # {nick: [{"role": ..., "content": ...}, ...]}
|
||||
"cooldowns": {}, # {nick: monotonic_ts}
|
||||
"model": "", # override per-bot; empty = use default
|
||||
})
|
||||
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
def _get_api_key(bot) -> str:
|
||||
"""Resolve API key from env or config."""
|
||||
return (
|
||||
os.environ.get("OPENROUTER_API_KEY", "")
|
||||
or bot.config.get("openrouter", {}).get("api_key", "")
|
||||
)
|
||||
|
||||
|
||||
def _get_model(bot) -> str:
|
||||
"""Resolve current model."""
|
||||
ps = _ps(bot)
|
||||
return (
|
||||
ps["model"]
|
||||
or bot.config.get("openrouter", {}).get("model", "")
|
||||
or _DEFAULT_MODEL
|
||||
)
|
||||
|
||||
|
||||
def _get_system_prompt(bot) -> str:
|
||||
"""Resolve system prompt from config or default."""
|
||||
return bot.config.get("openrouter", {}).get("system_prompt", _DEFAULT_SYSTEM)
|
||||
|
||||
|
||||
def _truncate(text: str, max_len: int = _MAX_REPLY_LEN) -> str:
|
||||
"""Truncate text with ellipsis if needed."""
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 3].rstrip() + "..."
|
||||
|
||||
|
||||
def _check_cooldown(bot, nick: str) -> bool:
|
||||
"""Return True if the user is within cooldown period."""
|
||||
ps = _ps(bot)
|
||||
last = ps["cooldowns"].get(nick, 0)
|
||||
return (time.monotonic() - last) < _COOLDOWN
|
||||
|
||||
|
||||
def _set_cooldown(bot, nick: str) -> None:
|
||||
"""Record a cooldown timestamp for a user."""
|
||||
_ps(bot)["cooldowns"][nick] = time.monotonic()
|
||||
|
||||
|
||||
# -- Blocking HTTP call ------------------------------------------------------
|
||||
|
||||
def _chat_request(api_key: str, model: str, messages: list[dict]) -> dict:
|
||||
"""Blocking OpenRouter chat completion. Run via executor.
|
||||
|
||||
Returns the parsed JSON response dict.
|
||||
Raises on HTTP or connection errors.
|
||||
"""
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(_API_URL, data=payload, method="POST")
|
||||
req.add_header("Authorization", f"Bearer {api_key}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
resp = _urlopen(req, timeout=_TIMEOUT)
|
||||
raw = resp.read()
|
||||
resp.close()
|
||||
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _extract_reply(data: dict) -> str:
|
||||
"""Extract reply text from OpenRouter response.
|
||||
|
||||
Handles reasoning models that return content="" with a reasoning field.
|
||||
"""
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
return ""
|
||||
|
||||
msg = choices[0].get("message", {})
|
||||
content = (msg.get("content") or "").strip()
|
||||
if content:
|
||||
return content
|
||||
|
||||
# Fallback for reasoning models
|
||||
reasoning = (msg.get("reasoning") or "").strip()
|
||||
return reasoning
|
||||
|
||||
|
||||
# -- Command handlers --------------------------------------------------------
|
||||
|
||||
@command("ask", help="Ask: !ask <question>")
|
||||
async def cmd_ask(bot, message):
|
||||
"""Single-shot LLM question (no history).
|
||||
|
||||
Usage: !ask <question>
|
||||
"""
|
||||
parts = message.text.split(None, 1)
|
||||
if len(parts) < 2 or not parts[1].strip():
|
||||
await bot.reply(message, "Usage: !ask <question>")
|
||||
return
|
||||
|
||||
api_key = _get_api_key(bot)
|
||||
if not api_key:
|
||||
await bot.reply(message, "OpenRouter API key not configured")
|
||||
return
|
||||
|
||||
nick = message.nick
|
||||
if _check_cooldown(bot, nick):
|
||||
await bot.reply(message, "Cooldown -- wait a few seconds")
|
||||
return
|
||||
|
||||
prompt = parts[1].strip()
|
||||
model = _get_model(bot)
|
||||
system = _get_system_prompt(bot)
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
_set_cooldown(bot, nick)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
data = await loop.run_in_executor(
|
||||
None, _chat_request, api_key, model, messages,
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 429:
|
||||
await bot.reply(message, "Rate limited by OpenRouter -- try again later")
|
||||
else:
|
||||
await bot.reply(message, f"API error: HTTP {exc.code}")
|
||||
return
|
||||
except Exception as exc:
|
||||
_log.warning("LLM request failed: %s", exc)
|
||||
await bot.reply(message, f"Request failed: {exc}")
|
||||
return
|
||||
|
||||
reply = _extract_reply(data)
|
||||
if not reply:
|
||||
await bot.reply(message, "No response from model")
|
||||
return
|
||||
|
||||
lines = _truncate(reply).split("\n")
|
||||
await bot.long_reply(message, lines, label="llm")
|
||||
|
||||
|
||||
@command("chat", help="Chat: !chat <msg> | clear | model [name] | models")
|
||||
async def cmd_chat(bot, message):
|
||||
"""Conversational LLM chat with per-user history.
|
||||
|
||||
Usage:
|
||||
!chat <message> Send a message (maintains history)
|
||||
!chat clear Clear your conversation history
|
||||
!chat model Show current model
|
||||
!chat model <name> Switch model
|
||||
!chat models List popular free models
|
||||
"""
|
||||
parts = message.text.split(None, 2)
|
||||
if len(parts) < 2 or not parts[1].strip():
|
||||
await bot.reply(message, "Usage: !chat <message> | clear | model [name] | models")
|
||||
return
|
||||
|
||||
sub = parts[1].strip().lower()
|
||||
|
||||
# -- Subcommands ---------------------------------------------------------
|
||||
|
||||
if sub == "clear":
|
||||
ps = _ps(bot)
|
||||
nick = message.nick
|
||||
if nick in ps["histories"]:
|
||||
del ps["histories"][nick]
|
||||
await bot.reply(message, "Conversation cleared")
|
||||
return
|
||||
|
||||
if sub == "model":
|
||||
if len(parts) > 2 and parts[2].strip():
|
||||
new_model = parts[2].strip()
|
||||
_ps(bot)["model"] = new_model
|
||||
await bot.reply(message, f"Model set to: {new_model}")
|
||||
else:
|
||||
await bot.reply(message, f"Current model: {_get_model(bot)}")
|
||||
return
|
||||
|
||||
if sub == "models":
|
||||
models = [
|
||||
"openrouter/auto -- auto-route to best available",
|
||||
"google/gemma-3-27b-it:free",
|
||||
"meta-llama/llama-3.3-70b-instruct:free",
|
||||
"deepseek/deepseek-r1:free",
|
||||
"qwen/qwen3-235b-a22b:free",
|
||||
"mistralai/mistral-small-3.1-24b-instruct:free",
|
||||
]
|
||||
await bot.long_reply(message, models, label="models")
|
||||
return
|
||||
|
||||
# -- Chat path -----------------------------------------------------------
|
||||
|
||||
api_key = _get_api_key(bot)
|
||||
if not api_key:
|
||||
await bot.reply(message, "OpenRouter API key not configured")
|
||||
return
|
||||
|
||||
nick = message.nick
|
||||
if _check_cooldown(bot, nick):
|
||||
await bot.reply(message, "Cooldown -- wait a few seconds")
|
||||
return
|
||||
|
||||
# Reconstruct full user text (sub might be part of the message)
|
||||
user_text = message.text.split(None, 1)[1].strip()
|
||||
|
||||
ps = _ps(bot)
|
||||
history = ps["histories"].setdefault(nick, [])
|
||||
|
||||
# Build messages
|
||||
system = _get_system_prompt(bot)
|
||||
history.append({"role": "user", "content": user_text})
|
||||
|
||||
# Cap history
|
||||
if len(history) > _MAX_HISTORY:
|
||||
history[:] = history[-_MAX_HISTORY:]
|
||||
|
||||
messages = [{"role": "system", "content": system}] + history
|
||||
|
||||
model = _get_model(bot)
|
||||
_set_cooldown(bot, nick)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
data = await loop.run_in_executor(
|
||||
None, _chat_request, api_key, model, messages,
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
# Remove the failed user message from history
|
||||
history.pop()
|
||||
if exc.code == 429:
|
||||
await bot.reply(message, "Rate limited by OpenRouter -- try again later")
|
||||
else:
|
||||
await bot.reply(message, f"API error: HTTP {exc.code}")
|
||||
return
|
||||
except Exception as exc:
|
||||
history.pop()
|
||||
_log.warning("LLM request failed: %s", exc)
|
||||
await bot.reply(message, f"Request failed: {exc}")
|
||||
return
|
||||
|
||||
reply = _extract_reply(data)
|
||||
if not reply:
|
||||
history.pop()
|
||||
await bot.reply(message, "No response from model")
|
||||
return
|
||||
|
||||
# Store assistant reply in history
|
||||
history.append({"role": "assistant", "content": reply})
|
||||
if len(history) > _MAX_HISTORY:
|
||||
history[:] = history[-_MAX_HISTORY:]
|
||||
|
||||
lines = _truncate(reply).split("\n")
|
||||
await bot.long_reply(message, lines, label="llm")
|
||||
276
plugins/mumble_admin.py
Normal file
276
plugins/mumble_admin.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Plugin: Mumble server administration via chat commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from derp.plugin import command
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_user(bot, name: str):
|
||||
"""Case-insensitive user lookup by name. Returns pymumble User or None."""
|
||||
mumble = getattr(bot, "_mumble", None)
|
||||
if mumble is None:
|
||||
return None
|
||||
lower = name.lower()
|
||||
for sid in list(mumble.users):
|
||||
user = mumble.users[sid]
|
||||
if user["name"].lower() == lower:
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
def _find_channel(bot, name: str):
|
||||
"""Case-insensitive channel lookup by name. Returns pymumble Channel or None."""
|
||||
mumble = getattr(bot, "_mumble", None)
|
||||
if mumble is None:
|
||||
return None
|
||||
lower = name.lower()
|
||||
for cid in list(mumble.channels):
|
||||
chan = mumble.channels[cid]
|
||||
if chan["name"].lower() == lower:
|
||||
return chan
|
||||
return None
|
||||
|
||||
|
||||
def _channel_name(bot, channel_id: int) -> str:
|
||||
"""Resolve a channel ID to its name, or return the ID as string."""
|
||||
mumble = getattr(bot, "_mumble", None)
|
||||
if mumble is None:
|
||||
return str(channel_id)
|
||||
chan = mumble.channels.get(channel_id)
|
||||
if chan is None:
|
||||
return str(channel_id)
|
||||
return chan["name"]
|
||||
|
||||
|
||||
# -- Sub-handlers ------------------------------------------------------------
|
||||
|
||||
|
||||
async def _sub_kick(bot, message, args: list[str]) -> None:
|
||||
if not args:
|
||||
await bot.reply(message, "Usage: !mu kick <user> [reason]")
|
||||
return
|
||||
user = _find_user(bot, args[0])
|
||||
if user is None:
|
||||
await bot.reply(message, f"User not found: {args[0]}")
|
||||
return
|
||||
reason = " ".join(args[1:]) if len(args) > 1 else ""
|
||||
user.kick(reason)
|
||||
await bot.reply(message, f"Kicked {user['name']}")
|
||||
|
||||
|
||||
async def _sub_ban(bot, message, args: list[str]) -> None:
|
||||
if not args:
|
||||
await bot.reply(message, "Usage: !mu ban <user> [reason]")
|
||||
return
|
||||
user = _find_user(bot, args[0])
|
||||
if user is None:
|
||||
await bot.reply(message, f"User not found: {args[0]}")
|
||||
return
|
||||
reason = " ".join(args[1:]) if len(args) > 1 else ""
|
||||
user.ban(reason)
|
||||
await bot.reply(message, f"Banned {user['name']}")
|
||||
|
||||
|
||||
async def _sub_mute(bot, message, args: list[str]) -> None:
|
||||
if not args:
|
||||
await bot.reply(message, "Usage: !mu mute <user>")
|
||||
return
|
||||
user = _find_user(bot, args[0])
|
||||
if user is None:
|
||||
await bot.reply(message, f"User not found: {args[0]}")
|
||||
return
|
||||
user.mute()
|
||||
await bot.reply(message, f"Muted {user['name']}")
|
||||
|
||||
|
||||
async def _sub_unmute(bot, message, args: list[str]) -> None:
|
||||
if not args:
|
||||
await bot.reply(message, "Usage: !mu unmute <user>")
|
||||
return
|
||||
user = _find_user(bot, args[0])
|
||||
if user is None:
|
||||
await bot.reply(message, f"User not found: {args[0]}")
|
||||
return
|
||||
user.unmute()
|
||||
await bot.reply(message, f"Unmuted {user['name']}")
|
||||
|
||||
|
||||
async def _sub_deafen(bot, message, args: list[str]) -> None:
|
||||
if not args:
|
||||
await bot.reply(message, "Usage: !mu deafen <user>")
|
||||
return
|
||||
user = _find_user(bot, args[0])
|
||||
if user is None:
|
||||
await bot.reply(message, f"User not found: {args[0]}")
|
||||
return
|
||||
user.deafen()
|
||||
await bot.reply(message, f"Deafened {user['name']}")
|
||||
|
||||
|
||||
async def _sub_undeafen(bot, message, args: list[str]) -> None:
|
||||
if not args:
|
||||
await bot.reply(message, "Usage: !mu undeafen <user>")
|
||||
return
|
||||
user = _find_user(bot, args[0])
|
||||
if user is None:
|
||||
await bot.reply(message, f"User not found: {args[0]}")
|
||||
return
|
||||
user.undeafen()
|
||||
await bot.reply(message, f"Undeafened {user['name']}")
|
||||
|
||||
|
||||
async def _sub_move(bot, message, args: list[str]) -> None:
|
||||
if len(args) < 2:
|
||||
await bot.reply(message, "Usage: !mu move <user> <channel>")
|
||||
return
|
||||
user = _find_user(bot, args[0])
|
||||
if user is None:
|
||||
await bot.reply(message, f"User not found: {args[0]}")
|
||||
return
|
||||
chan = _find_channel(bot, " ".join(args[1:]))
|
||||
if chan is None:
|
||||
await bot.reply(message, f"Channel not found: {' '.join(args[1:])}")
|
||||
return
|
||||
user.move_in(chan["channel_id"])
|
||||
await bot.reply(message, f"Moved {user['name']} to {chan['name']}")
|
||||
|
||||
|
||||
async def _sub_users(bot, message, args: list[str]) -> None:
|
||||
mumble = getattr(bot, "_mumble", None)
|
||||
if mumble is None:
|
||||
return
|
||||
bots = getattr(bot.registry, "_bots", {})
|
||||
lines: list[str] = []
|
||||
for sid in sorted(mumble.users):
|
||||
user = mumble.users[sid]
|
||||
name = user["name"]
|
||||
flags: list[str] = []
|
||||
if name in bots:
|
||||
flags.append("bot")
|
||||
if user.get("mute") or user.get("self_mute"):
|
||||
flags.append("muted")
|
||||
if user.get("deaf") or user.get("self_deaf"):
|
||||
flags.append("deaf")
|
||||
chan = _channel_name(bot, user.get("channel_id", 0))
|
||||
tag = f" [{', '.join(flags)}]" if flags else ""
|
||||
lines.append(f" {name} in {chan}{tag}")
|
||||
header = f"Online: {len(lines)} user(s)"
|
||||
await bot.reply(message, header + "\n" + "\n".join(lines))
|
||||
|
||||
|
||||
async def _sub_channels(bot, message, args: list[str]) -> None:
|
||||
mumble = getattr(bot, "_mumble", None)
|
||||
if mumble is None:
|
||||
return
|
||||
lines: list[str] = []
|
||||
for cid in sorted(mumble.channels):
|
||||
chan = mumble.channels[cid]
|
||||
name = chan["name"]
|
||||
# Count users in this channel
|
||||
count = sum(
|
||||
1 for sid in mumble.users
|
||||
if mumble.users[sid].get("channel_id") == cid
|
||||
)
|
||||
lines.append(f" {name} ({count})")
|
||||
await bot.reply(message, "Channels:\n" + "\n".join(lines))
|
||||
|
||||
|
||||
async def _sub_mkchan(bot, message, args: list[str]) -> None:
|
||||
if not args:
|
||||
await bot.reply(message, "Usage: !mu mkchan <name> [temp]")
|
||||
return
|
||||
mumble = getattr(bot, "_mumble", None)
|
||||
if mumble is None:
|
||||
return
|
||||
name = args[0]
|
||||
temp = len(args) > 1 and args[1].lower() in ("temp", "temporary", "true")
|
||||
mumble.channels.new_channel(0, name, temporary=temp)
|
||||
label = " (temporary)" if temp else ""
|
||||
await bot.reply(message, f"Created channel: {name}{label}")
|
||||
|
||||
|
||||
async def _sub_rmchan(bot, message, args: list[str]) -> None:
|
||||
if not args:
|
||||
await bot.reply(message, "Usage: !mu rmchan <channel>")
|
||||
return
|
||||
chan = _find_channel(bot, " ".join(args))
|
||||
if chan is None:
|
||||
await bot.reply(message, f"Channel not found: {' '.join(args)}")
|
||||
return
|
||||
name = chan["name"]
|
||||
chan.remove()
|
||||
await bot.reply(message, f"Removed channel: {name}")
|
||||
|
||||
|
||||
async def _sub_rename(bot, message, args: list[str]) -> None:
|
||||
if len(args) < 2:
|
||||
await bot.reply(message, "Usage: !mu rename <channel> <new-name>")
|
||||
return
|
||||
chan = _find_channel(bot, args[0])
|
||||
if chan is None:
|
||||
await bot.reply(message, f"Channel not found: {args[0]}")
|
||||
return
|
||||
old = chan["name"]
|
||||
chan.rename_channel(args[1])
|
||||
await bot.reply(message, f"Renamed {old} to {args[1]}")
|
||||
|
||||
|
||||
async def _sub_desc(bot, message, args: list[str]) -> None:
|
||||
if len(args) < 2:
|
||||
await bot.reply(message, "Usage: !mu desc <channel> <text>")
|
||||
return
|
||||
chan = _find_channel(bot, args[0])
|
||||
if chan is None:
|
||||
await bot.reply(message, f"Channel not found: {args[0]}")
|
||||
return
|
||||
text = " ".join(args[1:])
|
||||
chan.set_channel_description(text)
|
||||
await bot.reply(message, f"Set description for {chan['name']}")
|
||||
|
||||
|
||||
# -- Dispatch table ----------------------------------------------------------
|
||||
|
||||
|
||||
_SUBS: dict[str, object] = {
|
||||
"kick": _sub_kick,
|
||||
"ban": _sub_ban,
|
||||
"mute": _sub_mute,
|
||||
"unmute": _sub_unmute,
|
||||
"deafen": _sub_deafen,
|
||||
"undeafen": _sub_undeafen,
|
||||
"move": _sub_move,
|
||||
"users": _sub_users,
|
||||
"channels": _sub_channels,
|
||||
"mkchan": _sub_mkchan,
|
||||
"rmchan": _sub_rmchan,
|
||||
"rename": _sub_rename,
|
||||
"desc": _sub_desc,
|
||||
}
|
||||
|
||||
_USAGE = (
|
||||
"Usage: !mu <action> [args]\n"
|
||||
"Actions: kick, ban, mute, unmute, deafen, undeafen, move, "
|
||||
"users, channels, mkchan, rmchan, rename, desc"
|
||||
)
|
||||
|
||||
|
||||
@command("mu", help="Mumble admin: !mu <action> [args]", tier="admin")
|
||||
async def cmd_mu(bot, message):
|
||||
"""Mumble server administration commands."""
|
||||
parts = message.text.split()
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, _USAGE)
|
||||
return
|
||||
sub = parts[1].lower()
|
||||
handler = _SUBS.get(sub)
|
||||
if handler is None:
|
||||
await bot.reply(message, _USAGE)
|
||||
return
|
||||
await handler(bot, message, parts[2:])
|
||||
1688
plugins/music.py
1688
plugins/music.py
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from derp.http import urlopen as _urlopen
|
||||
from derp.plugin import command
|
||||
|
||||
# -- Constants ---------------------------------------------------------------
|
||||
@@ -38,7 +39,7 @@ def _search(query: str) -> list[dict]:
|
||||
url = f"{_SEARX_URL}?{params}"
|
||||
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
resp = urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT)
|
||||
resp = _urlopen(req, timeout=_FETCH_TIMEOUT, proxy=False)
|
||||
raw = resp.read()
|
||||
resp.close()
|
||||
|
||||
|
||||
617
plugins/voice.py
Normal file
617
plugins/voice.py
Normal file
@@ -0,0 +1,617 @@
|
||||
"""Plugin: voice STT/TTS for Mumble channels.
|
||||
|
||||
Listens for voice audio via pymumble's sound callback, buffers PCM per
|
||||
user, transcribes via Whisper STT on silence, and provides TTS playback
|
||||
via Piper. Commands: !listen, !say.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import wave
|
||||
|
||||
from derp.http import urlopen as _urlopen
|
||||
from derp.plugin import command
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# -- Constants ---------------------------------------------------------------
|
||||
|
||||
_SAMPLE_RATE = 48000
|
||||
_CHANNELS = 1
|
||||
_SAMPLE_WIDTH = 2 # s16le = 2 bytes per sample
|
||||
|
||||
_SILENCE_GAP = 1.5 # seconds of silence before flushing
|
||||
_MIN_DURATION = 0.5 # discard utterances shorter than this
|
||||
_MAX_DURATION = 30.0 # cap buffer at this many seconds
|
||||
_MIN_BYTES = int(_MIN_DURATION * _SAMPLE_RATE * _SAMPLE_WIDTH)
|
||||
_MAX_BYTES = int(_MAX_DURATION * _SAMPLE_RATE * _SAMPLE_WIDTH)
|
||||
_FLUSH_INTERVAL = 0.5 # flush monitor poll interval
|
||||
_MAX_SAY_LEN = 500 # max characters for !say
|
||||
|
||||
_WHISPER_URL = "http://192.168.129.9:8080/inference"
|
||||
_PIPER_URL = "http://192.168.129.9:5100/"
|
||||
|
||||
|
||||
# -- Per-bot state -----------------------------------------------------------
|
||||
|
||||
|
||||
def _ps(bot):
|
||||
"""Per-bot plugin runtime state."""
|
||||
cfg = getattr(bot, "config", {}).get("voice", {})
|
||||
trigger = cfg.get("trigger", "")
|
||||
# Bias Whisper toward the trigger word unless explicitly configured
|
||||
default_prompt = f"{trigger.capitalize()}, " if trigger else ""
|
||||
return bot._pstate.setdefault("voice", {
|
||||
"listen": False,
|
||||
"trigger": trigger,
|
||||
"buffers": {}, # {username: bytearray}
|
||||
"last_ts": {}, # {username: float monotonic}
|
||||
"flush_task": None,
|
||||
"lock": threading.Lock(),
|
||||
"silence_gap": cfg.get("silence_gap", _SILENCE_GAP),
|
||||
"whisper_url": cfg.get("whisper_url", _WHISPER_URL),
|
||||
"piper_url": cfg.get("piper_url", _PIPER_URL),
|
||||
"voice": cfg.get("voice", ""),
|
||||
"length_scale": cfg.get("length_scale", 1.0),
|
||||
"noise_scale": cfg.get("noise_scale", 0.667),
|
||||
"noise_w": cfg.get("noise_w", 0.8),
|
||||
"fx": cfg.get("fx", ""),
|
||||
"initial_prompt": cfg.get("initial_prompt", default_prompt),
|
||||
"_listener_registered": False,
|
||||
})
|
||||
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_mumble(bot) -> bool:
|
||||
"""Check if bot supports voice streaming."""
|
||||
return hasattr(bot, "stream_audio")
|
||||
|
||||
|
||||
def _pcm_to_wav(pcm: bytes) -> bytes:
|
||||
"""Wrap raw s16le 48kHz mono PCM in a WAV container."""
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
wf.setnchannels(_CHANNELS)
|
||||
wf.setsampwidth(_SAMPLE_WIDTH)
|
||||
wf.setframerate(_SAMPLE_RATE)
|
||||
wf.writeframes(pcm)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# -- Acknowledge tone --------------------------------------------------------
|
||||
|
||||
_ACK_FREQ = (880, 1320) # A5 -> E6 ascending
|
||||
_ACK_NOTE_DUR = 0.15 # seconds per note
|
||||
_ACK_AMP = 12000 # gentle amplitude
|
||||
_ACK_FRAME = 960 # 20ms at 48kHz, matches Mumble native
|
||||
|
||||
|
||||
async def _ack_tone(bot) -> None:
|
||||
"""Play a short two-tone ascending chime via pymumble sound_output."""
|
||||
mu = getattr(bot, "_mumble", None)
|
||||
if mu is None:
|
||||
return
|
||||
so = mu.sound_output
|
||||
if so is None:
|
||||
return
|
||||
|
||||
# Unmute if self-muted (stream_audio handles re-mute later)
|
||||
if getattr(bot, "_self_mute", False):
|
||||
if bot._mute_task and not bot._mute_task.done():
|
||||
bot._mute_task.cancel()
|
||||
bot._mute_task = None
|
||||
try:
|
||||
mu.users.myself.unmute()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
frames_per_note = int(_ACK_NOTE_DUR / 0.02) # 0.02s per frame
|
||||
for freq in _ACK_FREQ:
|
||||
for i in range(frames_per_note):
|
||||
samples = []
|
||||
for j in range(_ACK_FRAME):
|
||||
t = (i * _ACK_FRAME + j) / _SAMPLE_RATE
|
||||
samples.append(int(_ACK_AMP * math.sin(2 * math.pi * freq * t)))
|
||||
pcm = struct.pack(f"<{_ACK_FRAME}h", *samples)
|
||||
so.add_sound(pcm)
|
||||
while so.get_buffer_size() > 0.5:
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
# Wait for tone to finish
|
||||
while so.get_buffer_size() > 0:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
# -- STT: Sound listener (pymumble thread) ----------------------------------
|
||||
|
||||
|
||||
def _on_voice(bot, user, sound_chunk):
|
||||
"""Buffer incoming voice PCM per user. Runs on pymumble thread."""
|
||||
ps = _ps(bot)
|
||||
if not ps["listen"] and not ps["trigger"]:
|
||||
return
|
||||
try:
|
||||
name = user["name"]
|
||||
except (KeyError, TypeError):
|
||||
name = None
|
||||
if not name or name == bot.nick:
|
||||
return
|
||||
pcm = sound_chunk.pcm
|
||||
if not pcm:
|
||||
return
|
||||
with ps["lock"]:
|
||||
if name not in ps["buffers"]:
|
||||
ps["buffers"][name] = bytearray()
|
||||
buf = ps["buffers"][name]
|
||||
buf.extend(pcm)
|
||||
if len(buf) > _MAX_BYTES:
|
||||
ps["buffers"][name] = bytearray(buf[-_MAX_BYTES:])
|
||||
ps["last_ts"][name] = time.monotonic()
|
||||
|
||||
|
||||
# -- STT: Whisper transcription ---------------------------------------------
|
||||
|
||||
|
||||
def _transcribe(ps, pcm: bytes) -> str:
|
||||
"""POST PCM (as WAV) to Whisper and return transcribed text. Blocking."""
|
||||
wav_data = _pcm_to_wav(pcm)
|
||||
boundary = "----derp_voice_boundary"
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="file"; filename="audio.wav"\r\n'
|
||||
f"Content-Type: audio/wav\r\n\r\n"
|
||||
).encode() + wav_data + (
|
||||
f"\r\n--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="response_format"\r\n\r\n'
|
||||
f"json"
|
||||
).encode()
|
||||
# Bias Whisper toward the trigger word when configured
|
||||
prompt = ps.get("initial_prompt", "")
|
||||
if prompt:
|
||||
body += (
|
||||
f"\r\n--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="initial_prompt"\r\n\r\n'
|
||||
f"{prompt}"
|
||||
).encode()
|
||||
body += f"\r\n--{boundary}--\r\n".encode()
|
||||
req = urllib.request.Request(ps["whisper_url"], data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
resp = _urlopen(req, timeout=30, proxy=False)
|
||||
data = json.loads(resp.read())
|
||||
resp.close()
|
||||
return data.get("text", "").strip()
|
||||
|
||||
|
||||
# -- STT: Flush monitor (asyncio background task) ---------------------------
|
||||
|
||||
|
||||
async def _flush_monitor(bot):
|
||||
"""Poll for silence gaps and transcribe completed utterances."""
|
||||
ps = _ps(bot)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
while ps["listen"] or ps["trigger"]:
|
||||
await asyncio.sleep(_FLUSH_INTERVAL)
|
||||
now = time.monotonic()
|
||||
to_flush: list[tuple[str, bytes]] = []
|
||||
|
||||
with ps["lock"]:
|
||||
for name in list(ps["last_ts"]):
|
||||
elapsed = now - ps["last_ts"][name]
|
||||
if elapsed >= ps["silence_gap"] and name in ps["buffers"]:
|
||||
pcm = bytes(ps["buffers"].pop(name))
|
||||
del ps["last_ts"][name]
|
||||
to_flush.append((name, pcm))
|
||||
|
||||
for name, pcm in to_flush:
|
||||
if len(pcm) < _MIN_BYTES:
|
||||
continue
|
||||
try:
|
||||
text = await loop.run_in_executor(
|
||||
None, _transcribe, ps, pcm,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("voice: transcription failed for %s", name)
|
||||
continue
|
||||
if not text or text.strip("., ") == "":
|
||||
continue
|
||||
|
||||
trigger = ps["trigger"]
|
||||
if trigger and text.lower().startswith(trigger.lower()):
|
||||
remainder = text[len(trigger):].strip().lstrip(",.;:!?")
|
||||
if remainder:
|
||||
log.info("voice: trigger from %s: %s", name, remainder)
|
||||
bot._spawn(
|
||||
_tts_play(bot, remainder), name="voice-tts",
|
||||
)
|
||||
continue
|
||||
|
||||
if ps["listen"]:
|
||||
log.info("voice: %s said: %s", name, text)
|
||||
await bot.action("0", f"heard {name} say: {text}")
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
log.exception("voice: flush monitor error")
|
||||
|
||||
|
||||
# -- TTS: Piper fetch + playback --------------------------------------------
|
||||
|
||||
|
||||
def _fetch_tts(piper_url: str, text: str) -> str | None:
|
||||
"""POST text to Piper TTS and save the WAV response. Blocking."""
|
||||
import tempfile
|
||||
try:
|
||||
payload = json.dumps({"text": text}).encode()
|
||||
req = urllib.request.Request(
|
||||
piper_url, data=payload, method="POST",
|
||||
)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
resp = _urlopen(req, timeout=30, proxy=False)
|
||||
data = resp.read()
|
||||
resp.close()
|
||||
if not data:
|
||||
return None
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
suffix=".wav", prefix="derp_tts_", delete=False,
|
||||
)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
return tmp.name
|
||||
except Exception:
|
||||
log.exception("voice: TTS fetch failed")
|
||||
return None
|
||||
|
||||
|
||||
async def _tts_play(bot, text: str):
|
||||
"""Fetch TTS audio and play it via stream_audio.
|
||||
|
||||
Uses the configured voice profile (voice, fx, piper params) when set,
|
||||
otherwise falls back to Piper's default voice.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ps = _ps(bot)
|
||||
loop = asyncio.get_running_loop()
|
||||
if ps["voice"] or ps["fx"]:
|
||||
wav_path = await loop.run_in_executor(
|
||||
None, lambda: _fetch_tts_voice(
|
||||
ps["piper_url"], text,
|
||||
voice=ps["voice"],
|
||||
length_scale=ps["length_scale"],
|
||||
noise_scale=ps["noise_scale"],
|
||||
noise_w=ps["noise_w"],
|
||||
fx=ps["fx"],
|
||||
),
|
||||
)
|
||||
else:
|
||||
wav_path = await loop.run_in_executor(
|
||||
None, _fetch_tts, ps["piper_url"], text,
|
||||
)
|
||||
if wav_path is None:
|
||||
return
|
||||
try:
|
||||
# Signal music plugin to duck, wait for it to take effect
|
||||
bot.registry._tts_active = True
|
||||
await asyncio.sleep(1.5)
|
||||
await _ack_tone(bot)
|
||||
done = asyncio.Event()
|
||||
await bot.stream_audio(str(wav_path), volume=1.0, on_done=done)
|
||||
await done.wait()
|
||||
finally:
|
||||
bot.registry._tts_active = False
|
||||
Path(wav_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# -- Listener lifecycle -----------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_listener(bot):
|
||||
"""Register the sound listener callback (idempotent)."""
|
||||
ps = _ps(bot)
|
||||
if ps["_listener_registered"]:
|
||||
return
|
||||
if not hasattr(bot, "_sound_listeners"):
|
||||
return
|
||||
bot._sound_listeners.append(lambda user, chunk: _on_voice(bot, user, chunk))
|
||||
ps["_listener_registered"] = True
|
||||
log.info("voice: registered sound listener")
|
||||
|
||||
|
||||
def _ensure_flush_task(bot):
|
||||
"""Start the flush monitor if not running."""
|
||||
ps = _ps(bot)
|
||||
task = ps.get("flush_task")
|
||||
if task and not task.done():
|
||||
return
|
||||
ps["flush_task"] = bot._spawn(
|
||||
_flush_monitor(bot), name="voice-flush-monitor",
|
||||
)
|
||||
|
||||
|
||||
def _stop_flush_task(bot):
|
||||
"""Cancel the flush monitor."""
|
||||
ps = _ps(bot)
|
||||
task = ps.get("flush_task")
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
ps["flush_task"] = None
|
||||
|
||||
|
||||
# -- Commands ----------------------------------------------------------------
|
||||
|
||||
|
||||
@command("listen", help="Voice: !listen [on|off] -- toggle STT", tier="admin")
|
||||
async def cmd_listen(bot, message):
|
||||
"""Toggle voice-to-text transcription."""
|
||||
if not _is_mumble(bot):
|
||||
await bot.reply(message, "Voice is Mumble-only")
|
||||
return
|
||||
|
||||
ps = _ps(bot)
|
||||
parts = message.text.split()
|
||||
if len(parts) < 2:
|
||||
state = "on" if ps["listen"] else "off"
|
||||
trigger = ps["trigger"]
|
||||
info = f"Listen: {state}"
|
||||
if trigger:
|
||||
info += f" | Trigger: {trigger}"
|
||||
await bot.reply(message, info)
|
||||
return
|
||||
|
||||
sub = parts[1].lower()
|
||||
if sub == "on":
|
||||
ps["listen"] = True
|
||||
_ensure_listener(bot)
|
||||
_ensure_flush_task(bot)
|
||||
await bot.reply(message, "Listening for voice")
|
||||
elif sub == "off":
|
||||
ps["listen"] = False
|
||||
if not ps["trigger"]:
|
||||
with ps["lock"]:
|
||||
ps["buffers"].clear()
|
||||
ps["last_ts"].clear()
|
||||
_stop_flush_task(bot)
|
||||
await bot.reply(message, "Stopped listening")
|
||||
else:
|
||||
await bot.reply(message, "Usage: !listen [on|off]")
|
||||
|
||||
|
||||
@command("say", help="Voice: !say <text> -- text-to-speech")
|
||||
async def cmd_say(bot, message):
|
||||
"""Speak text aloud via Piper TTS."""
|
||||
if not _is_mumble(bot):
|
||||
await bot.reply(message, "Voice is Mumble-only")
|
||||
return
|
||||
|
||||
parts = message.text.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !say <text>")
|
||||
return
|
||||
|
||||
text = parts[1].strip()
|
||||
if len(text) > _MAX_SAY_LEN:
|
||||
await bot.reply(message, f"Text too long (max {_MAX_SAY_LEN} chars)")
|
||||
return
|
||||
|
||||
bot._spawn(_tts_play(bot, text), name="voice-tts")
|
||||
|
||||
|
||||
def _split_fx(fx: str) -> tuple[list[str], str]:
|
||||
"""Split FX chain into rubberband CLI args and ffmpeg filter string.
|
||||
|
||||
Alpine's ffmpeg lacks librubberband, so pitch shifting is handled by
|
||||
the ``rubberband`` CLI tool and remaining filters by ffmpeg.
|
||||
"""
|
||||
import math
|
||||
parts = fx.split(",")
|
||||
rb_args: list[str] = []
|
||||
ff_parts: list[str] = []
|
||||
for part in parts:
|
||||
if part.startswith("rubberband="):
|
||||
opts: dict[str, str] = {}
|
||||
for kv in part[len("rubberband="):].split(":"):
|
||||
k, _, v = kv.partition("=")
|
||||
opts[k] = v
|
||||
if "pitch" in opts:
|
||||
semitones = 12 * math.log2(float(opts["pitch"]))
|
||||
rb_args += ["--pitch", f"{semitones:.2f}"]
|
||||
if opts.get("formant") == "1":
|
||||
rb_args.append("--formant")
|
||||
else:
|
||||
ff_parts.append(part)
|
||||
return rb_args, ",".join(ff_parts)
|
||||
|
||||
|
||||
def _fetch_tts_voice(piper_url: str, text: str, *, voice: str = "",
|
||||
speaker_id: int = 0, length_scale: float = 1.0,
|
||||
noise_scale: float = 0.667, noise_w: float = 0.8,
|
||||
fx: str = "") -> str | None:
|
||||
"""Fetch TTS with explicit voice params and optional FX. Blocking.
|
||||
|
||||
Pitch shifting uses the ``rubberband`` CLI (Alpine ffmpeg has no
|
||||
librubberband); remaining audio filters go through ffmpeg.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
payload = {"text": text}
|
||||
if voice:
|
||||
payload["voice"] = voice
|
||||
if speaker_id:
|
||||
payload["speaker_id"] = speaker_id
|
||||
payload["length_scale"] = length_scale
|
||||
payload["noise_scale"] = noise_scale
|
||||
payload["noise_w"] = noise_w
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(piper_url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
resp = _urlopen(req, timeout=30, proxy=False)
|
||||
wav_data = resp.read()
|
||||
resp.close()
|
||||
if not wav_data:
|
||||
return None
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".wav", prefix="derp_aud_", delete=False)
|
||||
tmp.write(wav_data)
|
||||
tmp.close()
|
||||
if not fx:
|
||||
return tmp.name
|
||||
|
||||
rb_args, ff_filters = _split_fx(fx)
|
||||
current = tmp.name
|
||||
|
||||
# Pitch shift via rubberband CLI
|
||||
if rb_args:
|
||||
rb_out = tempfile.NamedTemporaryFile(
|
||||
suffix=".wav", prefix="derp_aud_", delete=False,
|
||||
)
|
||||
rb_out.close()
|
||||
r = subprocess.run(
|
||||
["rubberband"] + rb_args + [current, rb_out.name],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
os.unlink(current)
|
||||
if r.returncode != 0:
|
||||
log.warning("voice: rubberband failed: %s", r.stderr[:200])
|
||||
os.unlink(rb_out.name)
|
||||
return None
|
||||
current = rb_out.name
|
||||
|
||||
# Remaining filters via ffmpeg
|
||||
if ff_filters:
|
||||
ff_out = tempfile.NamedTemporaryFile(
|
||||
suffix=".wav", prefix="derp_aud_", delete=False,
|
||||
)
|
||||
ff_out.close()
|
||||
r = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", current, "-af", ff_filters, ff_out.name],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
os.unlink(current)
|
||||
if r.returncode != 0:
|
||||
log.warning("voice: ffmpeg failed: %s", r.stderr[:200])
|
||||
os.unlink(ff_out.name)
|
||||
return None
|
||||
current = ff_out.name
|
||||
|
||||
return current
|
||||
|
||||
|
||||
@command("audition", help="Voice: !audition -- play voice samples", tier="admin")
|
||||
async def cmd_audition(bot, message):
|
||||
"""Play voice samples through Mumble for comparison."""
|
||||
if not _is_mumble(bot):
|
||||
return
|
||||
|
||||
ps = _ps(bot)
|
||||
piper_url = ps["piper_url"]
|
||||
phrase = "The sorcerer has arrived. I have seen things beyond your understanding."
|
||||
|
||||
# FX building blocks
|
||||
_deep = "rubberband=pitch=0.87:formant=1"
|
||||
_bass = "bass=g=6:f=110:w=0.6"
|
||||
_bass_heavy = "equalizer=f=80:t=h:w=150:g=8"
|
||||
_echo_subtle = "aecho=0.8:0.6:25|40:0.25|0.15"
|
||||
_echo_chamber = "aecho=0.8:0.88:60:0.35"
|
||||
_echo_cave = "aecho=0.8:0.7:40|70|100:0.3|0.2|0.1"
|
||||
|
||||
samples = [
|
||||
# -- Base voices (no FX) for reference
|
||||
("ryan-high raw", "en_US-ryan-high", 0, ""),
|
||||
("lessac-high raw", "en_US-lessac-high", 0, ""),
|
||||
# -- Deep pitch only
|
||||
("ryan deep", "en_US-ryan-high", 0,
|
||||
_deep),
|
||||
("lessac deep", "en_US-lessac-high", 0,
|
||||
_deep),
|
||||
# -- Deep + bass boost
|
||||
("ryan deep+bass", "en_US-ryan-high", 0,
|
||||
f"{_deep},{_bass}"),
|
||||
("lessac deep+bass", "en_US-lessac-high", 0,
|
||||
f"{_deep},{_bass}"),
|
||||
# -- Deep + heavy bass
|
||||
("ryan deep+heavy bass", "en_US-ryan-high", 0,
|
||||
f"{_deep},{_bass_heavy}"),
|
||||
# -- Deep + bass + subtle echo
|
||||
("ryan deep+bass+echo", "en_US-ryan-high", 0,
|
||||
f"{_deep},{_bass},{_echo_subtle}"),
|
||||
("lessac deep+bass+echo", "en_US-lessac-high", 0,
|
||||
f"{_deep},{_bass},{_echo_subtle}"),
|
||||
# -- Deep + bass + chamber reverb
|
||||
("ryan deep+bass+chamber", "en_US-ryan-high", 0,
|
||||
f"{_deep},{_bass},{_echo_chamber}"),
|
||||
("lessac deep+bass+chamber", "en_US-lessac-high", 0,
|
||||
f"{_deep},{_bass},{_echo_chamber}"),
|
||||
# -- Deep + heavy bass + cave reverb
|
||||
("ryan deep+heavybass+cave", "en_US-ryan-high", 0,
|
||||
f"{_deep},{_bass_heavy},{_echo_cave}"),
|
||||
# -- Libritts best candidates with full sorcerer chain
|
||||
("libritts #20 deep+bass+echo", "en_US-libritts_r-medium", 20,
|
||||
f"{_deep},{_bass},{_echo_subtle}"),
|
||||
("libritts #22 deep+bass+echo", "en_US-libritts_r-medium", 22,
|
||||
f"{_deep},{_bass},{_echo_subtle}"),
|
||||
("libritts #79 deep+bass+chamber", "en_US-libritts_r-medium", 79,
|
||||
f"{_deep},{_bass},{_echo_chamber}"),
|
||||
]
|
||||
|
||||
await bot.reply(message, f"Auditioning {len(samples)} voice samples...")
|
||||
loop = asyncio.get_running_loop()
|
||||
from pathlib import Path
|
||||
|
||||
for i, (label, voice, sid, fx) in enumerate(samples, 1):
|
||||
await bot.send("0", f"[{i}/{len(samples)}] {label}")
|
||||
await asyncio.sleep(1)
|
||||
sample_wav = await loop.run_in_executor(
|
||||
None, lambda v=voice, s=sid, f=fx: _fetch_tts_voice(
|
||||
piper_url, phrase, voice=v, speaker_id=s,
|
||||
length_scale=1.15, noise_scale=0.4, noise_w=0.5, fx=f,
|
||||
),
|
||||
)
|
||||
if sample_wav is None:
|
||||
await bot.send("0", " (failed)")
|
||||
continue
|
||||
try:
|
||||
done = asyncio.Event()
|
||||
await bot.stream_audio(sample_wav, volume=1.0, on_done=done)
|
||||
await done.wait()
|
||||
finally:
|
||||
Path(sample_wav).unlink(missing_ok=True)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await bot.send("0", "Audition complete.")
|
||||
|
||||
|
||||
# -- Plugin lifecycle --------------------------------------------------------
|
||||
|
||||
|
||||
async def on_connected(bot) -> None:
|
||||
"""Re-register listener after reconnect; play TTS greeting on first connect."""
|
||||
if not _is_mumble(bot):
|
||||
return
|
||||
ps = _ps(bot)
|
||||
if ps["listen"] or ps["trigger"]:
|
||||
_ensure_listener(bot)
|
||||
_ensure_flush_task(bot)
|
||||
|
||||
# Greet via TTS on first connection only
|
||||
greet = getattr(bot, "config", {}).get("mumble", {}).get("greet")
|
||||
if greet and not ps.get("_greeted"):
|
||||
ps["_greeted"] = True
|
||||
ready = getattr(bot, "_is_audio_ready", None)
|
||||
if ready:
|
||||
for _ in range(20):
|
||||
if ready():
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
bot._spawn(_tts_play(bot, greet), name="voice-greet")
|
||||
@@ -22,6 +22,7 @@ where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 99
|
||||
|
||||
4
requirements-dev.txt
Normal file
4
requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
-e .
|
||||
pymumble>=1.6
|
||||
pytest>=7.0
|
||||
ruff>=0.4
|
||||
@@ -96,6 +96,7 @@ class Bot:
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
self._reconnect_delay: float = 5.0
|
||||
self._admins: list[str] = config.get("bot", {}).get("admins", [])
|
||||
self._sorcerers: list[str] = config.get("bot", {}).get("sorcerers", [])
|
||||
self._operators: list[str] = config.get("bot", {}).get("operators", [])
|
||||
self._trusted: list[str] = config.get("bot", {}).get("trusted", [])
|
||||
self._opers: set[str] = set() # hostmasks of known IRC operators
|
||||
@@ -252,7 +253,10 @@ class Bot:
|
||||
async def _loop(self) -> None:
|
||||
"""Read and dispatch messages until disconnect."""
|
||||
while self._running:
|
||||
line = await self.conn.readline()
|
||||
try:
|
||||
line = await asyncio.wait_for(self.conn.readline(), timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
if line is None:
|
||||
log.warning("server closed connection")
|
||||
return
|
||||
@@ -377,6 +381,9 @@ class Bot:
|
||||
for pattern in self._admins:
|
||||
if fnmatch.fnmatch(msg.prefix, pattern):
|
||||
return "admin"
|
||||
for pattern in self._sorcerers:
|
||||
if fnmatch.fnmatch(msg.prefix, pattern):
|
||||
return "sorcerer"
|
||||
for pattern in self._operators:
|
||||
if fnmatch.fnmatch(msg.prefix, pattern):
|
||||
return "oper"
|
||||
@@ -401,6 +408,12 @@ class Bot:
|
||||
parts = text[len(self.prefix):].split(None, 1)
|
||||
cmd_name = parts[0].lower() if parts else ""
|
||||
handler = self._resolve_command(cmd_name)
|
||||
if handler is None:
|
||||
# Check user-defined aliases
|
||||
target = self.state.get("alias", cmd_name) if hasattr(self, "state") else None
|
||||
if target:
|
||||
cmd_name = target
|
||||
handler = self._resolve_command(cmd_name)
|
||||
if handler is None:
|
||||
return
|
||||
if handler is _AMBIGUOUS:
|
||||
|
||||
@@ -10,6 +10,7 @@ import sys
|
||||
from derp import __version__
|
||||
from derp.bot import Bot
|
||||
from derp.config import build_server_configs, resolve_config
|
||||
from derp.irc import format_msg
|
||||
from derp.log import JsonFormatter
|
||||
from derp.plugin import PluginRegistry
|
||||
|
||||
@@ -37,8 +38,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--cprofile",
|
||||
metavar="PATH",
|
||||
nargs="?",
|
||||
const="derp.prof",
|
||||
help="enable cProfile; dump stats to PATH [derp.prof]",
|
||||
const="data/derp.prof",
|
||||
help="enable cProfile; dump stats to PATH [data/derp.prof]",
|
||||
)
|
||||
p.add_argument(
|
||||
"--tracemalloc",
|
||||
@@ -72,12 +73,24 @@ def _run(bots: list) -> None:
|
||||
|
||||
|
||||
def _shutdown(bots: list) -> None:
|
||||
"""Signal handler: stop all bot loops so cProfile can flush."""
|
||||
"""Signal handler: stop all bot loops and tear down connections."""
|
||||
logging.getLogger("derp").info("SIGTERM received, shutting down")
|
||||
loop = asyncio.get_running_loop()
|
||||
for bot in bots:
|
||||
bot._running = False
|
||||
if hasattr(bot, "conn"):
|
||||
asyncio.get_running_loop().create_task(bot.conn.close())
|
||||
if hasattr(bot, "conn") and bot.conn.connected:
|
||||
loop.create_task(_quit_and_close(bot))
|
||||
elif hasattr(bot, "_mumble") and bot._mumble:
|
||||
bot._mumble.stop()
|
||||
|
||||
|
||||
async def _quit_and_close(bot) -> None:
|
||||
"""Send IRC QUIT and close the connection."""
|
||||
try:
|
||||
await bot.conn.send(format_msg("QUIT", "shutting down"))
|
||||
except Exception:
|
||||
pass
|
||||
await bot.conn.close()
|
||||
|
||||
|
||||
def _dump_tracemalloc(log: logging.Logger, path: str, limit: int = 25) -> None:
|
||||
@@ -155,6 +168,33 @@ def main(argv: list[str] | None = None) -> int:
|
||||
mumble_bot = MumbleBot("mumble", config, registry)
|
||||
bots.append(mumble_bot)
|
||||
|
||||
# Additional Mumble bots (e.g. merlin)
|
||||
for extra in config.get("mumble", {}).get("extra", []):
|
||||
extra_cfg = dict(config)
|
||||
merged_mu = dict(config["mumble"])
|
||||
merged_mu.update(extra)
|
||||
merged_mu.pop("extra", None)
|
||||
# Plugin filters are exclusive; don't inherit the parent's
|
||||
if "only_plugins" in extra:
|
||||
merged_mu.pop("except_plugins", None)
|
||||
elif "except_plugins" in extra:
|
||||
merged_mu.pop("only_plugins", None)
|
||||
extra_cfg["mumble"] = merged_mu
|
||||
username = extra.get("username", f"mumble-{len(bots)}")
|
||||
# Voice config: per-bot [<username>.voice] overrides global [voice]
|
||||
per_bot_voice = config.get(username, {}).get("voice")
|
||||
if per_bot_voice:
|
||||
voice_cfg = dict(config.get("voice", {}))
|
||||
voice_cfg.update(per_bot_voice)
|
||||
extra_cfg["voice"] = voice_cfg
|
||||
elif "voice" not in extra:
|
||||
extra_cfg["voice"] = {
|
||||
k: v for k, v in config.get("voice", {}).items()
|
||||
if k != "trigger"
|
||||
}
|
||||
bot = MumbleBot(username, extra_cfg, registry)
|
||||
bots.append(bot)
|
||||
|
||||
names = ", ".join(b.name for b in bots)
|
||||
log.info("servers: %s", names)
|
||||
|
||||
@@ -166,10 +206,32 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
if args.cprofile:
|
||||
import cProfile
|
||||
import threading
|
||||
|
||||
log.info("cProfile enabled, output: %s", args.cprofile)
|
||||
cProfile.runctx("_run(bots)", globals(), {"bots": bots, "_run": _run}, args.cprofile)
|
||||
log.info("profile saved to %s", args.cprofile)
|
||||
prof = cProfile.Profile()
|
||||
prof_path = args.cprofile
|
||||
prof_interval = 10 # dump every 10 seconds
|
||||
prof_stop = threading.Event()
|
||||
|
||||
def _periodic_dump():
|
||||
while not prof_stop.wait(prof_interval):
|
||||
try:
|
||||
prof.dump_stats(prof_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dumper = threading.Thread(target=_periodic_dump, daemon=True)
|
||||
dumper.start()
|
||||
log.info("cProfile enabled, output: %s (saves every %ds)",
|
||||
prof_path, prof_interval)
|
||||
prof.enable()
|
||||
try:
|
||||
_run(bots)
|
||||
finally:
|
||||
prof.disable()
|
||||
prof_stop.set()
|
||||
prof.dump_stats(prof_path)
|
||||
log.info("profile saved to %s", prof_path)
|
||||
else:
|
||||
_run(bots)
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ def _get_pool() -> SOCKSProxyManager:
|
||||
if _pool is None:
|
||||
_pool = SOCKSProxyManager(
|
||||
f"socks5h://{_PROXY_ADDR}:{_PROXY_PORT}/",
|
||||
num_pools=20,
|
||||
maxsize=4,
|
||||
num_pools=30,
|
||||
maxsize=8,
|
||||
retries=_POOL_RETRIES,
|
||||
)
|
||||
return _pool
|
||||
@@ -85,10 +85,46 @@ class _ProxyHandler(SocksiPyHandler, urllib.request.HTTPSHandler):
|
||||
|
||||
# -- Public HTTP interface ---------------------------------------------------
|
||||
|
||||
|
||||
class _PooledResponse:
|
||||
"""Thin wrapper around a preloaded urllib3 response.
|
||||
|
||||
Provides a ``read()`` that behaves like stdlib (returns full data
|
||||
on first call, empty bytes on subsequent calls), plus ``close()``
|
||||
as a no-op. Preloading ensures the underlying connection returns
|
||||
to the pool immediately.
|
||||
"""
|
||||
|
||||
__slots__ = ("status", "headers", "reason", "_data", "_pos")
|
||||
|
||||
def __init__(self, resp):
|
||||
self.status = resp.status
|
||||
self.headers = resp.headers
|
||||
self.reason = resp.reason
|
||||
self._data = resp.data # already fully read (preloaded)
|
||||
self._pos = 0
|
||||
|
||||
def read(self, amt=None):
|
||||
if self._pos >= len(self._data):
|
||||
return b""
|
||||
if amt is None:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
else:
|
||||
chunk = self._data[self._pos:self._pos + amt]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def urlopen(req, *, timeout=None, context=None, retries=None, proxy=True):
|
||||
"""HTTP urlopen with optional SOCKS5 proxy.
|
||||
|
||||
Uses connection pooling via urllib3 for proxied requests.
|
||||
Uses connection pooling via urllib3 for proxied requests. Responses
|
||||
are preloaded so the SOCKS connection returns to the pool immediately
|
||||
(avoids opening 500+ fresh connections per session).
|
||||
Falls back to legacy opener for custom SSL context.
|
||||
When ``proxy=False``, uses stdlib ``urllib.request.urlopen`` directly.
|
||||
Retries on transient SSL/connection errors with exponential backoff.
|
||||
@@ -123,17 +159,14 @@ def urlopen(req, *, timeout=None, context=None, retries=None, proxy=True):
|
||||
headers=headers,
|
||||
body=body,
|
||||
timeout=to,
|
||||
preload_content=False,
|
||||
preload_content=True,
|
||||
)
|
||||
if resp.status >= 400:
|
||||
# Drain body so connection returns to pool, then raise
|
||||
# urllib.error.HTTPError for backward compatibility.
|
||||
resp.read()
|
||||
raise urllib.error.HTTPError(
|
||||
url, resp.status, resp.reason or "",
|
||||
resp.headers, None,
|
||||
)
|
||||
return resp
|
||||
return _PooledResponse(resp)
|
||||
except urllib.error.HTTPError:
|
||||
raise
|
||||
except _RETRY_ERRORS as exc:
|
||||
|
||||
@@ -6,8 +6,10 @@ import array
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -16,6 +18,7 @@ import pymumble_py3 as pymumble
|
||||
from pymumble_py3.constants import (
|
||||
PYMUMBLE_CLBK_CONNECTED,
|
||||
PYMUMBLE_CLBK_DISCONNECTED,
|
||||
PYMUMBLE_CLBK_SOUNDRECEIVED,
|
||||
PYMUMBLE_CLBK_TEXTMESSAGERECEIVED,
|
||||
)
|
||||
|
||||
@@ -42,9 +45,13 @@ def _strip_html(text: str) -> str:
|
||||
return html.unescape(_TAG_RE.sub("", text))
|
||||
|
||||
|
||||
_URL_RE = re.compile(r'(https?://[^\s<>&]+)')
|
||||
|
||||
|
||||
def _escape_html(text: str) -> str:
|
||||
"""Escape text for Mumble HTML messages."""
|
||||
return html.escape(text, quote=False)
|
||||
"""Escape text for Mumble HTML messages, auto-linking URLs."""
|
||||
escaped = html.escape(text, quote=False)
|
||||
return _URL_RE.sub(r'<a href="\1">\1</a>', escaped)
|
||||
|
||||
|
||||
def _shell_quote(s: str) -> str:
|
||||
@@ -135,6 +142,8 @@ class MumbleBot:
|
||||
self._port: int = mu_cfg.get("port", 64738)
|
||||
self._username: str = mu_cfg.get("username", "derp")
|
||||
self._password: str = mu_cfg.get("password", "")
|
||||
self._certfile: str | None = mu_cfg.get("certfile")
|
||||
self._keyfile: str | None = mu_cfg.get("keyfile")
|
||||
self.nick: str = self._username
|
||||
self.prefix: str = (
|
||||
mu_cfg.get("prefix")
|
||||
@@ -144,6 +153,7 @@ class MumbleBot:
|
||||
self._started: float = time.monotonic()
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
self._admins: list[str] = [str(x) for x in mu_cfg.get("admins", [])]
|
||||
self._sorcerers: list[str] = [str(x) for x in mu_cfg.get("sorcerers", [])]
|
||||
self._operators: list[str] = [str(x) for x in mu_cfg.get("operators", [])]
|
||||
self._trusted: list[str] = [str(x) for x in mu_cfg.get("trusted", [])]
|
||||
self.state = StateStore(f"data/state-{name}.db")
|
||||
@@ -151,6 +161,24 @@ class MumbleBot:
|
||||
# pymumble state
|
||||
self._mumble: pymumble.Mumble | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._last_voice_ts: float = 0.0
|
||||
self._connect_count: int = 0
|
||||
self._sound_listeners: list = []
|
||||
self._receive_sound: bool = mu_cfg.get("receive_sound", True)
|
||||
self._self_mute: bool = mu_cfg.get("self_mute", False)
|
||||
self._self_deaf: bool = mu_cfg.get("self_deaf", False)
|
||||
self._mute_task: asyncio.Task | None = None
|
||||
self._only_plugins: set[str] | None = (
|
||||
set(mu_cfg["only_plugins"]) if "only_plugins" in mu_cfg else None
|
||||
)
|
||||
self._except_plugins: set[str] | None = (
|
||||
set(mu_cfg["except_plugins"]) if "except_plugins" in mu_cfg else None
|
||||
)
|
||||
|
||||
# Register in shared bot index so plugins can find peers
|
||||
if not hasattr(registry, "_bots"):
|
||||
registry._bots = {}
|
||||
registry._bots[self._username] = self
|
||||
|
||||
rate_cfg = config.get("bot", {})
|
||||
self._bucket = _TokenBucket(
|
||||
@@ -165,8 +193,14 @@ class MumbleBot:
|
||||
self._mumble = pymumble.Mumble(
|
||||
self._host, self._username,
|
||||
port=self._port, password=self._password,
|
||||
certfile=self._certfile, keyfile=self._keyfile,
|
||||
reconnect=True,
|
||||
)
|
||||
# pymumble captures threading.current_thread() as parent_thread at
|
||||
# init time. Since we run in an executor thread (which is transient),
|
||||
# the parent check in pymumble's loop would see a dead thread and
|
||||
# disconnect. Point it at the main thread instead.
|
||||
self._mumble.parent_thread = threading.main_thread()
|
||||
self._mumble.callbacks.set_callback(
|
||||
PYMUMBLE_CLBK_TEXTMESSAGERECEIVED,
|
||||
self._on_text_message,
|
||||
@@ -179,19 +213,127 @@ class MumbleBot:
|
||||
PYMUMBLE_CLBK_DISCONNECTED,
|
||||
self._on_disconnected,
|
||||
)
|
||||
self._mumble.set_receive_sound(False)
|
||||
self._mumble.callbacks.set_callback(
|
||||
PYMUMBLE_CLBK_SOUNDRECEIVED,
|
||||
self._on_sound_received,
|
||||
)
|
||||
self._mumble.set_receive_sound(self._receive_sound)
|
||||
# Raise retry interval so 2+ bots on the same IP don't trip
|
||||
# the server's autoban (default: 10 attempts / 120s).
|
||||
import pymumble_py3.mumble as _pm
|
||||
if getattr(_pm, "PYMUMBLE_CONNECTION_RETRY_INTERVAL", 0) < 15:
|
||||
_pm.PYMUMBLE_CONNECTION_RETRY_INTERVAL = 15
|
||||
self._mumble.start()
|
||||
self._mumble.is_ready()
|
||||
|
||||
def _on_connected(self) -> None:
|
||||
"""Callback from pymumble thread: connection established."""
|
||||
# Enable TCP keepalive on the control socket to prevent NAT
|
||||
# gateways from dropping the mapping during idle periods.
|
||||
try:
|
||||
import socket as _sock
|
||||
raw = self._mumble.control_socket
|
||||
raw.setsockopt(_sock.SOL_SOCKET, _sock.SO_KEEPALIVE, 1)
|
||||
raw.setsockopt(_sock.IPPROTO_TCP, _sock.TCP_KEEPIDLE, 10)
|
||||
raw.setsockopt(_sock.IPPROTO_TCP, _sock.TCP_KEEPINTVL, 5)
|
||||
raw.setsockopt(_sock.IPPROTO_TCP, _sock.TCP_KEEPCNT, 3)
|
||||
except Exception:
|
||||
pass
|
||||
self._connect_count += 1
|
||||
kind = "reconnected" if self._connect_count > 1 else "connected"
|
||||
session = getattr(self._mumble.users, "myself_session", "?")
|
||||
log.info("mumble: connected as %s on %s:%d (session=%s)",
|
||||
self._username, self._host, self._port, session)
|
||||
log.info("mumble: %s as %s on %s:%d (session=%s)",
|
||||
kind, self._username, self._host, self._port, session)
|
||||
if self._self_mute:
|
||||
try:
|
||||
self._mumble.users.myself.mute()
|
||||
except Exception:
|
||||
log.exception("mumble: failed to self-mute on connect")
|
||||
if self._self_deaf:
|
||||
try:
|
||||
self._mumble.users.myself.deafen()
|
||||
except Exception:
|
||||
log.exception("mumble: failed to self-deafen on connect")
|
||||
# Self-register on first connect so the server stores the cert
|
||||
# and treats this bot as a known user (persistent identity).
|
||||
if self._connect_count == 1:
|
||||
try:
|
||||
myself = self._mumble.users.myself
|
||||
if not myself.get("user_id"):
|
||||
myself.register()
|
||||
log.info("mumble: self-registered %s", self._username)
|
||||
except Exception:
|
||||
log.debug("mumble: self-register skipped (already registered?)")
|
||||
if self._loop:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._notify_plugins_connected(), self._loop,
|
||||
)
|
||||
|
||||
async def _notify_plugins_connected(self) -> None:
|
||||
"""Call on_connected(bot) in each loaded plugin that defines it.
|
||||
|
||||
Respects ``only_plugins`` / ``except_plugins`` so lifecycle hooks
|
||||
only fire for plugins this bot is allowed to handle.
|
||||
|
||||
After plugin hooks, checks for a ``greet`` config on the connecting
|
||||
bot. If present and this is the first connection, the greeting is
|
||||
spoken through the voice-capable peer (the bot whose ``only_plugins``
|
||||
includes ``voice``), so that a non-speaking bot like merlin can
|
||||
still have an audible entrance announced by derp.
|
||||
"""
|
||||
for name, mod in self.registry._modules.items():
|
||||
if not self._plugin_allowed(name, None):
|
||||
continue
|
||||
fn = getattr(mod, "on_connected", None)
|
||||
if fn is None or not asyncio.iscoroutinefunction(fn):
|
||||
continue
|
||||
try:
|
||||
await fn(self)
|
||||
except Exception:
|
||||
log.exception("mumble: on_connected hook failed in %s", name)
|
||||
await self._play_greet()
|
||||
|
||||
async def _play_greet(self) -> None:
|
||||
"""No-op: greeting is now handled by the voice plugin's on_connected."""
|
||||
|
||||
def _on_disconnected(self) -> None:
|
||||
"""Callback from pymumble thread: connection lost."""
|
||||
log.warning("mumble: disconnected")
|
||||
self._last_voice_ts = 0.0
|
||||
|
||||
def _instant_duck(self) -> None:
|
||||
"""Snap music volume to duck floor immediately.
|
||||
|
||||
Called from pymumble thread on voice/unmute events so ducking
|
||||
takes effect on the next audio frame (~20ms) instead of waiting
|
||||
for the 1s duck monitor poll.
|
||||
"""
|
||||
for peer in getattr(self.registry, "_bots", {}).values():
|
||||
ps = getattr(peer, "_pstate", {}).get("music")
|
||||
if ps and ps.get("duck_enabled") and ps.get("task"):
|
||||
ps["duck_vol"] = float(ps["duck_floor"])
|
||||
|
||||
def _on_sound_received(self, user, sound_chunk) -> None:
|
||||
"""Callback from pymumble thread: voice audio received.
|
||||
|
||||
Ignores audio from our own bots entirely -- prevents self-ducking
|
||||
and avoids STT transcribing bot TTS/music.
|
||||
"""
|
||||
name = user["name"] if isinstance(user, dict) else None
|
||||
bots = getattr(self.registry, "_bots", {})
|
||||
if name and name in bots:
|
||||
return
|
||||
prev = self._last_voice_ts
|
||||
self._last_voice_ts = time.monotonic()
|
||||
self.registry._voice_ts = self._last_voice_ts
|
||||
if prev == 0.0:
|
||||
log.info("mumble: first voice packet from %s", name or "?")
|
||||
self._instant_duck()
|
||||
for fn in self._sound_listeners:
|
||||
try:
|
||||
fn(user, sound_chunk)
|
||||
except Exception:
|
||||
log.exception("mumble: sound listener error")
|
||||
|
||||
def _on_text_message(self, message) -> None:
|
||||
"""Callback from pymumble thread: text message received.
|
||||
@@ -208,6 +350,8 @@ class MumbleBot:
|
||||
"""Process a text message from pymumble (runs on asyncio loop)."""
|
||||
text = _strip_html(pb_msg.message)
|
||||
actor = pb_msg.actor
|
||||
log.debug("mumble: [%s] text from actor %s: %s",
|
||||
self._username, actor, text[:100])
|
||||
|
||||
# Look up sender username
|
||||
nick = None
|
||||
@@ -236,6 +380,13 @@ class MumbleBot:
|
||||
is_channel=is_channel,
|
||||
params=[target or "", text],
|
||||
)
|
||||
|
||||
# Check for direct addressing: "botname: command ..."
|
||||
addressed = self._parse_addressed(text)
|
||||
if addressed is not None:
|
||||
await self._dispatch_addressed(msg, addressed)
|
||||
return
|
||||
|
||||
await self._dispatch_command(msg)
|
||||
|
||||
# -- Lifecycle -----------------------------------------------------------
|
||||
@@ -250,10 +401,69 @@ class MumbleBot:
|
||||
while self._running:
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
# Cancel background tasks first so play-loop can save resume state
|
||||
for task in list(self._tasks):
|
||||
task.cancel()
|
||||
if self._tasks:
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
if self._mumble:
|
||||
self._mumble.stop()
|
||||
self._mumble = None
|
||||
|
||||
# -- Direct addressing ---------------------------------------------------
|
||||
|
||||
def _parse_addressed(self, text: str) -> str | None:
|
||||
"""Check if text is addressed to this bot: ``botname: rest``.
|
||||
|
||||
Returns the text after the address prefix, or None.
|
||||
"""
|
||||
name = self._username.lower()
|
||||
lowered = text.lower()
|
||||
for sep in (":", ",", " "):
|
||||
prefix = name + sep
|
||||
if lowered.startswith(prefix):
|
||||
return text[len(prefix):].strip()
|
||||
return None
|
||||
|
||||
def _find_voice_peer(self):
|
||||
"""Find the voice-capable bot (the one with 'voice' in only_plugins)."""
|
||||
bots = getattr(self.registry, "_bots", {})
|
||||
for name, bot in bots.items():
|
||||
if name == self._username:
|
||||
continue
|
||||
if bot._only_plugins and "voice" in bot._only_plugins:
|
||||
return bot
|
||||
return None
|
||||
|
||||
async def _dispatch_addressed(self, msg: MumbleMessage, text: str) -> None:
|
||||
"""Handle a message directly addressed to this bot.
|
||||
|
||||
Supports a small set of built-in commands that don't use the
|
||||
``!prefix`` convention. Currently: ``say <text>``.
|
||||
|
||||
TTS playback is routed through the voice-capable peer (e.g.
|
||||
derp) so audio comes from the music bot's connection.
|
||||
"""
|
||||
parts = text.split(None, 1)
|
||||
if not parts:
|
||||
return
|
||||
sub = parts[0].lower()
|
||||
arg = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
log.info("mumble: [%s] addressed command: %s (arg=%s)",
|
||||
self._username, sub, arg[:80])
|
||||
|
||||
if sub == "say" and arg:
|
||||
voice_mod = self.registry._modules.get("voice")
|
||||
tts_play = getattr(voice_mod, "_tts_play", None) if voice_mod else None
|
||||
if tts_play is None:
|
||||
await self.reply(msg, "Voice not available")
|
||||
return
|
||||
# Route audio through the voice-capable peer
|
||||
speaker = self._find_voice_peer() or self
|
||||
speaker._spawn(tts_play(speaker, arg), name="addressed-say")
|
||||
# Extend with elif for future addressed commands
|
||||
|
||||
# -- Command dispatch ----------------------------------------------------
|
||||
|
||||
async def _dispatch_command(self, msg: MumbleMessage) -> None:
|
||||
@@ -297,12 +507,17 @@ class MumbleBot:
|
||||
log.exception("mumble: error in command handler '%s'", cmd_name)
|
||||
|
||||
def _resolve_command(self, name: str):
|
||||
"""Resolve command name with unambiguous prefix matching."""
|
||||
"""Resolve command name with unambiguous prefix matching.
|
||||
|
||||
Only considers commands from plugins this bot is allowed to handle,
|
||||
so filtered-out plugins never trigger ambiguity or dispatch.
|
||||
"""
|
||||
handler = self.registry.commands.get(name)
|
||||
if handler is not None:
|
||||
if handler is not None and self._plugin_allowed(handler.plugin, None):
|
||||
return handler
|
||||
matches = [v for k, v in self.registry.commands.items()
|
||||
if k.startswith(name)]
|
||||
if k.startswith(name)
|
||||
and self._plugin_allowed(v.plugin, None)]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
@@ -310,7 +525,11 @@ class MumbleBot:
|
||||
return None
|
||||
|
||||
def _plugin_allowed(self, plugin_name: str, channel: str | None) -> bool:
|
||||
"""Channel filtering is IRC-only; all plugins are allowed on Mumble."""
|
||||
"""Check if this bot handles commands from the given plugin."""
|
||||
if self._only_plugins is not None:
|
||||
return plugin_name in self._only_plugins
|
||||
if self._except_plugins is not None:
|
||||
return plugin_name not in self._except_plugins
|
||||
return True
|
||||
|
||||
# -- Permission tiers ----------------------------------------------------
|
||||
@@ -322,6 +541,9 @@ class MumbleBot:
|
||||
for name in self._admins:
|
||||
if msg.prefix == name:
|
||||
return "admin"
|
||||
for name in self._sorcerers:
|
||||
if msg.prefix == name:
|
||||
return "sorcerer"
|
||||
for name in self._operators:
|
||||
if msg.prefix == name:
|
||||
return "oper"
|
||||
@@ -413,6 +635,16 @@ class MumbleBot:
|
||||
|
||||
# -- Voice streaming -----------------------------------------------------
|
||||
|
||||
def _is_audio_ready(self) -> bool:
|
||||
"""Check if pymumble can accept audio (connected + encoder ready)."""
|
||||
if self._mumble is None:
|
||||
return False
|
||||
try:
|
||||
so = self._mumble.sound_output
|
||||
return so is not None and so.encoder is not None
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
async def test_tone(self, duration: float = 3.0) -> None:
|
||||
"""Send a 440Hz sine test tone for debugging voice output."""
|
||||
import math
|
||||
@@ -446,95 +678,288 @@ class MumbleBot:
|
||||
*,
|
||||
volume=0.5,
|
||||
on_done=None,
|
||||
seek: float = 0.0,
|
||||
progress: list | None = None,
|
||||
fade_step=None,
|
||||
fade_in: float | bool = False,
|
||||
seek_req: list | None = None,
|
||||
) -> None:
|
||||
"""Stream audio from URL through yt-dlp|ffmpeg to voice channel.
|
||||
|
||||
Pipeline:
|
||||
yt-dlp -o - -f bestaudio <url>
|
||||
| ffmpeg -i pipe:0 -f s16le -ar 48000 -ac 1 pipe:1
|
||||
| ffmpeg [-ss N.NNN] -i pipe:0 -f s16le -ar 48000 -ac 1 pipe:1
|
||||
|
||||
Feeds raw PCM to pymumble's sound_output which handles Opus
|
||||
encoding, packetization, and timing.
|
||||
|
||||
``volume`` may be a float (static) or a callable returning float
|
||||
(dynamic, re-read each frame).
|
||||
(dynamic, re-read each frame). ``seek`` skips into the track
|
||||
(seconds). ``progress`` is a mutable ``[0]`` list updated to the
|
||||
current frame count each frame. ``fade_step`` is an optional
|
||||
callable returning a float or None; when non-None it overrides
|
||||
the default ramp step for fast fades (e.g. skip/stop).
|
||||
``fade_in`` controls the initial volume ramp: ``False``/``0`` =
|
||||
no fade-in, ``True`` = 5.0s ramp, or a float for a custom
|
||||
duration in seconds. ``seek_req`` is a mutable ``[None]`` list;
|
||||
when ``seek_req[0]`` is set to a float, the stream swaps its
|
||||
ffmpeg pipeline in-place (fade-out, swap, fade-in) without
|
||||
cancelling the task.
|
||||
"""
|
||||
if self._mumble is None:
|
||||
return
|
||||
|
||||
_get_vol = volume if callable(volume) else lambda: volume
|
||||
log.info("stream_audio: starting pipeline for %s (vol=%.0f%%)",
|
||||
url, _get_vol() * 100)
|
||||
# Unmute before streaming if self_mute is enabled
|
||||
if self._self_mute:
|
||||
if self._mute_task and not self._mute_task.done():
|
||||
self._mute_task.cancel()
|
||||
self._mute_task = None
|
||||
try:
|
||||
self._mumble.users.myself.unmute()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_get_vol = volume if callable(volume) else lambda: volume
|
||||
log.info("stream_audio: [%s] starting pipeline for %s (vol=%.0f%%, seek=%.1fs)",
|
||||
self._username, url, _get_vol() * 100, seek)
|
||||
|
||||
def _build_cmd(seek_pos):
|
||||
seek_flag = f" -ss {seek_pos:.3f}" if seek_pos > 0 else ""
|
||||
if os.path.isfile(url):
|
||||
return (f"ffmpeg{seek_flag} -i {_shell_quote(url)}"
|
||||
f" -f s16le -ar 48000 -ac 1 -loglevel error pipe:1")
|
||||
return (f"yt-dlp -o - -f bestaudio --no-warnings {_shell_quote(url)}"
|
||||
f" | ffmpeg{seek_flag} -i pipe:0 -f s16le -ar 48000 -ac 1"
|
||||
f" -loglevel error pipe:1")
|
||||
|
||||
cmd = _build_cmd(seek)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sh", "-c",
|
||||
f"yt-dlp -o - -f bestaudio --no-warnings {_shell_quote(url)}"
|
||||
f" | ffmpeg -i pipe:0 -f s16le -ar 48000 -ac 1"
|
||||
f" -loglevel error pipe:1",
|
||||
"sh", "-c", cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
_max_step = 0.1 # max volume change per frame (~200ms full ramp)
|
||||
_cur_vol = _get_vol()
|
||||
_max_step = 0.005 # max volume change per frame (~4s full ramp)
|
||||
# Normalize fade_in to a duration in seconds
|
||||
if fade_in is True:
|
||||
_fade_dur = 5.0
|
||||
elif fade_in:
|
||||
_fade_dur = float(fade_in)
|
||||
else:
|
||||
_fade_dur = 0.0
|
||||
_fade_in_target = _get_vol()
|
||||
_cur_vol = 0.0 if _fade_dur > 0 else _fade_in_target
|
||||
_fade_in_total = int(_fade_dur / 0.02) if _fade_dur > 0 else 0
|
||||
_fade_in_frames = _fade_in_total
|
||||
_fade_in_step = (_fade_in_target / _fade_in_total) if _fade_in_total else 0
|
||||
_was_feeding = True # track connected/disconnected transitions
|
||||
|
||||
# Seek state (in-stream pipeline swap)
|
||||
_seek_fading = False
|
||||
_seek_target = 0.0
|
||||
_seek_fade_out = 0
|
||||
_SEEK_FADE_FRAMES = 10 # 0.2s ramp-down
|
||||
|
||||
frames = 0
|
||||
try:
|
||||
while True:
|
||||
# Seek: swap pipeline when fade-out complete
|
||||
if _seek_fading and _seek_fade_out <= 0:
|
||||
try:
|
||||
if self._is_audio_ready():
|
||||
self._mumble.sound_output.clear_buffer()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(proc.stderr.read(), timeout=3)
|
||||
await asyncio.wait_for(proc.wait(), timeout=3)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
pass
|
||||
cmd = _build_cmd(_seek_target)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sh", "-c", cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
frames = 0
|
||||
if progress is not None:
|
||||
progress[0] = 0
|
||||
seek = _seek_target
|
||||
_fade_in_total = 25 # 0.5s fade-in
|
||||
_fade_in_frames = _fade_in_total
|
||||
_fade_in_target = _get_vol()
|
||||
_fade_in_step = (
|
||||
(_fade_in_target / _fade_in_total)
|
||||
if _fade_in_total else 0
|
||||
)
|
||||
_cur_vol = 0.0
|
||||
_seek_fading = False
|
||||
log.info("stream_audio: seek to %.1fs", _seek_target)
|
||||
continue
|
||||
|
||||
pcm = await proc.stdout.read(_FRAME_BYTES)
|
||||
if not pcm and _seek_fading:
|
||||
_seek_fade_out = 0
|
||||
continue
|
||||
if not pcm:
|
||||
break
|
||||
if len(pcm) < _FRAME_BYTES:
|
||||
pcm += b"\x00" * (_FRAME_BYTES - len(pcm))
|
||||
|
||||
frames += 1
|
||||
if progress is not None:
|
||||
progress[0] = frames
|
||||
|
||||
if not self._is_audio_ready():
|
||||
# Disconnected -- keep reading ffmpeg at real-time pace
|
||||
if _was_feeding:
|
||||
log.warning("stream_audio: [%s] connection lost, "
|
||||
"dropping frames at %d",
|
||||
self._username, frames)
|
||||
_was_feeding = False
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
|
||||
if not _was_feeding:
|
||||
log.info("stream_audio: [%s] connection restored, "
|
||||
"resuming feed at frame %d",
|
||||
self._username, frames)
|
||||
_was_feeding = True
|
||||
|
||||
# Seek: fade-out in progress
|
||||
if _seek_fading:
|
||||
if (seek_req is not None
|
||||
and seek_req[0] is not None
|
||||
and seek_req[0] != _seek_target):
|
||||
_seek_target = seek_req[0]
|
||||
seek_req[0] = None
|
||||
fade_ratio = _seek_fade_out / _SEEK_FADE_FRAMES
|
||||
pcm = _scale_pcm(pcm, _cur_vol * fade_ratio)
|
||||
try:
|
||||
self._mumble.sound_output.add_sound(pcm)
|
||||
except (TypeError, AttributeError, OSError):
|
||||
pass
|
||||
_seek_fade_out -= 1
|
||||
try:
|
||||
while (self._is_audio_ready()
|
||||
and self._mumble.sound_output.get_buffer_size() > 1.0):
|
||||
await asyncio.sleep(0.05)
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
continue
|
||||
|
||||
# Seek: check for new request
|
||||
if seek_req is not None and seek_req[0] is not None:
|
||||
_seek_target = seek_req[0]
|
||||
seek_req[0] = None
|
||||
_seek_fading = True
|
||||
_seek_fade_out = _SEEK_FADE_FRAMES
|
||||
log.info("stream_audio: seek to %.1fs, fading out",
|
||||
_seek_target)
|
||||
|
||||
target = _get_vol()
|
||||
if _cur_vol == target:
|
||||
# Fast path: flat scaling
|
||||
step = _max_step
|
||||
if _fade_in_frames > 0:
|
||||
step = _fade_in_step
|
||||
_fade_in_frames -= 1
|
||||
elif fade_step is not None:
|
||||
fs = fade_step()
|
||||
if fs:
|
||||
step = fs
|
||||
diff = target - _cur_vol
|
||||
if abs(diff) < 0.0001:
|
||||
# Close enough -- flat scaling (no ramp artifacts)
|
||||
if target != 1.0:
|
||||
pcm = _scale_pcm(pcm, target)
|
||||
_cur_vol = target
|
||||
else:
|
||||
# Ramp toward target, clamped to _max_step per frame
|
||||
diff = target - _cur_vol
|
||||
if abs(diff) <= _max_step:
|
||||
# Ramp toward target, clamped to step per frame
|
||||
if abs(diff) <= step:
|
||||
next_vol = target
|
||||
elif diff > 0:
|
||||
next_vol = _cur_vol + _max_step
|
||||
next_vol = _cur_vol + step
|
||||
else:
|
||||
next_vol = _cur_vol - _max_step
|
||||
next_vol = _cur_vol - step
|
||||
pcm = _scale_pcm_ramp(pcm, _cur_vol, next_vol)
|
||||
_cur_vol = next_vol
|
||||
|
||||
self._mumble.sound_output.add_sound(pcm)
|
||||
frames += 1
|
||||
try:
|
||||
self._mumble.sound_output.add_sound(pcm)
|
||||
except (TypeError, AttributeError, OSError):
|
||||
# Disconnected mid-feed -- skip this frame
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
|
||||
if frames == 1:
|
||||
log.info("stream_audio: first frame fed to pymumble")
|
||||
log.info("stream_audio: [%s] first frame fed to pymumble",
|
||||
self._username)
|
||||
|
||||
# Keep buffer at most 1 second ahead
|
||||
while self._mumble.sound_output.get_buffer_size() > 1.0:
|
||||
await asyncio.sleep(0.05)
|
||||
try:
|
||||
while (self._is_audio_ready()
|
||||
and self._mumble.sound_output.get_buffer_size() > 1.0):
|
||||
await asyncio.sleep(0.05)
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
# Wait for buffer to drain
|
||||
while self._mumble.sound_output.get_buffer_size() > 0:
|
||||
await asyncio.sleep(0.1)
|
||||
log.info("stream_audio: finished, %d frames", frames)
|
||||
try:
|
||||
while (self._is_audio_ready()
|
||||
and self._mumble.sound_output.get_buffer_size() > 0):
|
||||
await asyncio.sleep(0.1)
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
log.info("stream_audio: [%s] finished, %d frames",
|
||||
self._username, frames)
|
||||
except asyncio.CancelledError:
|
||||
self._mumble.sound_output.clear_buffer()
|
||||
log.info("stream_audio: cancelled at frame %d", frames)
|
||||
# Only clear the buffer if volume is still audible -- if a
|
||||
# fade-out has already driven _cur_vol to ~0 the remaining
|
||||
# frames are silent and clearing mid-drain causes a click.
|
||||
if _cur_vol > 0.01:
|
||||
try:
|
||||
if self._is_audio_ready():
|
||||
self._mumble.sound_output.clear_buffer()
|
||||
except Exception:
|
||||
pass
|
||||
log.info("stream_audio: [%s] cancelled at frame %d (vol=%.3f)",
|
||||
self._username, frames, _cur_vol)
|
||||
raise
|
||||
except Exception:
|
||||
log.exception("stream_audio: error at frame %d", frames)
|
||||
log.exception("stream_audio: [%s] error at frame %d",
|
||||
self._username, frames)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
stderr_out = await proc.stderr.read()
|
||||
await proc.wait()
|
||||
try:
|
||||
stderr_out = await asyncio.wait_for(proc.stderr.read(), timeout=3)
|
||||
await asyncio.wait_for(proc.wait(), timeout=3)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
stderr_out = b""
|
||||
if stderr_out:
|
||||
log.warning("stream_audio: subprocess stderr: %s",
|
||||
stderr_out.decode(errors="replace")[:500])
|
||||
if on_done is not None:
|
||||
on_done.set()
|
||||
# Re-mute after audio finishes
|
||||
if self._self_mute:
|
||||
self._mute_task = self._spawn(
|
||||
self._delayed_mute(3.0), name="self-mute",
|
||||
)
|
||||
|
||||
async def _delayed_mute(self, delay: float) -> None:
|
||||
"""Re-mute after a delay (lets the audio buffer drain fully)."""
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
self._mumble.users.myself.mute()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def shorten_url(self, url: str) -> str:
|
||||
"""Shorten a URL via FlaskPaste. Returns original on failure."""
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any, Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TIERS: tuple[str, ...] = ("user", "trusted", "oper", "admin")
|
||||
TIERS: tuple[str, ...] = ("user", "trusted", "oper", "sorcerer", "admin")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -27,7 +27,13 @@ class Handler:
|
||||
tier: str = "user"
|
||||
|
||||
|
||||
def command(name: str, help: str = "", admin: bool = False, tier: str = "") -> Callable:
|
||||
def command(
|
||||
name: str,
|
||||
help: str = "",
|
||||
admin: bool = False,
|
||||
tier: str = "",
|
||||
aliases: list[str] | None = None,
|
||||
) -> Callable:
|
||||
"""Decorator to register an async function as a bot command.
|
||||
|
||||
Usage::
|
||||
@@ -40,8 +46,8 @@ def command(name: str, help: str = "", admin: bool = False, tier: str = "") -> C
|
||||
async def cmd_reload(bot, message):
|
||||
...
|
||||
|
||||
@command("trusted_cmd", help="Trusted-only", tier="trusted")
|
||||
async def cmd_trusted(bot, message):
|
||||
@command("skip", help="Skip track", aliases=["next"])
|
||||
async def cmd_skip(bot, message):
|
||||
...
|
||||
"""
|
||||
|
||||
@@ -50,6 +56,7 @@ def command(name: str, help: str = "", admin: bool = False, tier: str = "") -> C
|
||||
func._derp_help = help # type: ignore[attr-defined]
|
||||
func._derp_admin = admin # type: ignore[attr-defined]
|
||||
func._derp_tier = tier if tier else ("admin" if admin else "user") # type: ignore[attr-defined]
|
||||
func._derp_aliases = aliases or [] # type: ignore[attr-defined]
|
||||
return func
|
||||
|
||||
return decorator
|
||||
@@ -107,14 +114,25 @@ class PluginRegistry:
|
||||
count = 0
|
||||
for _name, obj in inspect.getmembers(module, inspect.isfunction):
|
||||
if hasattr(obj, "_derp_command"):
|
||||
cmd_tier = getattr(obj, "_derp_tier", "user")
|
||||
cmd_admin = getattr(obj, "_derp_admin", False)
|
||||
self.register_command(
|
||||
obj._derp_command, obj,
|
||||
help=getattr(obj, "_derp_help", ""),
|
||||
plugin=plugin_name,
|
||||
admin=getattr(obj, "_derp_admin", False),
|
||||
tier=getattr(obj, "_derp_tier", "user"),
|
||||
admin=cmd_admin,
|
||||
tier=cmd_tier,
|
||||
)
|
||||
count += 1
|
||||
for alias in getattr(obj, "_derp_aliases", []):
|
||||
self.register_command(
|
||||
alias, obj,
|
||||
help=f"alias for !{obj._derp_command}",
|
||||
plugin=plugin_name,
|
||||
admin=cmd_admin,
|
||||
tier=cmd_tier,
|
||||
)
|
||||
count += 1
|
||||
if hasattr(obj, "_derp_event"):
|
||||
self.register_event(obj._derp_event, obj, plugin=plugin_name)
|
||||
count += 1
|
||||
|
||||
@@ -107,7 +107,7 @@ def _msg(text: str, prefix: str = "nick!user@host") -> Message:
|
||||
|
||||
class TestTierConstants:
|
||||
def test_tier_order(self):
|
||||
assert TIERS == ("user", "trusted", "oper", "admin")
|
||||
assert TIERS == ("user", "trusted", "oper", "sorcerer", "admin")
|
||||
|
||||
def test_index_comparison(self):
|
||||
assert TIERS.index("user") < TIERS.index("trusted")
|
||||
|
||||
@@ -420,7 +420,7 @@ class TestExtractVideos:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResp()):
|
||||
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
|
||||
results = _search_youtube("test")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "dup1"
|
||||
@@ -438,7 +438,7 @@ class TestSearchYoutube:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResp()):
|
||||
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
|
||||
results = _search_youtube("test query")
|
||||
assert len(results) == 2
|
||||
assert results[0]["id"] == "abc123"
|
||||
@@ -446,7 +446,7 @@ class TestSearchYoutube:
|
||||
|
||||
def test_http_error_propagates(self):
|
||||
import pytest
|
||||
with patch("urllib.request.urlopen", side_effect=ConnectionError("fail")):
|
||||
with patch.object(_mod, "_urlopen", side_effect=ConnectionError("fail")):
|
||||
with pytest.raises(ConnectionError):
|
||||
_search_youtube("test")
|
||||
|
||||
@@ -1263,7 +1263,7 @@ class TestSearchSearx:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResp()):
|
||||
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
|
||||
results = _search_searx("test query")
|
||||
# Same response served for all categories; deduped by URL
|
||||
assert len(results) == 3
|
||||
@@ -1281,13 +1281,13 @@ class TestSearchSearx:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResp()):
|
||||
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
|
||||
results = _search_searx("nothing")
|
||||
assert results == []
|
||||
|
||||
def test_http_error_returns_empty(self):
|
||||
"""SearXNG catches per-category errors; all failing returns empty."""
|
||||
with patch("urllib.request.urlopen", side_effect=ConnectionError("fail")):
|
||||
with patch.object(_mod, "_urlopen", side_effect=ConnectionError("fail")):
|
||||
results = _search_searx("test")
|
||||
assert results == []
|
||||
|
||||
|
||||
212
tests/test_alias.py
Normal file
212
tests/test_alias.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Tests for the alias plugin."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
from derp.plugin import PluginRegistry
|
||||
|
||||
# -- Load plugin module directly ---------------------------------------------
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("alias", "plugins/alias.py")
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["alias"] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
|
||||
# -- Fakes -------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeState:
|
||||
def __init__(self):
|
||||
self._store: dict[str, dict[str, str]] = {}
|
||||
|
||||
def get(self, ns: str, key: str) -> str | None:
|
||||
return self._store.get(ns, {}).get(key)
|
||||
|
||||
def set(self, ns: str, key: str, value: str) -> None:
|
||||
self._store.setdefault(ns, {})[key] = value
|
||||
|
||||
def delete(self, ns: str, key: str) -> bool:
|
||||
if ns in self._store and key in self._store[ns]:
|
||||
del self._store[ns][key]
|
||||
return True
|
||||
return False
|
||||
|
||||
def keys(self, ns: str) -> list[str]:
|
||||
return list(self._store.get(ns, {}).keys())
|
||||
|
||||
def clear(self, ns: str) -> int:
|
||||
count = len(self._store.get(ns, {}))
|
||||
self._store.pop(ns, None)
|
||||
return count
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self, *, admin: bool = False):
|
||||
self.replied: list[str] = []
|
||||
self.state = _FakeState()
|
||||
self.registry = PluginRegistry()
|
||||
self._admin = admin
|
||||
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
def _is_admin(self, message) -> bool:
|
||||
return self._admin
|
||||
|
||||
|
||||
class _Msg:
|
||||
def __init__(self, text="!alias"):
|
||||
self.text = text
|
||||
self.nick = "Alice"
|
||||
self.target = "#test"
|
||||
self.is_channel = True
|
||||
self.prefix = "Alice!~alice@host"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAliasAdd
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAliasAdd:
|
||||
def test_add_creates_alias(self):
|
||||
bot = _FakeBot()
|
||||
# Register a target command
|
||||
async def _noop(b, m): pass
|
||||
bot.registry.register_command("skip", _noop, plugin="music")
|
||||
msg = _Msg(text="!alias add s skip")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert bot.state.get("alias", "s") == "skip"
|
||||
assert any("s -> skip" in r for r in bot.replied)
|
||||
|
||||
def test_add_rejects_existing_command(self):
|
||||
bot = _FakeBot()
|
||||
async def _noop(b, m): pass
|
||||
bot.registry.register_command("skip", _noop, plugin="music")
|
||||
msg = _Msg(text="!alias add skip stop")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("already a registered command" in r for r in bot.replied)
|
||||
assert bot.state.get("alias", "skip") is None
|
||||
|
||||
def test_add_rejects_chaining(self):
|
||||
bot = _FakeBot()
|
||||
async def _noop(b, m): pass
|
||||
bot.registry.register_command("skip", _noop, plugin="music")
|
||||
bot.state.set("alias", "sk", "skip")
|
||||
msg = _Msg(text="!alias add x sk")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("no chaining" in r for r in bot.replied)
|
||||
|
||||
def test_add_rejects_unknown_target(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!alias add s nonexistent")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("unknown command" in r for r in bot.replied)
|
||||
|
||||
def test_add_lowercases_name(self):
|
||||
bot = _FakeBot()
|
||||
async def _noop(b, m): pass
|
||||
bot.registry.register_command("skip", _noop, plugin="music")
|
||||
msg = _Msg(text="!alias add S skip")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert bot.state.get("alias", "s") == "skip"
|
||||
|
||||
def test_add_missing_args(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!alias add s")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("Usage" in r for r in bot.replied)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAliasDel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAliasDel:
|
||||
def test_del_removes_alias(self):
|
||||
bot = _FakeBot()
|
||||
bot.state.set("alias", "s", "skip")
|
||||
msg = _Msg(text="!alias del s")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert bot.state.get("alias", "s") is None
|
||||
assert any("removed" in r for r in bot.replied)
|
||||
|
||||
def test_del_nonexistent(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!alias del x")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("no alias" in r for r in bot.replied)
|
||||
|
||||
def test_del_missing_name(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!alias del")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("Usage" in r for r in bot.replied)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAliasList
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAliasList:
|
||||
def test_list_empty(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!alias list")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("No aliases" in r for r in bot.replied)
|
||||
|
||||
def test_list_shows_entries(self):
|
||||
bot = _FakeBot()
|
||||
bot.state.set("alias", "s", "skip")
|
||||
bot.state.set("alias", "np", "nowplaying")
|
||||
msg = _Msg(text="!alias list")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("s -> skip" in r for r in bot.replied)
|
||||
assert any("np -> nowplaying" in r for r in bot.replied)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAliasClear
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAliasClear:
|
||||
def test_clear_as_admin(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
bot.state.set("alias", "s", "skip")
|
||||
bot.state.set("alias", "np", "nowplaying")
|
||||
msg = _Msg(text="!alias clear")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("Cleared 2" in r for r in bot.replied)
|
||||
assert bot.state.keys("alias") == []
|
||||
|
||||
def test_clear_denied_non_admin(self):
|
||||
bot = _FakeBot(admin=False)
|
||||
bot.state.set("alias", "s", "skip")
|
||||
msg = _Msg(text="!alias clear")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("Permission denied" in r for r in bot.replied)
|
||||
assert bot.state.get("alias", "s") == "skip"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAliasUsage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAliasUsage:
|
||||
def test_no_subcommand(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!alias")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("Usage" in r for r in bot.replied)
|
||||
|
||||
def test_unknown_subcommand(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!alias foo")
|
||||
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||
assert any("Usage" in r for r in bot.replied)
|
||||
288
tests/test_core.py
Normal file
288
tests/test_core.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""Tests for the core plugin."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# -- Load plugin module directly ---------------------------------------------
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("core", "plugins/core.py")
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["core"] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
|
||||
# -- Fakes -------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeHandler:
|
||||
name: str
|
||||
callback: object
|
||||
help: str = ""
|
||||
plugin: str = ""
|
||||
admin: bool = False
|
||||
tier: str = "user"
|
||||
|
||||
|
||||
class _FakeRegistry:
|
||||
def __init__(self):
|
||||
self._bots: dict = {}
|
||||
self.commands: dict = {}
|
||||
self._modules: dict = {}
|
||||
self.events: dict = {}
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self, *, mumble: bool = False):
|
||||
self.replied: list[str] = []
|
||||
self.registry = _FakeRegistry()
|
||||
self.nick = "derp"
|
||||
self.prefix = "!"
|
||||
self._receive_sound = False
|
||||
if mumble:
|
||||
self._mumble = MagicMock()
|
||||
|
||||
def _plugin_allowed(self, plugin: str, channel) -> bool:
|
||||
return True
|
||||
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
|
||||
def _make_listener():
|
||||
"""Create a fake listener bot (merlin) with _receive_sound=True."""
|
||||
listener = _FakeBot(mumble=True)
|
||||
listener.nick = "merlin"
|
||||
listener._receive_sound = True
|
||||
return listener
|
||||
|
||||
|
||||
class _Msg:
|
||||
def __init__(self, text="!deaf"):
|
||||
self.text = text
|
||||
self.nick = "Alice"
|
||||
self.target = "0"
|
||||
self.is_channel = True
|
||||
self.prefix = "Alice"
|
||||
|
||||
|
||||
# -- Tests -------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeafCommand:
|
||||
def test_deaf_targets_listener(self):
|
||||
"""!deaf toggles the listener bot (merlin), not the calling bot."""
|
||||
bot = _FakeBot(mumble=True)
|
||||
listener = _make_listener()
|
||||
bot.registry._bots = {"derp": bot, "merlin": listener}
|
||||
listener._mumble.users.myself.get.return_value = False
|
||||
msg = _Msg(text="!deaf")
|
||||
asyncio.run(_mod.cmd_deaf(bot, msg))
|
||||
listener._mumble.users.myself.deafen.assert_called_once()
|
||||
assert any("merlin" in r and "deafened" in r for r in bot.replied)
|
||||
|
||||
def test_deaf_toggle_off(self):
|
||||
bot = _FakeBot(mumble=True)
|
||||
listener = _make_listener()
|
||||
bot.registry._bots = {"derp": bot, "merlin": listener}
|
||||
listener._mumble.users.myself.get.return_value = True
|
||||
msg = _Msg(text="!deaf")
|
||||
asyncio.run(_mod.cmd_deaf(bot, msg))
|
||||
listener._mumble.users.myself.undeafen.assert_called_once()
|
||||
listener._mumble.users.myself.unmute.assert_called_once()
|
||||
assert any("merlin" in r and "undeafened" in r for r in bot.replied)
|
||||
|
||||
def test_deaf_non_mumble_silent(self):
|
||||
bot = _FakeBot(mumble=False)
|
||||
msg = _Msg(text="!deaf")
|
||||
asyncio.run(_mod.cmd_deaf(bot, msg))
|
||||
assert bot.replied == []
|
||||
|
||||
def test_deaf_fallback_no_listener(self):
|
||||
"""Falls back to calling bot when no listener is registered."""
|
||||
bot = _FakeBot(mumble=True)
|
||||
bot._mumble.users.myself.get.return_value = False
|
||||
msg = _Msg(text="!deaf")
|
||||
asyncio.run(_mod.cmd_deaf(bot, msg))
|
||||
bot._mumble.users.myself.deafen.assert_called_once()
|
||||
|
||||
|
||||
# -- Help command tests ------------------------------------------------------
|
||||
|
||||
|
||||
def _cmd_with_doc():
|
||||
"""Manage widgets.
|
||||
|
||||
Usage:
|
||||
!widget add <name>
|
||||
!widget del <name>
|
||||
|
||||
Examples:
|
||||
!widget add foo
|
||||
"""
|
||||
|
||||
|
||||
def _cmd_no_doc():
|
||||
pass
|
||||
|
||||
|
||||
def _make_fp_module(url="https://paste.example.com/abc/raw", capture=None):
|
||||
"""Create a fake flaskpaste module that returns a fixed URL.
|
||||
|
||||
If capture is a list, appended paste content is stored there.
|
||||
"""
|
||||
mod = types.ModuleType("flaskpaste")
|
||||
|
||||
def _create(bot, text):
|
||||
if capture is not None:
|
||||
capture.append(text)
|
||||
return url
|
||||
|
||||
mod.create_paste = _create
|
||||
return mod
|
||||
|
||||
|
||||
class TestHelpCommand:
|
||||
def test_help_cmd_with_paste(self):
|
||||
"""!help <cmd> with docstring pastes detail, appends URL."""
|
||||
bot = _FakeBot()
|
||||
handler = _FakeHandler(
|
||||
name="widget", callback=_cmd_with_doc,
|
||||
help="Manage widgets", plugin="widgets",
|
||||
)
|
||||
bot.registry.commands["widget"] = handler
|
||||
bot.registry._modules["flaskpaste"] = _make_fp_module()
|
||||
msg = _Msg(text="!help widget")
|
||||
asyncio.run(_mod.cmd_help(bot, msg))
|
||||
assert len(bot.replied) == 1
|
||||
assert "!widget -- Manage widgets" in bot.replied[0]
|
||||
assert "https://paste.example.com/abc/raw" in bot.replied[0]
|
||||
|
||||
def test_help_cmd_no_docstring(self):
|
||||
"""!help <cmd> without docstring skips paste."""
|
||||
bot = _FakeBot()
|
||||
handler = _FakeHandler(
|
||||
name="noop", callback=_cmd_no_doc,
|
||||
help="Does nothing", plugin="misc",
|
||||
)
|
||||
bot.registry.commands["noop"] = handler
|
||||
bot.registry._modules["flaskpaste"] = _make_fp_module()
|
||||
msg = _Msg(text="!help noop")
|
||||
asyncio.run(_mod.cmd_help(bot, msg))
|
||||
assert len(bot.replied) == 1
|
||||
assert "!noop -- Does nothing" in bot.replied[0]
|
||||
assert "paste.example.com" not in bot.replied[0]
|
||||
|
||||
def test_help_plugin_with_paste(self):
|
||||
"""!help <plugin> pastes detail for all plugin commands."""
|
||||
bot = _FakeBot()
|
||||
mod = types.ModuleType("widgets")
|
||||
mod.__doc__ = "Widget management plugin."
|
||||
bot.registry._modules["widgets"] = mod
|
||||
bot.registry._modules["flaskpaste"] = _make_fp_module()
|
||||
bot.registry.commands["widget"] = _FakeHandler(
|
||||
name="widget", callback=_cmd_with_doc,
|
||||
help="Manage widgets", plugin="widgets",
|
||||
)
|
||||
bot.registry.commands["wstat"] = _FakeHandler(
|
||||
name="wstat", callback=_cmd_no_doc,
|
||||
help="Widget stats", plugin="widgets",
|
||||
)
|
||||
msg = _Msg(text="!help widgets")
|
||||
asyncio.run(_mod.cmd_help(bot, msg))
|
||||
assert len(bot.replied) == 1
|
||||
reply = bot.replied[0]
|
||||
assert "widgets -- Widget management plugin." in reply
|
||||
assert "!widget, !wstat" in reply
|
||||
# Only widget has a docstring, so paste should still happen
|
||||
assert "https://paste.example.com/abc/raw" in reply
|
||||
|
||||
def test_help_list_with_paste(self):
|
||||
"""!help (no args) pastes full reference."""
|
||||
bot = _FakeBot()
|
||||
bot.registry._modules["flaskpaste"] = _make_fp_module()
|
||||
mod = types.ModuleType("core")
|
||||
mod.__doc__ = "Core plugin."
|
||||
bot.registry._modules["core"] = mod
|
||||
bot.registry.commands["ping"] = _FakeHandler(
|
||||
name="ping", callback=_cmd_with_doc,
|
||||
help="Check alive", plugin="core",
|
||||
)
|
||||
bot.registry.commands["help"] = _FakeHandler(
|
||||
name="help", callback=_cmd_no_doc,
|
||||
help="Show help", plugin="core",
|
||||
)
|
||||
msg = _Msg(text="!help")
|
||||
asyncio.run(_mod.cmd_help(bot, msg))
|
||||
assert len(bot.replied) == 1
|
||||
assert "help, ping" in bot.replied[0]
|
||||
assert "https://paste.example.com/abc/raw" in bot.replied[0]
|
||||
|
||||
def test_help_no_flaskpaste(self):
|
||||
"""Without flaskpaste loaded, help still works (no URL)."""
|
||||
bot = _FakeBot()
|
||||
handler = _FakeHandler(
|
||||
name="widget", callback=_cmd_with_doc,
|
||||
help="Manage widgets", plugin="widgets",
|
||||
)
|
||||
bot.registry.commands["widget"] = handler
|
||||
# No flaskpaste in _modules
|
||||
msg = _Msg(text="!help widget")
|
||||
asyncio.run(_mod.cmd_help(bot, msg))
|
||||
assert len(bot.replied) == 1
|
||||
assert "!widget -- Manage widgets" in bot.replied[0]
|
||||
assert "https://" not in bot.replied[0]
|
||||
|
||||
def test_help_cmd_paste_hierarchy(self):
|
||||
"""Single-command paste: header at 0, docstring at 4."""
|
||||
bot = _FakeBot()
|
||||
pastes: list[str] = []
|
||||
bot.registry._modules["flaskpaste"] = _make_fp_module(capture=pastes)
|
||||
bot.registry.commands["widget"] = _FakeHandler(
|
||||
name="widget", callback=_cmd_with_doc,
|
||||
help="Manage widgets", plugin="widgets",
|
||||
)
|
||||
msg = _Msg(text="!help widget")
|
||||
asyncio.run(_mod.cmd_help(bot, msg))
|
||||
assert len(pastes) == 1
|
||||
lines = pastes[0].split("\n")
|
||||
# Level 0: command header flush-left
|
||||
assert lines[0] == "!widget -- Manage widgets"
|
||||
# Level 1: docstring lines indented 4 spaces
|
||||
for line in lines[1:]:
|
||||
if line.strip():
|
||||
assert line.startswith(" "), f"not indented: {line!r}"
|
||||
|
||||
def test_help_list_paste_hierarchy(self):
|
||||
"""Full reference paste: plugin at 0, command at 4, doc at 8."""
|
||||
bot = _FakeBot()
|
||||
pastes: list[str] = []
|
||||
bot.registry._modules["flaskpaste"] = _make_fp_module(capture=pastes)
|
||||
mod = types.ModuleType("core")
|
||||
mod.__doc__ = "Core plugin."
|
||||
bot.registry._modules["core"] = mod
|
||||
bot.registry.commands["state"] = _FakeHandler(
|
||||
name="state", callback=_cmd_with_doc,
|
||||
help="Inspect state", plugin="core",
|
||||
)
|
||||
msg = _Msg(text="!help")
|
||||
asyncio.run(_mod.cmd_help(bot, msg))
|
||||
assert len(pastes) == 1
|
||||
text = pastes[0]
|
||||
lines = text.split("\n")
|
||||
# Level 0: plugin header
|
||||
assert lines[0] == "[core]"
|
||||
# Level 1: plugin description
|
||||
assert lines[1] == " Core plugin."
|
||||
# Blank separator
|
||||
assert lines[2] == ""
|
||||
# Level 1: command header at indent 4
|
||||
assert lines[3] == " !state -- Inspect state"
|
||||
# Level 2: docstring at indent 8
|
||||
for line in lines[4:]:
|
||||
if line.strip():
|
||||
assert line.startswith(" "), f"not at indent 8: {line!r}"
|
||||
@@ -203,11 +203,15 @@ class TestUrlopen:
|
||||
pool = MagicMock()
|
||||
resp = MagicMock()
|
||||
resp.status = 200
|
||||
resp.data = b"ok"
|
||||
resp.reason = "OK"
|
||||
resp.headers = {}
|
||||
pool.request.return_value = resp
|
||||
mock_pool_fn.return_value = pool
|
||||
|
||||
result = urlopen("https://example.com/")
|
||||
assert result is resp
|
||||
assert result.status == 200
|
||||
assert result.read() == b"ok"
|
||||
|
||||
@patch.object(derp.http, "_get_pool")
|
||||
def test_context_falls_back_to_opener(self, mock_pool_fn):
|
||||
|
||||
916
tests/test_lastfm.py
Normal file
916
tests/test_lastfm.py
Normal file
@@ -0,0 +1,916 @@
|
||||
"""Tests for the Last.fm music discovery plugin."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
# -- Load plugin module directly ---------------------------------------------
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("lastfm", "plugins/lastfm.py")
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["lastfm"] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
|
||||
# -- Fakes -------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeRegistry:
|
||||
def __init__(self):
|
||||
self._modules: dict = {}
|
||||
self._bots: dict = {}
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self, *, api_key: str = "test-key", name: str = "derp"):
|
||||
self.replied: list[str] = []
|
||||
self.config: dict = {"lastfm": {"api_key": api_key}} if api_key else {}
|
||||
self._pstate: dict = {}
|
||||
self._only_plugins: set[str] | None = None
|
||||
self.registry = _FakeRegistry()
|
||||
self._username = name
|
||||
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
async def long_reply(self, message, lines: list[str], *,
|
||||
label: str = "") -> None:
|
||||
for line in lines:
|
||||
self.replied.append(line)
|
||||
|
||||
|
||||
class _Msg:
|
||||
def __init__(self, text="!similar", nick="Alice", target="0",
|
||||
is_channel=True):
|
||||
self.text = text
|
||||
self.nick = nick
|
||||
self.target = target
|
||||
self.is_channel = is_channel
|
||||
self.prefix = nick
|
||||
self.command = "PRIVMSG"
|
||||
self.params = [target, text]
|
||||
self.tags = {}
|
||||
self.raw = {}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeTrack:
|
||||
url: str = ""
|
||||
title: str = ""
|
||||
requester: str = ""
|
||||
|
||||
|
||||
# -- API response fixtures ---------------------------------------------------
|
||||
|
||||
SIMILAR_ARTISTS_RESP = {
|
||||
"similarartists": {
|
||||
"artist": [
|
||||
{"name": "Artist B", "match": "0.85"},
|
||||
{"name": "Artist C", "match": "0.72"},
|
||||
{"name": "Artist D", "match": "0.60"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
SIMILAR_TRACKS_RESP = {
|
||||
"similartracks": {
|
||||
"track": [
|
||||
{"name": "Track X", "artist": {"name": "Artist B"}, "match": "0.9"},
|
||||
{"name": "Track Y", "artist": {"name": "Artist C"}, "match": "0.7"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
TOP_TAGS_RESP = {
|
||||
"toptags": {
|
||||
"tag": [
|
||||
{"name": "rock", "count": 100},
|
||||
{"name": "alternative", "count": 80},
|
||||
{"name": "indie", "count": 60},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
TRACK_SEARCH_RESP = {
|
||||
"results": {
|
||||
"trackmatches": {
|
||||
"track": [
|
||||
{"name": "Found Track", "artist": "Found Artist"},
|
||||
{"name": "Another", "artist": "Someone"},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGetApiKey
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetApiKey:
|
||||
def test_from_config(self):
|
||||
bot = _FakeBot(api_key="cfg-key")
|
||||
assert _mod._get_api_key(bot) == "cfg-key"
|
||||
|
||||
def test_from_env(self):
|
||||
bot = _FakeBot(api_key="")
|
||||
with patch.dict("os.environ", {"LASTFM_API_KEY": "env-key"}):
|
||||
assert _mod._get_api_key(bot) == "env-key"
|
||||
|
||||
def test_env_takes_priority(self):
|
||||
bot = _FakeBot(api_key="cfg-key")
|
||||
with patch.dict("os.environ", {"LASTFM_API_KEY": "env-key"}):
|
||||
assert _mod._get_api_key(bot) == "env-key"
|
||||
|
||||
def test_empty_when_unset(self):
|
||||
bot = _FakeBot(api_key="")
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
assert _mod._get_api_key(bot) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestApiCall
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApiCall:
|
||||
def test_parses_json(self):
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b'{"result": "ok"}'
|
||||
with patch.object(_mod, "urlopen", create=True, return_value=resp):
|
||||
# _api_call imports urlopen from derp.http at call time
|
||||
with patch("derp.http.urlopen", return_value=resp):
|
||||
data = _mod._api_call("key", "artist.getSimilar", artist="X")
|
||||
assert data == {"result": "ok"}
|
||||
|
||||
def test_returns_empty_on_error(self):
|
||||
with patch("derp.http.urlopen", side_effect=ConnectionError("fail")):
|
||||
data = _mod._api_call("key", "artist.getSimilar", artist="X")
|
||||
assert data == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGetSimilarArtists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetSimilarArtists:
|
||||
def test_returns_list(self):
|
||||
with patch.object(_mod, "_api_call", return_value=SIMILAR_ARTISTS_RESP):
|
||||
result = _mod._get_similar_artists("key", "Artist A")
|
||||
assert len(result) == 3
|
||||
assert result[0]["name"] == "Artist B"
|
||||
|
||||
def test_single_dict_wrapped(self):
|
||||
"""Single artist result (dict instead of list) gets wrapped."""
|
||||
data = {"similarartists": {"artist": {"name": "Solo", "match": "1.0"}}}
|
||||
with patch.object(_mod, "_api_call", return_value=data):
|
||||
result = _mod._get_similar_artists("key", "X")
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Solo"
|
||||
|
||||
def test_empty_response(self):
|
||||
with patch.object(_mod, "_api_call", return_value={}):
|
||||
result = _mod._get_similar_artists("key", "X")
|
||||
assert result == []
|
||||
|
||||
def test_missing_key(self):
|
||||
with patch.object(_mod, "_api_call", return_value={"error": 6}):
|
||||
result = _mod._get_similar_artists("key", "X")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGetTopTags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTopTags:
|
||||
def test_returns_list(self):
|
||||
with patch.object(_mod, "_api_call", return_value=TOP_TAGS_RESP):
|
||||
result = _mod._get_top_tags("key", "Artist A")
|
||||
assert len(result) == 3
|
||||
assert result[0]["name"] == "rock"
|
||||
|
||||
def test_single_dict_wrapped(self):
|
||||
data = {"toptags": {"tag": {"name": "electronic", "count": 50}}}
|
||||
with patch.object(_mod, "_api_call", return_value=data):
|
||||
result = _mod._get_top_tags("key", "X")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_response(self):
|
||||
with patch.object(_mod, "_api_call", return_value={}):
|
||||
result = _mod._get_top_tags("key", "X")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGetSimilarTracks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetSimilarTracks:
|
||||
def test_returns_list(self):
|
||||
with patch.object(_mod, "_api_call", return_value=SIMILAR_TRACKS_RESP):
|
||||
result = _mod._get_similar_tracks("key", "A", "T")
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "Track X"
|
||||
|
||||
def test_single_dict_wrapped(self):
|
||||
data = {"similartracks": {"track": {"name": "Solo", "artist": {"name": "X"}}}}
|
||||
with patch.object(_mod, "_api_call", return_value=data):
|
||||
result = _mod._get_similar_tracks("key", "X", "T")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_response(self):
|
||||
with patch.object(_mod, "_api_call", return_value={}):
|
||||
result = _mod._get_similar_tracks("key", "X", "T")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSearchTrack
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSearchTrack:
|
||||
def test_returns_results(self):
|
||||
with patch.object(_mod, "_api_call", return_value=TRACK_SEARCH_RESP):
|
||||
result = _mod._search_track("key", "test")
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "Found Track"
|
||||
|
||||
def test_single_dict_wrapped(self):
|
||||
data = {"results": {"trackmatches": {
|
||||
"track": {"name": "One", "artist": "X"},
|
||||
}}}
|
||||
with patch.object(_mod, "_api_call", return_value=data):
|
||||
result = _mod._search_track("key", "test")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_response(self):
|
||||
with patch.object(_mod, "_api_call", return_value={}):
|
||||
result = _mod._search_track("key", "test")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCurrentMeta
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCurrentMeta:
|
||||
def test_no_music_state(self):
|
||||
bot = _FakeBot()
|
||||
assert _mod._current_meta(bot) == ("", "")
|
||||
|
||||
def test_no_current_track(self):
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {"current": None}
|
||||
assert _mod._current_meta(bot) == ("", "")
|
||||
|
||||
def test_dash_separator(self):
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {"current": _FakeTrack(title="Tool - Lateralus")}
|
||||
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
|
||||
|
||||
def test_double_dash_separator(self):
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {"current": _FakeTrack(title="Tool -- Lateralus")}
|
||||
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
|
||||
|
||||
def test_pipe_separator(self):
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {"current": _FakeTrack(title="Tool | Lateralus")}
|
||||
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
|
||||
|
||||
def test_tilde_separator(self):
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {"current": _FakeTrack(title="Tool ~ Lateralus")}
|
||||
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
|
||||
|
||||
def test_no_separator(self):
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {"current": _FakeTrack(title="Lateralus")}
|
||||
assert _mod._current_meta(bot) == ("", "Lateralus")
|
||||
|
||||
def test_empty_title(self):
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {"current": _FakeTrack(title="")}
|
||||
assert _mod._current_meta(bot) == ("", "")
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title=" Tool - Lateralus "),
|
||||
}
|
||||
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
|
||||
|
||||
def test_peer_bot_music_state(self):
|
||||
"""Extra bot sees music state from peer bot via shared registry."""
|
||||
music_bot = _FakeBot()
|
||||
music_bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||
}
|
||||
extra_bot = _FakeBot()
|
||||
# No music state on extra_bot
|
||||
# Share registry with _bots index
|
||||
shared_reg = _FakeRegistry()
|
||||
shared_reg._bots = {"derp": music_bot, "merlin": extra_bot}
|
||||
music_bot.registry = shared_reg
|
||||
extra_bot.registry = shared_reg
|
||||
assert _mod._current_meta(extra_bot) == ("Tool", "Lateralus")
|
||||
|
||||
def test_peer_bot_no_music(self):
|
||||
"""Returns empty when no bot has music state."""
|
||||
bot_a = _FakeBot()
|
||||
bot_b = _FakeBot()
|
||||
shared_reg = _FakeRegistry()
|
||||
shared_reg._bots = {"a": bot_a, "b": bot_b}
|
||||
bot_a.registry = shared_reg
|
||||
bot_b.registry = shared_reg
|
||||
assert _mod._current_meta(bot_a) == ("", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestFmtMatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFmtMatch:
|
||||
def test_float_score(self):
|
||||
assert _mod._fmt_match(0.85) == "85%"
|
||||
|
||||
def test_string_score(self):
|
||||
assert _mod._fmt_match("0.72") == "72%"
|
||||
|
||||
def test_one(self):
|
||||
assert _mod._fmt_match(1.0) == "100%"
|
||||
|
||||
def test_zero(self):
|
||||
assert _mod._fmt_match(0.0) == "0%"
|
||||
|
||||
def test_invalid(self):
|
||||
assert _mod._fmt_match("bad") == ""
|
||||
|
||||
def test_none(self):
|
||||
assert _mod._fmt_match(None) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCmdSimilar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSearchQueries:
|
||||
def test_track_results(self):
|
||||
similar = [
|
||||
{"name": "Track X", "artist": {"name": "Band A"}, "match": "0.9"},
|
||||
{"name": "Track Y", "artist": {"name": "Band B"}, "match": "0.7"},
|
||||
]
|
||||
result = _mod._search_queries(similar, [], [])
|
||||
assert result == ["Band A Track X", "Band B Track Y"]
|
||||
|
||||
def test_artist_results(self):
|
||||
artists = [{"name": "Deftones"}, {"name": "APC"}]
|
||||
result = _mod._search_queries([], artists, [])
|
||||
assert result == ["Deftones", "APC"]
|
||||
|
||||
def test_mb_results(self):
|
||||
mb = [{"artist": "MB Band", "title": "MB Song"}]
|
||||
result = _mod._search_queries([], [], mb)
|
||||
assert result == ["MB Band MB Song"]
|
||||
|
||||
def test_mixed_sources(self):
|
||||
"""Track results come first, then artist, then MB."""
|
||||
similar = [{"name": "T1", "artist": {"name": "A1"}}]
|
||||
artists = [{"name": "A2"}]
|
||||
mb = [{"artist": "MB", "title": "S1"}]
|
||||
result = _mod._search_queries(similar, artists, mb)
|
||||
assert result == ["A1 T1", "A2", "MB S1"]
|
||||
|
||||
def test_limit(self):
|
||||
artists = [{"name": f"Band {i}"} for i in range(20)]
|
||||
result = _mod._search_queries([], artists, [], limit=5)
|
||||
assert len(result) == 5
|
||||
|
||||
def test_skips_empty(self):
|
||||
similar = [{"name": "", "artist": {"name": ""}}]
|
||||
artists = [{"name": ""}]
|
||||
mb = [{"artist": "", "title": ""}]
|
||||
result = _mod._search_queries(similar, artists, mb)
|
||||
assert result == []
|
||||
|
||||
def test_empty_inputs(self):
|
||||
assert _mod._search_queries([], [], []) == []
|
||||
|
||||
|
||||
class TestCmdSimilar:
|
||||
def test_no_api_key_mb_list_fallback(self):
|
||||
"""No API key + list mode falls back to MusicBrainz for results."""
|
||||
bot = _FakeBot(api_key="")
|
||||
msg = _Msg(text="!similar list Tool")
|
||||
mb_picks = [{"artist": "MB Artist", "title": "MB Song"}]
|
||||
with patch.dict("os.environ", {}, clear=True), \
|
||||
patch("plugins._musicbrainz.mb_search_artist",
|
||||
return_value="mbid-123"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags",
|
||||
return_value=["rock", "metal"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("Similar to Tool" in r for r in bot.replied)
|
||||
assert any("MB Artist" in r for r in bot.replied)
|
||||
|
||||
def test_no_api_key_mb_no_results(self):
|
||||
"""No API key + MusicBrainz returns nothing shows 'no similar'."""
|
||||
bot = _FakeBot(api_key="")
|
||||
msg = _Msg(text="!similar Tool")
|
||||
with patch.dict("os.environ", {}, clear=True), \
|
||||
patch("plugins._musicbrainz.mb_search_artist",
|
||||
return_value=None):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("No similar artists" in r for r in bot.replied)
|
||||
|
||||
def test_no_artist_nothing_playing(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar")
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("Nothing playing" in r for r in bot.replied)
|
||||
|
||||
def test_list_artist_shows_similar(self):
|
||||
"""!similar list <artist> shows similar artists (display only)."""
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar list Tool")
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists",
|
||||
return_value=SIMILAR_ARTISTS_RESP["similarartists"]["artist"]):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("Similar to Tool" in r for r in bot.replied)
|
||||
assert any("Artist B" in r for r in bot.replied)
|
||||
|
||||
def test_list_track_level(self):
|
||||
"""!similar list with track results shows track similarity."""
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||
}
|
||||
msg = _Msg(text="!similar list")
|
||||
tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=tracks):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("Similar to Tool - Lateralus" in r for r in bot.replied)
|
||||
assert any("Track X" in r for r in bot.replied)
|
||||
|
||||
def test_list_falls_back_to_artist(self):
|
||||
"""!similar list falls back to artist similarity when no track results."""
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||
}
|
||||
msg = _Msg(text="!similar list")
|
||||
artists = SIMILAR_ARTISTS_RESP["similarartists"]["artist"]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("Similar to Tool" in r for r in bot.replied)
|
||||
|
||||
def test_no_similar_found(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar Obscure Band")
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists", return_value=[]):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("No similar artists" in r for r in bot.replied)
|
||||
|
||||
def test_list_match_score_displayed(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar list Tool")
|
||||
artists = [{"name": "Deftones", "match": "0.85"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("85%" in r for r in bot.replied)
|
||||
|
||||
def test_list_current_track_no_separator(self):
|
||||
"""Title without separator uses whole title as search artist."""
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Lateralus"),
|
||||
}
|
||||
msg = _Msg(text="!similar list")
|
||||
artists = [{"name": "APC", "match": "0.7"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("Similar to Lateralus" in r for r in bot.replied)
|
||||
|
||||
def test_builds_playlist(self):
|
||||
"""Default !similar builds playlist and starts playback."""
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar Tool")
|
||||
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod._ps.return_value = {
|
||||
"queue": [], "current": None, "task": None,
|
||||
}
|
||||
music_mod._fade_and_cancel = AsyncMock()
|
||||
music_mod._ensure_loop = MagicMock()
|
||||
bot.registry._modules["music"] = music_mod
|
||||
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||
patch.object(_mod, "_get_similar_artists", return_value=artists), \
|
||||
patch.object(_mod, "_resolve_playlist",
|
||||
return_value=fake_tracks):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
|
||||
music_mod._fade_and_cancel.assert_called_once()
|
||||
music_mod._ensure_loop.assert_called_once()
|
||||
ps = music_mod._ps(bot)
|
||||
assert ps["queue"] == fake_tracks
|
||||
assert any("Playing 1 similar" in r for r in bot.replied)
|
||||
|
||||
def test_builds_playlist_from_current_track(self):
|
||||
"""!similar with no args discovers from currently playing track."""
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||
}
|
||||
msg = _Msg(text="!similar")
|
||||
tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"]
|
||||
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod._ps.return_value = {
|
||||
"queue": [], "current": None, "task": None,
|
||||
}
|
||||
music_mod._fade_and_cancel = AsyncMock()
|
||||
music_mod._ensure_loop = MagicMock()
|
||||
bot.registry._modules["music"] = music_mod
|
||||
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \
|
||||
patch.object(_mod, "_resolve_playlist",
|
||||
return_value=fake_tracks):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
|
||||
assert any("Playing 1 similar" in r for r in bot.replied)
|
||||
assert any("Tool" in r for r in bot.replied)
|
||||
|
||||
def test_no_music_mod_falls_back_to_display(self):
|
||||
"""Without music plugin, !similar falls back to display mode."""
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar Tool")
|
||||
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
# Falls back to display since no music module registered
|
||||
assert any("Similar to Tool" in r for r in bot.replied)
|
||||
assert any("Deftones" in r for r in bot.replied)
|
||||
|
||||
def test_no_playable_tracks_resolved(self):
|
||||
"""Shows error when resolution returns empty."""
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar Tool")
|
||||
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod._ps.return_value = {"queue": [], "current": None}
|
||||
bot.registry._modules["music"] = music_mod
|
||||
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||
patch.object(_mod, "_get_similar_artists", return_value=artists), \
|
||||
patch.object(_mod, "_resolve_playlist",
|
||||
return_value=[]):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("No playable tracks" in r for r in bot.replied)
|
||||
|
||||
def test_mb_builds_playlist(self):
|
||||
"""MB fallback results build playlist in play mode."""
|
||||
bot = _FakeBot(api_key="")
|
||||
msg = _Msg(text="!similar Tool")
|
||||
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
|
||||
fake_tracks = [_FakeTrack(url="http://yt/1", title="MB Track")]
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod._ps.return_value = {
|
||||
"queue": [], "current": None, "task": None,
|
||||
}
|
||||
music_mod._fade_and_cancel = AsyncMock()
|
||||
music_mod._ensure_loop = MagicMock()
|
||||
bot.registry._modules["music"] = music_mod
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True), \
|
||||
patch("plugins._musicbrainz.mb_search_artist",
|
||||
return_value="mbid-123"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags",
|
||||
return_value=["rock"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks), \
|
||||
patch.object(_mod, "_resolve_playlist",
|
||||
return_value=fake_tracks):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("Playing 1 similar" in r for r in bot.replied)
|
||||
|
||||
def test_cross_bot_delegates_to_music_bot(self):
|
||||
"""When merlin runs !similar, playback starts on derp (music bot)."""
|
||||
# derp = music bot (has active music state)
|
||||
derp = _FakeBot(api_key="test-key", name="derp")
|
||||
derp._only_plugins = {"music", "voice"}
|
||||
derp._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||
"queue": [],
|
||||
}
|
||||
# merlin = calling bot (no music plugin)
|
||||
merlin = _FakeBot(api_key="test-key", name="merlin")
|
||||
shared_reg = _FakeRegistry()
|
||||
shared_reg._bots = {"derp": derp, "merlin": merlin}
|
||||
derp.registry = shared_reg
|
||||
merlin.registry = shared_reg
|
||||
|
||||
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod._ps.return_value = {
|
||||
"queue": [], "current": None, "task": None,
|
||||
}
|
||||
music_mod._fade_and_cancel = AsyncMock()
|
||||
music_mod._ensure_loop = MagicMock()
|
||||
shared_reg._modules["music"] = music_mod
|
||||
|
||||
msg = _Msg(text="!similar Tool")
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||
patch.object(_mod, "_get_similar_artists", return_value=artists), \
|
||||
patch.object(_mod, "_resolve_playlist",
|
||||
return_value=fake_tracks):
|
||||
asyncio.run(_mod.cmd_similar(merlin, msg))
|
||||
|
||||
# Music ops must target derp, not merlin
|
||||
music_mod._fade_and_cancel.assert_called_once_with(derp, duration=3.0)
|
||||
music_mod._ensure_loop.assert_called_once_with(derp, fade_in=True)
|
||||
music_mod._ps.assert_called_with(derp)
|
||||
# Reply still goes through merlin (the bot the user talked to)
|
||||
assert any("Playing 1 similar" in r for r in merlin.replied)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCmdTags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCmdTags:
|
||||
def test_no_api_key_mb_fallback(self):
|
||||
"""No API key falls back to MusicBrainz for tags."""
|
||||
bot = _FakeBot(api_key="")
|
||||
msg = _Msg(text="!tags Tool")
|
||||
with patch.dict("os.environ", {}, clear=True), \
|
||||
patch("plugins._musicbrainz.mb_search_artist",
|
||||
return_value="mbid-123"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags",
|
||||
return_value=["rock", "progressive metal", "art rock"]):
|
||||
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||
assert any("Tool:" in r for r in bot.replied)
|
||||
assert any("rock" in r for r in bot.replied)
|
||||
assert any("progressive metal" in r for r in bot.replied)
|
||||
|
||||
def test_no_api_key_mb_no_results(self):
|
||||
"""No API key + MusicBrainz returns nothing shows 'no tags'."""
|
||||
bot = _FakeBot(api_key="")
|
||||
msg = _Msg(text="!tags Obscure")
|
||||
with patch.dict("os.environ", {}, clear=True), \
|
||||
patch("plugins._musicbrainz.mb_search_artist",
|
||||
return_value=None):
|
||||
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||
assert any("No tags found" in r for r in bot.replied)
|
||||
|
||||
def test_no_artist_nothing_playing(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!tags")
|
||||
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||
assert any("Nothing playing" in r for r in bot.replied)
|
||||
|
||||
def test_shows_tags(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!tags Tool")
|
||||
tags = TOP_TAGS_RESP["toptags"]["tag"]
|
||||
with patch.object(_mod, "_get_top_tags", return_value=tags):
|
||||
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||
assert any("rock" in r for r in bot.replied)
|
||||
assert any("alternative" in r for r in bot.replied)
|
||||
assert any("Tool:" in r for r in bot.replied)
|
||||
|
||||
def test_no_tags_found(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!tags Obscure")
|
||||
with patch.object(_mod, "_get_top_tags", return_value=[]):
|
||||
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||
assert any("No tags found" in r for r in bot.replied)
|
||||
|
||||
def test_from_current_track(self):
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||
}
|
||||
msg = _Msg(text="!tags")
|
||||
tags = [{"name": "prog metal", "count": 100}]
|
||||
with patch.object(_mod, "_get_top_tags", return_value=tags):
|
||||
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||
assert any("Tool:" in r for r in bot.replied)
|
||||
assert any("prog metal" in r for r in bot.replied)
|
||||
|
||||
def test_from_current_no_separator(self):
|
||||
"""Uses full title as artist when no separator."""
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Lateralus"),
|
||||
}
|
||||
msg = _Msg(text="!tags")
|
||||
tags = [{"name": "rock", "count": 50}]
|
||||
with patch.object(_mod, "_get_top_tags", return_value=tags):
|
||||
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||
assert any("Lateralus:" in r for r in bot.replied)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMusicBot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMusicBot:
|
||||
def test_returns_self_when_active(self):
|
||||
"""Returns calling bot when it has active music state."""
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {"current": _FakeTrack(title="X"), "queue": []}
|
||||
assert _mod._music_bot(bot) is bot
|
||||
|
||||
def test_returns_peer_with_active_state(self):
|
||||
"""Returns peer bot that has music playing."""
|
||||
derp = _FakeBot(name="derp")
|
||||
derp._pstate["music"] = {"current": _FakeTrack(title="X"), "queue": []}
|
||||
merlin = _FakeBot(name="merlin")
|
||||
reg = _FakeRegistry()
|
||||
reg._bots = {"derp": derp, "merlin": merlin}
|
||||
derp.registry = reg
|
||||
merlin.registry = reg
|
||||
assert _mod._music_bot(merlin) is derp
|
||||
|
||||
def test_falls_back_to_only_plugins(self):
|
||||
"""Returns bot with only_plugins containing 'music' when no active state."""
|
||||
derp = _FakeBot(name="derp")
|
||||
derp._only_plugins = {"music", "voice"}
|
||||
merlin = _FakeBot(name="merlin")
|
||||
reg = _FakeRegistry()
|
||||
reg._bots = {"derp": derp, "merlin": merlin}
|
||||
derp.registry = reg
|
||||
merlin.registry = reg
|
||||
assert _mod._music_bot(merlin) is derp
|
||||
|
||||
def test_returns_self_as_last_resort(self):
|
||||
"""Returns calling bot when no peer has music state or filters."""
|
||||
bot = _FakeBot()
|
||||
assert _mod._music_bot(bot) is bot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestParseTitle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseTitle:
|
||||
def test_dash_separator(self):
|
||||
assert _mod._parse_title("Tool - Lateralus") == ("Tool", "Lateralus")
|
||||
|
||||
def test_double_dash(self):
|
||||
assert _mod._parse_title("Tool -- Lateralus") == ("Tool", "Lateralus")
|
||||
|
||||
def test_pipe_separator(self):
|
||||
assert _mod._parse_title("Tool | Lateralus") == ("Tool", "Lateralus")
|
||||
|
||||
def test_tilde_separator(self):
|
||||
assert _mod._parse_title("Tool ~ Lateralus") == ("Tool", "Lateralus")
|
||||
|
||||
def test_no_separator(self):
|
||||
assert _mod._parse_title("Lateralus") == ("", "Lateralus")
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _mod._parse_title("") == ("", "")
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert _mod._parse_title(" Tool - Lateralus ") == ("Tool", "Lateralus")
|
||||
|
||||
def test_first_separator_wins(self):
|
||||
"""Only the first matching separator is used."""
|
||||
assert _mod._parse_title("A - B - C") == ("A", "B - C")
|
||||
|
||||
def test_dash_priority_over_pipe(self):
|
||||
"""Dash separator is tried before pipe."""
|
||||
assert _mod._parse_title("A - B | C") == ("A", "B | C")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestDiscoverSimilar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscoverSimilar:
|
||||
def test_lastfm_path(self):
|
||||
"""Returns Last.fm result when API key + results available."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
tracks = [{"name": "Found", "artist": {"name": "Band"}, "match": "0.9"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=tracks):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result == ("Band", "Found")
|
||||
|
||||
def test_lastfm_empty_falls_to_musicbrainz(self):
|
||||
"""Falls back to MusicBrainz when Last.fm returns nothing."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
mb_picks = [{"artist": "MB Artist", "title": "MB Song"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result == ("MB Artist", "MB Song")
|
||||
|
||||
def test_no_api_key_uses_musicbrainz(self):
|
||||
"""Skips Last.fm when no API key, goes straight to MusicBrainz."""
|
||||
bot = _FakeBot(api_key="")
|
||||
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
|
||||
with patch.dict("os.environ", {}, clear=True), \
|
||||
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result == ("MB Band", "MB Track")
|
||||
|
||||
def test_both_fail_returns_none(self):
|
||||
"""Returns None when both Last.fm and MusicBrainz fail."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||
patch("plugins._musicbrainz.mb_search_artist", return_value=None):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_no_artist_returns_none(self):
|
||||
"""Returns None when title has no artist component."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Lateralus"),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_musicbrainz_import_error_handled(self):
|
||||
"""Gracefully handles import error for _musicbrainz module."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||
patch.dict("sys.modules", {"plugins._musicbrainz": None}):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_lastfm_exception_falls_to_musicbrainz(self):
|
||||
"""Last.fm exception triggers MusicBrainz fallback."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
mb_picks = [{"artist": "Fallback", "title": "Song"}]
|
||||
with patch.object(_mod, "_get_similar_tracks",
|
||||
side_effect=Exception("API down")), \
|
||||
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result == ("Fallback", "Song")
|
||||
|
||||
def test_lastfm_pick_missing_name_falls_to_musicbrainz(self):
|
||||
"""Falls to MB when Last.fm result has empty artist/title."""
|
||||
bot = _FakeBot(api_key="test-key")
|
||||
tracks = [{"name": "", "artist": {"name": ""}, "match": "0.9"}]
|
||||
mb_picks = [{"artist": "MB", "title": "Track"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \
|
||||
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks):
|
||||
result = asyncio.run(
|
||||
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||
)
|
||||
assert result == ("MB", "Track")
|
||||
536
tests/test_llm.py
Normal file
536
tests/test_llm.py
Normal file
@@ -0,0 +1,536 @@
|
||||
"""Tests for the OpenRouter LLM chat plugin."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from derp.irc import Message
|
||||
|
||||
# plugins/ is not a Python package -- load the module from file path
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"plugins.llm", Path(__file__).resolve().parent.parent / "plugins" / "llm.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_spec.name] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
from plugins.llm import ( # noqa: E402
|
||||
_COOLDOWN,
|
||||
_MAX_HISTORY,
|
||||
_MAX_REPLY_LEN,
|
||||
_check_cooldown,
|
||||
_extract_reply,
|
||||
_get_api_key,
|
||||
_get_model,
|
||||
_ps,
|
||||
_set_cooldown,
|
||||
_truncate,
|
||||
cmd_ask,
|
||||
cmd_chat,
|
||||
)
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
class _FakeState:
|
||||
"""In-memory stand-in for bot.state."""
|
||||
|
||||
def __init__(self):
|
||||
self._store: dict[str, dict[str, str]] = {}
|
||||
|
||||
def get(self, plugin: str, key: str, default: str | None = None) -> str | None:
|
||||
return self._store.get(plugin, {}).get(key, default)
|
||||
|
||||
def set(self, plugin: str, key: str, value: str) -> None:
|
||||
self._store.setdefault(plugin, {})[key] = value
|
||||
|
||||
def delete(self, plugin: str, key: str) -> bool:
|
||||
try:
|
||||
del self._store[plugin][key]
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def keys(self, plugin: str) -> list[str]:
|
||||
return sorted(self._store.get(plugin, {}).keys())
|
||||
|
||||
|
||||
class _FakeRegistry:
|
||||
"""Minimal registry stand-in."""
|
||||
|
||||
def __init__(self):
|
||||
self._modules: dict = {}
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
"""Minimal bot stand-in that captures sent/replied messages."""
|
||||
|
||||
def __init__(self, *, admin: bool = False, config: dict | None = None):
|
||||
self.sent: list[tuple[str, str]] = []
|
||||
self.actions: list[tuple[str, str]] = []
|
||||
self.replied: list[str] = []
|
||||
self.state = _FakeState()
|
||||
self._pstate: dict = {}
|
||||
self.registry = _FakeRegistry()
|
||||
self._admin = admin
|
||||
self.config = config or {}
|
||||
|
||||
async def send(self, target: str, text: str) -> None:
|
||||
self.sent.append((target, text))
|
||||
|
||||
async def action(self, target: str, text: str) -> None:
|
||||
self.actions.append((target, text))
|
||||
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
async def long_reply(self, message, lines, *, label: str = "") -> None:
|
||||
for line in lines:
|
||||
self.replied.append(line)
|
||||
|
||||
def _is_admin(self, message) -> bool:
|
||||
return self._admin
|
||||
|
||||
|
||||
def _msg(text: str, nick: str = "alice", target: str = "#test") -> Message:
|
||||
"""Create a channel PRIVMSG."""
|
||||
return Message(
|
||||
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
||||
command="PRIVMSG", params=[target, text], tags={},
|
||||
)
|
||||
|
||||
|
||||
def _pm(text: str, nick: str = "alice") -> Message:
|
||||
"""Create a private PRIVMSG."""
|
||||
return Message(
|
||||
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
||||
command="PRIVMSG", params=["botname", text], tags={},
|
||||
)
|
||||
|
||||
|
||||
def _api_response(content: str = "Hello!", reasoning: str = "") -> dict:
|
||||
"""Build a mock API response."""
|
||||
msg = {"role": "assistant", "content": content}
|
||||
if reasoning:
|
||||
msg["reasoning"] = reasoning
|
||||
return {"choices": [{"message": msg}]}
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
"""Mock HTTP response."""
|
||||
|
||||
def __init__(self, data: dict):
|
||||
self._data = json.dumps(data).encode()
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _clear(bot=None) -> None:
|
||||
"""Reset per-bot plugin state between tests."""
|
||||
if bot is None:
|
||||
return
|
||||
ps = _ps(bot)
|
||||
ps["histories"].clear()
|
||||
ps["cooldowns"].clear()
|
||||
ps["model"] = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTruncate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTruncate:
|
||||
def test_short_text_unchanged(self):
|
||||
assert _truncate("hello") == "hello"
|
||||
|
||||
def test_exact_length_unchanged(self):
|
||||
text = "a" * _MAX_REPLY_LEN
|
||||
assert _truncate(text) == text
|
||||
|
||||
def test_long_text_truncated(self):
|
||||
text = "a" * 600
|
||||
result = _truncate(text)
|
||||
assert len(result) == _MAX_REPLY_LEN
|
||||
assert result.endswith("...")
|
||||
|
||||
def test_custom_max(self):
|
||||
result = _truncate("abcdefghij", 7)
|
||||
assert result == "abcd..."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestExtractReply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractReply:
|
||||
def test_normal_content(self):
|
||||
data = _api_response(content="Hello world")
|
||||
assert _extract_reply(data) == "Hello world"
|
||||
|
||||
def test_empty_content_falls_back_to_reasoning(self):
|
||||
data = _api_response(content="", reasoning="Thinking about it")
|
||||
assert _extract_reply(data) == "Thinking about it"
|
||||
|
||||
def test_content_preferred_over_reasoning(self):
|
||||
data = _api_response(content="Answer", reasoning="Reasoning")
|
||||
assert _extract_reply(data) == "Answer"
|
||||
|
||||
def test_empty_choices(self):
|
||||
assert _extract_reply({"choices": []}) == ""
|
||||
|
||||
def test_no_choices(self):
|
||||
assert _extract_reply({}) == ""
|
||||
|
||||
def test_whitespace_content_falls_back(self):
|
||||
data = _api_response(content=" ", reasoning="Fallback")
|
||||
assert _extract_reply(data) == "Fallback"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGetApiKey
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetApiKey:
|
||||
def test_from_env(self):
|
||||
bot = _FakeBot()
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "env-key"}):
|
||||
assert _get_api_key(bot) == "env-key"
|
||||
|
||||
def test_from_config(self):
|
||||
bot = _FakeBot(config={"openrouter": {"api_key": "cfg-key"}})
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
import os
|
||||
os.environ.pop("OPENROUTER_API_KEY", None)
|
||||
assert _get_api_key(bot) == "cfg-key"
|
||||
|
||||
def test_env_takes_precedence(self):
|
||||
bot = _FakeBot(config={"openrouter": {"api_key": "cfg-key"}})
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "env-key"}):
|
||||
assert _get_api_key(bot) == "env-key"
|
||||
|
||||
def test_missing_returns_empty(self):
|
||||
bot = _FakeBot()
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
import os
|
||||
os.environ.pop("OPENROUTER_API_KEY", None)
|
||||
assert _get_api_key(bot) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGetModel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetModel:
|
||||
def test_default_model(self):
|
||||
bot = _FakeBot()
|
||||
assert _get_model(bot) == "openrouter/auto"
|
||||
|
||||
def test_from_config(self):
|
||||
bot = _FakeBot(config={"openrouter": {"model": "some/model"}})
|
||||
assert _get_model(bot) == "some/model"
|
||||
|
||||
def test_runtime_override(self):
|
||||
bot = _FakeBot(config={"openrouter": {"model": "some/model"}})
|
||||
_ps(bot)["model"] = "override/model"
|
||||
assert _get_model(bot) == "override/model"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCooldown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCooldown:
|
||||
def test_first_request_not_limited(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
assert _check_cooldown(bot, "alice") is False
|
||||
|
||||
def test_second_request_within_cooldown(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
_set_cooldown(bot, "alice")
|
||||
assert _check_cooldown(bot, "alice") is True
|
||||
|
||||
def test_different_users_independent(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
_set_cooldown(bot, "alice")
|
||||
assert _check_cooldown(bot, "bob") is False
|
||||
|
||||
def test_after_cooldown_passes(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
_set_cooldown(bot, "alice")
|
||||
# Simulate time passing
|
||||
_ps(bot)["cooldowns"]["alice"] = time.monotonic() - _COOLDOWN - 1
|
||||
assert _check_cooldown(bot, "alice") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCmdAsk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdAsk:
|
||||
def test_no_args(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
def test_empty_args(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask ")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
def test_no_api_key(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
import os
|
||||
os.environ.pop("OPENROUTER_API_KEY", None)
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask what is python")))
|
||||
assert "not configured" in bot.replied[0]
|
||||
|
||||
def test_success(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
resp = _FakeResp(_api_response(content="Python is a programming language."))
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask what is python")))
|
||||
|
||||
assert len(bot.replied) == 1
|
||||
assert "Python is a programming language" in bot.replied[0]
|
||||
|
||||
def test_api_error_429(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
err = urllib.error.HTTPError(
|
||||
"url", 429, "Too Many Requests", {}, None,
|
||||
)
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", side_effect=err):
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||
|
||||
assert "Rate limited" in bot.replied[0]
|
||||
|
||||
def test_api_error_500(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
err = urllib.error.HTTPError(
|
||||
"url", 500, "Internal Server Error", {}, None,
|
||||
)
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", side_effect=err):
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||
|
||||
assert "API error" in bot.replied[0]
|
||||
assert "500" in bot.replied[0]
|
||||
|
||||
def test_connection_error(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", side_effect=ConnectionError("fail")):
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||
|
||||
assert "Request failed" in bot.replied[0]
|
||||
|
||||
def test_empty_response(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
resp = _FakeResp({"choices": []})
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||
|
||||
assert "No response" in bot.replied[0]
|
||||
|
||||
def test_cooldown(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
resp = _FakeResp(_api_response(content="Hello!"))
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask first")))
|
||||
bot.replied.clear()
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask second")))
|
||||
|
||||
assert "Cooldown" in bot.replied[0]
|
||||
|
||||
def test_response_truncation(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
long_text = "a" * 600
|
||||
resp = _FakeResp(_api_response(content=long_text))
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||
|
||||
assert len(bot.replied[0]) == _MAX_REPLY_LEN
|
||||
assert bot.replied[0].endswith("...")
|
||||
|
||||
def test_reasoning_model_fallback(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
resp = _FakeResp(_api_response(content="", reasoning="Deep thought"))
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask meaning of life")))
|
||||
|
||||
assert "Deep thought" in bot.replied[0]
|
||||
|
||||
def test_multiline_uses_long_reply(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
resp = _FakeResp(_api_response(content="Line one\nLine two\nLine three"))
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||
|
||||
assert len(bot.replied) == 3
|
||||
assert bot.replied[0] == "Line one"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCmdChat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdChat:
|
||||
def test_no_args(self):
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
def test_chat_with_history(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
resp1 = _FakeResp(_api_response(content="I am an assistant."))
|
||||
resp2 = _FakeResp(_api_response(content="You asked who I am."))
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", return_value=resp1):
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat who are you")))
|
||||
# Clear cooldown for second request
|
||||
_ps(bot)["cooldowns"].clear()
|
||||
with patch.object(_mod, "_urlopen", return_value=resp2) as mock_url:
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat what did I ask")))
|
||||
# Verify the history was sent with the second request
|
||||
call_args = mock_url.call_args
|
||||
req = call_args[0][0]
|
||||
body = json.loads(req.data)
|
||||
# System + user1 + assistant1 + user2 = 4 messages
|
||||
assert len(body["messages"]) == 4
|
||||
assert body["messages"][1]["content"] == "who are you"
|
||||
assert body["messages"][2]["content"] == "I am an assistant."
|
||||
assert body["messages"][3]["content"] == "what did I ask"
|
||||
|
||||
assert "I am an assistant" in bot.replied[0]
|
||||
assert "You asked who I am" in bot.replied[1]
|
||||
|
||||
def test_chat_clear(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
# Pre-populate history
|
||||
_ps(bot)["histories"]["alice"] = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat clear")))
|
||||
assert "cleared" in bot.replied[0].lower()
|
||||
assert "alice" not in _ps(bot)["histories"]
|
||||
|
||||
def test_chat_cooldown(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
resp = _FakeResp(_api_response(content="Hello!"))
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat first")))
|
||||
bot.replied.clear()
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat second")))
|
||||
|
||||
assert "Cooldown" in bot.replied[0]
|
||||
|
||||
def test_chat_model_show(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat model")))
|
||||
assert "openrouter/auto" in bot.replied[0]
|
||||
|
||||
def test_chat_model_switch(self):
|
||||
bot = _FakeBot(admin=True)
|
||||
_clear(bot)
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat model meta-llama/llama-3.3-70b-instruct:free")))
|
||||
assert "Model set to" in bot.replied[0]
|
||||
assert _ps(bot)["model"] == "meta-llama/llama-3.3-70b-instruct:free"
|
||||
|
||||
def test_chat_models_list(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat models")))
|
||||
assert len(bot.replied) >= 3
|
||||
assert any("openrouter/auto" in r for r in bot.replied)
|
||||
|
||||
def test_chat_no_api_key(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
import os
|
||||
os.environ.pop("OPENROUTER_API_KEY", None)
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat hello")))
|
||||
assert "not configured" in bot.replied[0]
|
||||
|
||||
def test_history_cap(self):
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
# Pre-populate with MAX_HISTORY messages
|
||||
ps = _ps(bot)
|
||||
ps["histories"]["alice"] = [
|
||||
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg{i}"}
|
||||
for i in range(_MAX_HISTORY)
|
||||
]
|
||||
|
||||
resp = _FakeResp(_api_response(content="Latest reply"))
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat overflow")))
|
||||
|
||||
history = ps["histories"]["alice"]
|
||||
# History should be capped at MAX_HISTORY
|
||||
assert len(history) <= _MAX_HISTORY
|
||||
|
||||
def test_chat_api_error_removes_user_msg(self):
|
||||
"""On API failure, the user message should be removed from history."""
|
||||
bot = _FakeBot()
|
||||
_clear(bot)
|
||||
err = urllib.error.HTTPError(
|
||||
"url", 500, "Internal Server Error", {}, None,
|
||||
)
|
||||
|
||||
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||
with patch.object(_mod, "_urlopen", side_effect=err):
|
||||
asyncio.run(cmd_chat(bot, _msg("!chat hello")))
|
||||
|
||||
ps = _ps(bot)
|
||||
# History should be empty -- user msg was removed on failure
|
||||
assert len(ps["histories"].get("alice", [])) == 0
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from derp.mumble import (
|
||||
MumbleBot,
|
||||
@@ -645,3 +645,95 @@ class TestPcmRamping:
|
||||
assert samples[1] == 7500
|
||||
assert samples[2] == 5000
|
||||
assert samples[3] == 2500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestIsAudioReady
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsAudioReady:
|
||||
def test_no_mumble_object(self):
|
||||
bot = _make_bot()
|
||||
bot._mumble = None
|
||||
assert bot._is_audio_ready() is False
|
||||
|
||||
def test_no_sound_output(self):
|
||||
bot = _make_bot()
|
||||
bot._mumble = MagicMock()
|
||||
bot._mumble.sound_output = None
|
||||
assert bot._is_audio_ready() is False
|
||||
|
||||
def test_no_encoder(self):
|
||||
bot = _make_bot()
|
||||
bot._mumble = MagicMock()
|
||||
bot._mumble.sound_output.encoder = None
|
||||
assert bot._is_audio_ready() is False
|
||||
|
||||
def test_ready(self):
|
||||
bot = _make_bot()
|
||||
bot._mumble = MagicMock()
|
||||
bot._mumble.sound_output.encoder = MagicMock()
|
||||
assert bot._is_audio_ready() is True
|
||||
|
||||
def test_attribute_error_handled(self):
|
||||
bot = _make_bot()
|
||||
bot._mumble = MagicMock()
|
||||
del bot._mumble.sound_output
|
||||
assert bot._is_audio_ready() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestStreamAudioDisconnect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamAudioDisconnect:
|
||||
def test_stream_survives_disconnect(self):
|
||||
"""stream_audio keeps ffmpeg alive when connection drops mid-stream."""
|
||||
bot = _make_bot()
|
||||
bot._mumble = MagicMock()
|
||||
bot._mumble.sound_output.encoder = MagicMock()
|
||||
bot._mumble.sound_output.get_buffer_size.return_value = 0.0
|
||||
|
||||
frame = b"\x00" * 1920
|
||||
# Track which frame we're on; disconnect after frame 3
|
||||
frame_count = [0]
|
||||
connected = [True]
|
||||
|
||||
async def _fake_read(n):
|
||||
if frame_count[0] < 5:
|
||||
frame_count[0] += 1
|
||||
# Disconnect after 3 frames are read
|
||||
if frame_count[0] > 3:
|
||||
connected[0] = False
|
||||
return frame
|
||||
return b""
|
||||
|
||||
def _ready():
|
||||
return connected[0]
|
||||
|
||||
proc = MagicMock()
|
||||
proc.stdout.read = _fake_read
|
||||
proc.stderr.read = AsyncMock(return_value=b"")
|
||||
proc.wait = AsyncMock(return_value=0)
|
||||
proc.kill = MagicMock()
|
||||
|
||||
progress = [0]
|
||||
|
||||
async def _run():
|
||||
with patch.object(bot, "_is_audio_ready", side_effect=_ready):
|
||||
with patch("asyncio.create_subprocess_exec",
|
||||
return_value=proc):
|
||||
await bot.stream_audio(
|
||||
"http://example.com/audio",
|
||||
volume=0.5,
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
# All 5 frames were read (progress tracks all, connected or not)
|
||||
assert progress[0] == 5
|
||||
# Only 3 frames were fed to sound_output (the connected ones)
|
||||
assert bot._mumble.sound_output.add_sound.call_count == 3
|
||||
|
||||
498
tests/test_mumble_admin.py
Normal file
498
tests/test_mumble_admin.py
Normal file
@@ -0,0 +1,498 @@
|
||||
"""Tests for the mumble_admin plugin."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from dataclasses import dataclass, field
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# -- Load plugin module directly ---------------------------------------------
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"mumble_admin", "plugins/mumble_admin.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
cmd_mu = _mod.cmd_mu
|
||||
_find_user = _mod._find_user
|
||||
_find_channel = _mod._find_channel
|
||||
_channel_name = _mod._channel_name
|
||||
|
||||
|
||||
# -- Fakes -------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeMessage:
|
||||
text: str = ""
|
||||
nick: str = "admin"
|
||||
prefix: str = "admin"
|
||||
target: str = "0"
|
||||
is_channel: bool = True
|
||||
params: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class _FakeRegistry:
|
||||
_bots: dict = field(default_factory=dict)
|
||||
|
||||
def __init__(self):
|
||||
self._bots = {}
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self, users=None, channels=None):
|
||||
self.registry = _FakeRegistry()
|
||||
self._mumble = MagicMock()
|
||||
if users is not None:
|
||||
self._mumble.users = users
|
||||
else:
|
||||
self._mumble.users = {}
|
||||
if channels is not None:
|
||||
self._mumble.channels = channels
|
||||
self._replies: list[str] = []
|
||||
|
||||
async def reply(self, message, text):
|
||||
self._replies.append(text)
|
||||
|
||||
async def send(self, target, text):
|
||||
self._replies.append(text)
|
||||
|
||||
|
||||
def _make_user(name, channel_id=0, mute=False, deaf=False,
|
||||
self_mute=False, self_deaf=False):
|
||||
"""Create a fake pymumble user (dict with methods)."""
|
||||
u = MagicMock()
|
||||
u.__getitem__ = lambda s, k: {
|
||||
"name": name,
|
||||
"channel_id": channel_id,
|
||||
"mute": mute,
|
||||
"deaf": deaf,
|
||||
"self_mute": self_mute,
|
||||
"self_deaf": self_deaf,
|
||||
}[k]
|
||||
u.get = lambda k, d=None: {
|
||||
"name": name,
|
||||
"channel_id": channel_id,
|
||||
"mute": mute,
|
||||
"deaf": deaf,
|
||||
"self_mute": self_mute,
|
||||
"self_deaf": self_deaf,
|
||||
}.get(k, d)
|
||||
return u
|
||||
|
||||
|
||||
def _make_channel(name, channel_id=0, parent=0):
|
||||
"""Create a fake pymumble channel (dict with methods)."""
|
||||
c = MagicMock()
|
||||
c.__getitem__ = lambda s, k: {
|
||||
"name": name,
|
||||
"channel_id": channel_id,
|
||||
"parent": parent,
|
||||
}[k]
|
||||
c.get = lambda k, d=None: {
|
||||
"name": name,
|
||||
"channel_id": channel_id,
|
||||
"parent": parent,
|
||||
}.get(k, d)
|
||||
return c
|
||||
|
||||
|
||||
# -- TestFindUser ------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindUser:
|
||||
def test_case_insensitive(self):
|
||||
alice = _make_user("Alice")
|
||||
bot = _FakeBot(users={1: alice})
|
||||
assert _find_user(bot, "alice") is alice
|
||||
assert _find_user(bot, "ALICE") is alice
|
||||
assert _find_user(bot, "Alice") is alice
|
||||
|
||||
def test_not_found(self):
|
||||
bot = _FakeBot(users={1: _make_user("Alice")})
|
||||
assert _find_user(bot, "Bob") is None
|
||||
|
||||
def test_no_mumble(self):
|
||||
bot = _FakeBot()
|
||||
bot._mumble = None
|
||||
assert _find_user(bot, "anyone") is None
|
||||
|
||||
|
||||
# -- TestFindChannel ---------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindChannel:
|
||||
def test_case_insensitive(self):
|
||||
lobby = _make_channel("Lobby", channel_id=0)
|
||||
bot = _FakeBot(channels={0: lobby})
|
||||
assert _find_channel(bot, "lobby") is lobby
|
||||
assert _find_channel(bot, "LOBBY") is lobby
|
||||
|
||||
def test_not_found(self):
|
||||
bot = _FakeBot(channels={0: _make_channel("Lobby")})
|
||||
assert _find_channel(bot, "AFK") is None
|
||||
|
||||
def test_no_mumble(self):
|
||||
bot = _FakeBot()
|
||||
bot._mumble = None
|
||||
assert _find_channel(bot, "any") is None
|
||||
|
||||
|
||||
# -- TestChannelName ---------------------------------------------------------
|
||||
|
||||
|
||||
class TestChannelName:
|
||||
def test_resolves(self):
|
||||
lobby = _make_channel("Lobby", channel_id=0)
|
||||
bot = _FakeBot(channels={0: lobby})
|
||||
assert _channel_name(bot, 0) == "Lobby"
|
||||
|
||||
def test_missing_returns_id(self):
|
||||
bot = _FakeBot(channels={})
|
||||
assert _channel_name(bot, 42) == "42"
|
||||
|
||||
def test_no_mumble(self):
|
||||
bot = _FakeBot()
|
||||
bot._mumble = None
|
||||
assert _channel_name(bot, 5) == "5"
|
||||
|
||||
|
||||
# -- TestDispatch ------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDispatch:
|
||||
def test_no_args_shows_usage(self):
|
||||
bot = _FakeBot()
|
||||
msg = _FakeMessage(text="!mu")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert len(bot._replies) == 1
|
||||
assert "Usage" in bot._replies[0]
|
||||
|
||||
def test_unknown_sub_shows_usage(self):
|
||||
bot = _FakeBot()
|
||||
msg = _FakeMessage(text="!mu bogus")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "Usage" in bot._replies[0]
|
||||
|
||||
def test_valid_sub_routes(self):
|
||||
alice = _make_user("Alice")
|
||||
bot = _FakeBot(users={1: alice})
|
||||
msg = _FakeMessage(text="!mu kick Alice")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
alice.kick.assert_called_once_with("")
|
||||
assert "Kicked" in bot._replies[0]
|
||||
|
||||
|
||||
# -- TestKick ----------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKick:
|
||||
def test_kick_user(self):
|
||||
alice = _make_user("Alice")
|
||||
bot = _FakeBot(users={1: alice})
|
||||
msg = _FakeMessage(text="!mu kick Alice")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
alice.kick.assert_called_once_with("")
|
||||
assert "Kicked Alice" in bot._replies[0]
|
||||
|
||||
def test_kick_with_reason(self):
|
||||
alice = _make_user("Alice")
|
||||
bot = _FakeBot(users={1: alice})
|
||||
msg = _FakeMessage(text="!mu kick Alice being rude")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
alice.kick.assert_called_once_with("being rude")
|
||||
|
||||
def test_kick_user_not_found(self):
|
||||
bot = _FakeBot(users={})
|
||||
msg = _FakeMessage(text="!mu kick Ghost")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "not found" in bot._replies[0].lower()
|
||||
|
||||
def test_kick_no_args(self):
|
||||
bot = _FakeBot()
|
||||
msg = _FakeMessage(text="!mu kick")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "Usage" in bot._replies[0]
|
||||
|
||||
|
||||
# -- TestBan -----------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBan:
|
||||
def test_ban_user(self):
|
||||
bob = _make_user("Bob")
|
||||
bot = _FakeBot(users={1: bob})
|
||||
msg = _FakeMessage(text="!mu ban Bob")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
bob.ban.assert_called_once_with("")
|
||||
assert "Banned Bob" in bot._replies[0]
|
||||
|
||||
def test_ban_with_reason(self):
|
||||
bob = _make_user("Bob")
|
||||
bot = _FakeBot(users={1: bob})
|
||||
msg = _FakeMessage(text="!mu ban Bob spamming")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
bob.ban.assert_called_once_with("spamming")
|
||||
|
||||
def test_ban_user_not_found(self):
|
||||
bot = _FakeBot(users={})
|
||||
msg = _FakeMessage(text="!mu ban Ghost")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "not found" in bot._replies[0].lower()
|
||||
|
||||
|
||||
# -- TestMuteUnmute ----------------------------------------------------------
|
||||
|
||||
|
||||
class TestMuteUnmute:
|
||||
def test_mute(self):
|
||||
alice = _make_user("Alice")
|
||||
bot = _FakeBot(users={1: alice})
|
||||
msg = _FakeMessage(text="!mu mute Alice")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
alice.mute.assert_called_once()
|
||||
assert "Muted" in bot._replies[0]
|
||||
|
||||
def test_unmute(self):
|
||||
alice = _make_user("Alice", mute=True)
|
||||
bot = _FakeBot(users={1: alice})
|
||||
msg = _FakeMessage(text="!mu unmute Alice")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
alice.unmute.assert_called_once()
|
||||
assert "Unmuted" in bot._replies[0]
|
||||
|
||||
def test_mute_not_found(self):
|
||||
bot = _FakeBot(users={})
|
||||
msg = _FakeMessage(text="!mu mute Nobody")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "not found" in bot._replies[0].lower()
|
||||
|
||||
def test_unmute_not_found(self):
|
||||
bot = _FakeBot(users={})
|
||||
msg = _FakeMessage(text="!mu unmute Nobody")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "not found" in bot._replies[0].lower()
|
||||
|
||||
|
||||
# -- TestDeafenUndeafen ------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeafenUndeafen:
|
||||
def test_deafen(self):
|
||||
alice = _make_user("Alice")
|
||||
bot = _FakeBot(users={1: alice})
|
||||
msg = _FakeMessage(text="!mu deafen Alice")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
alice.deafen.assert_called_once()
|
||||
assert "Deafened" in bot._replies[0]
|
||||
|
||||
def test_undeafen(self):
|
||||
alice = _make_user("Alice", deaf=True)
|
||||
bot = _FakeBot(users={1: alice})
|
||||
msg = _FakeMessage(text="!mu undeafen Alice")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
alice.undeafen.assert_called_once()
|
||||
assert "Undeafened" in bot._replies[0]
|
||||
|
||||
def test_deafen_not_found(self):
|
||||
bot = _FakeBot(users={})
|
||||
msg = _FakeMessage(text="!mu deafen Nobody")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "not found" in bot._replies[0].lower()
|
||||
|
||||
def test_undeafen_not_found(self):
|
||||
bot = _FakeBot(users={})
|
||||
msg = _FakeMessage(text="!mu undeafen Nobody")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "not found" in bot._replies[0].lower()
|
||||
|
||||
|
||||
# -- TestMove ----------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMove:
|
||||
def test_move_user(self):
|
||||
alice = _make_user("Alice", channel_id=0)
|
||||
afk = _make_channel("AFK", channel_id=5)
|
||||
bot = _FakeBot(users={1: alice}, channels={0: _make_channel("Root"), 5: afk})
|
||||
msg = _FakeMessage(text="!mu move Alice AFK")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
alice.move_in.assert_called_once_with(5)
|
||||
assert "Moved Alice to AFK" in bot._replies[0]
|
||||
|
||||
def test_move_user_not_found(self):
|
||||
bot = _FakeBot(users={}, channels={5: _make_channel("AFK", channel_id=5)})
|
||||
msg = _FakeMessage(text="!mu move Ghost AFK")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "user not found" in bot._replies[0].lower()
|
||||
|
||||
def test_move_channel_not_found(self):
|
||||
alice = _make_user("Alice")
|
||||
bot = _FakeBot(users={1: alice}, channels={})
|
||||
msg = _FakeMessage(text="!mu move Alice Nowhere")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "channel not found" in bot._replies[0].lower()
|
||||
|
||||
def test_move_missing_args(self):
|
||||
bot = _FakeBot()
|
||||
msg = _FakeMessage(text="!mu move Alice")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "Usage" in bot._replies[0]
|
||||
|
||||
|
||||
# -- TestUsers ---------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUsers:
|
||||
def test_list_users(self):
|
||||
alice = _make_user("Alice", channel_id=0)
|
||||
bob = _make_user("Bob", channel_id=0, self_mute=True)
|
||||
lobby = _make_channel("Lobby", channel_id=0)
|
||||
bot = _FakeBot(users={1: alice, 2: bob}, channels={0: lobby})
|
||||
msg = _FakeMessage(text="!mu users")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
reply = bot._replies[0]
|
||||
assert "2 user(s)" in reply
|
||||
assert "Alice" in reply
|
||||
assert "Bob" in reply
|
||||
assert "muted" in reply
|
||||
|
||||
def test_list_with_bots(self):
|
||||
alice = _make_user("Alice", channel_id=0)
|
||||
derp = _make_user("derp", channel_id=0)
|
||||
lobby = _make_channel("Lobby", channel_id=0)
|
||||
bot = _FakeBot(users={1: alice, 2: derp}, channels={0: lobby})
|
||||
bot.registry._bots = {"derp": MagicMock()}
|
||||
msg = _FakeMessage(text="!mu users")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
reply = bot._replies[0]
|
||||
assert "bot" in reply
|
||||
assert "2 user(s)" in reply
|
||||
|
||||
def test_deaf_flag(self):
|
||||
alice = _make_user("Alice", channel_id=0, self_deaf=True)
|
||||
lobby = _make_channel("Lobby", channel_id=0)
|
||||
bot = _FakeBot(users={1: alice}, channels={0: lobby})
|
||||
msg = _FakeMessage(text="!mu users")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "deaf" in bot._replies[0]
|
||||
|
||||
|
||||
# -- TestChannels ------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChannels:
|
||||
def test_list_channels(self):
|
||||
lobby = _make_channel("Lobby", channel_id=0)
|
||||
afk = _make_channel("AFK", channel_id=1)
|
||||
alice = _make_user("Alice", channel_id=0)
|
||||
bot = _FakeBot(users={1: alice}, channels={0: lobby, 1: afk})
|
||||
msg = _FakeMessage(text="!mu channels")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
reply = bot._replies[0]
|
||||
assert "Lobby (1)" in reply
|
||||
assert "AFK (0)" in reply
|
||||
|
||||
|
||||
# -- TestMkchan --------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMkchan:
|
||||
def test_create_channel(self):
|
||||
bot = _FakeBot()
|
||||
msg = _FakeMessage(text="!mu mkchan Gaming")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
bot._mumble.channels.new_channel.assert_called_once_with(
|
||||
0, "Gaming", temporary=False,
|
||||
)
|
||||
assert "Created" in bot._replies[0]
|
||||
|
||||
def test_create_temp_channel(self):
|
||||
bot = _FakeBot()
|
||||
msg = _FakeMessage(text="!mu mkchan Gaming temp")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
bot._mumble.channels.new_channel.assert_called_once_with(
|
||||
0, "Gaming", temporary=True,
|
||||
)
|
||||
assert "temporary" in bot._replies[0]
|
||||
|
||||
def test_missing_name(self):
|
||||
bot = _FakeBot()
|
||||
msg = _FakeMessage(text="!mu mkchan")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "Usage" in bot._replies[0]
|
||||
|
||||
|
||||
# -- TestRmchan --------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRmchan:
|
||||
def test_remove_channel(self):
|
||||
afk = _make_channel("AFK", channel_id=5)
|
||||
bot = _FakeBot(channels={5: afk})
|
||||
msg = _FakeMessage(text="!mu rmchan AFK")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
afk.remove.assert_called_once()
|
||||
assert "Removed" in bot._replies[0]
|
||||
|
||||
def test_channel_not_found(self):
|
||||
bot = _FakeBot(channels={})
|
||||
msg = _FakeMessage(text="!mu rmchan Nowhere")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "not found" in bot._replies[0].lower()
|
||||
|
||||
def test_missing_args(self):
|
||||
bot = _FakeBot()
|
||||
msg = _FakeMessage(text="!mu rmchan")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "Usage" in bot._replies[0]
|
||||
|
||||
|
||||
# -- TestRename --------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRename:
|
||||
def test_rename_channel(self):
|
||||
afk = _make_channel("AFK", channel_id=5)
|
||||
bot = _FakeBot(channels={5: afk})
|
||||
msg = _FakeMessage(text="!mu rename AFK Chill")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
afk.rename_channel.assert_called_once_with("Chill")
|
||||
assert "Renamed" in bot._replies[0]
|
||||
|
||||
def test_channel_not_found(self):
|
||||
bot = _FakeBot(channels={})
|
||||
msg = _FakeMessage(text="!mu rename Nowhere New")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "not found" in bot._replies[0].lower()
|
||||
|
||||
def test_missing_args(self):
|
||||
bot = _FakeBot()
|
||||
msg = _FakeMessage(text="!mu rename AFK")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "Usage" in bot._replies[0]
|
||||
|
||||
|
||||
# -- TestDesc ----------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDesc:
|
||||
def test_set_description(self):
|
||||
afk = _make_channel("AFK", channel_id=5)
|
||||
bot = _FakeBot(channels={5: afk})
|
||||
msg = _FakeMessage(text="!mu desc AFK Away from keyboard")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
afk.set_channel_description.assert_called_once_with("Away from keyboard")
|
||||
assert "description" in bot._replies[0].lower()
|
||||
|
||||
def test_channel_not_found(self):
|
||||
bot = _FakeBot(channels={})
|
||||
msg = _FakeMessage(text="!mu desc Nowhere some text")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "not found" in bot._replies[0].lower()
|
||||
|
||||
def test_missing_args(self):
|
||||
bot = _FakeBot()
|
||||
msg = _FakeMessage(text="!mu desc AFK")
|
||||
asyncio.run(cmd_mu(bot, msg))
|
||||
assert "Usage" in bot._replies[0]
|
||||
2157
tests/test_music.py
2157
tests/test_music.py
File diff suppressed because it is too large
Load Diff
310
tests/test_musicbrainz.py
Normal file
310
tests/test_musicbrainz.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""Tests for the MusicBrainz API helper module."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# -- Load module directly ----------------------------------------------------
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"_musicbrainz", "plugins/_musicbrainz.py",
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["_musicbrainz"] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_resp(data: dict) -> MagicMock:
|
||||
"""Create a fake HTTP response with JSON body."""
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps(data).encode()
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMbRequest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMbRequest:
|
||||
def setup_method(self):
|
||||
_mod._last_request = 0.0
|
||||
|
||||
def test_returns_parsed_json(self):
|
||||
resp = _make_resp({"status": "ok"})
|
||||
with patch("derp.http.urlopen", return_value=resp):
|
||||
result = _mod._mb_request("artist", {"query": "Tool"})
|
||||
assert result == {"status": "ok"}
|
||||
|
||||
def test_rate_delay_enforced(self):
|
||||
"""Second call within rate interval triggers sleep."""
|
||||
_mod._last_request = time.monotonic()
|
||||
resp = _make_resp({})
|
||||
slept = []
|
||||
with patch("derp.http.urlopen", return_value=resp), \
|
||||
patch.object(_mod.time, "sleep", side_effect=slept.append), \
|
||||
patch.object(_mod.time, "monotonic", return_value=_mod._last_request + 0.2):
|
||||
_mod._mb_request("artist", {"query": "X"})
|
||||
assert len(slept) == 1
|
||||
assert slept[0] > 0
|
||||
|
||||
def test_no_delay_when_interval_elapsed(self):
|
||||
"""No sleep when enough time has passed since last request."""
|
||||
_mod._last_request = time.monotonic() - 5.0
|
||||
resp = _make_resp({})
|
||||
with patch("derp.http.urlopen", return_value=resp), \
|
||||
patch.object(_mod.time, "sleep") as mock_sleep:
|
||||
_mod._mb_request("artist", {"query": "X"})
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
def test_returns_empty_on_error(self):
|
||||
with patch("derp.http.urlopen", side_effect=ConnectionError("fail")):
|
||||
result = _mod._mb_request("artist", {"query": "X"})
|
||||
assert result == {}
|
||||
|
||||
def test_updates_last_request_on_success(self):
|
||||
_mod._last_request = 0.0
|
||||
resp = _make_resp({})
|
||||
with patch("derp.http.urlopen", return_value=resp):
|
||||
_mod._mb_request("test")
|
||||
assert _mod._last_request > 0
|
||||
|
||||
def test_updates_last_request_on_error(self):
|
||||
_mod._last_request = 0.0
|
||||
with patch("derp.http.urlopen", side_effect=Exception("boom")):
|
||||
_mod._mb_request("test")
|
||||
assert _mod._last_request > 0
|
||||
|
||||
def test_none_params(self):
|
||||
"""Handles None params without error."""
|
||||
resp = _make_resp({"ok": True})
|
||||
with patch("derp.http.urlopen", return_value=resp):
|
||||
result = _mod._mb_request("test", None)
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMbSearchArtist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMbSearchArtist:
|
||||
def setup_method(self):
|
||||
_mod._last_request = 0.0
|
||||
|
||||
def test_returns_mbid(self):
|
||||
data = {"artists": [{"id": "abc-123", "name": "Tool", "score": 100}]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
result = _mod.mb_search_artist("Tool")
|
||||
assert result == "abc-123"
|
||||
|
||||
def test_returns_none_no_results(self):
|
||||
with patch.object(_mod, "_mb_request", return_value={"artists": []}):
|
||||
assert _mod.mb_search_artist("Unknown") is None
|
||||
|
||||
def test_returns_none_on_empty_response(self):
|
||||
with patch.object(_mod, "_mb_request", return_value={}):
|
||||
assert _mod.mb_search_artist("X") is None
|
||||
|
||||
def test_returns_none_low_score(self):
|
||||
"""Rejects matches with score below 50."""
|
||||
data = {"artists": [{"id": "low", "name": "Mismatch", "score": 30}]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
assert _mod.mb_search_artist("Tool") is None
|
||||
|
||||
def test_returns_none_on_error(self):
|
||||
with patch.object(_mod, "_mb_request", return_value={}):
|
||||
assert _mod.mb_search_artist("Error") is None
|
||||
|
||||
def test_accepts_high_score(self):
|
||||
data = {"artists": [{"id": "abc", "name": "Tool", "score": 85}]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
assert _mod.mb_search_artist("Tool") == "abc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMbArtistTags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMbArtistTags:
|
||||
def setup_method(self):
|
||||
_mod._last_request = 0.0
|
||||
|
||||
def test_returns_sorted_top_5(self):
|
||||
data = {"tags": [
|
||||
{"name": "rock", "count": 50},
|
||||
{"name": "metal", "count": 100},
|
||||
{"name": "prog", "count": 80},
|
||||
{"name": "alternative", "count": 60},
|
||||
{"name": "hard rock", "count": 40},
|
||||
{"name": "grunge", "count": 30},
|
||||
]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
result = _mod.mb_artist_tags("mbid-123")
|
||||
assert len(result) == 5
|
||||
assert result[0] == "metal"
|
||||
assert result[1] == "prog"
|
||||
assert result[2] == "alternative"
|
||||
assert result[3] == "rock"
|
||||
assert result[4] == "hard rock"
|
||||
|
||||
def test_empty_tags(self):
|
||||
with patch.object(_mod, "_mb_request", return_value={"tags": []}):
|
||||
assert _mod.mb_artist_tags("mbid") == []
|
||||
|
||||
def test_no_tags_key(self):
|
||||
with patch.object(_mod, "_mb_request", return_value={}):
|
||||
assert _mod.mb_artist_tags("mbid") == []
|
||||
|
||||
def test_skips_nameless_tags(self):
|
||||
data = {"tags": [
|
||||
{"name": "rock", "count": 50},
|
||||
{"count": 100}, # no name
|
||||
{"name": "", "count": 80}, # empty name
|
||||
]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
result = _mod.mb_artist_tags("mbid")
|
||||
assert result == ["rock"]
|
||||
|
||||
def test_fewer_than_5_tags(self):
|
||||
data = {"tags": [{"name": "jazz", "count": 10}]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
result = _mod.mb_artist_tags("mbid")
|
||||
assert result == ["jazz"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMbFindSimilarRecordings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMbFindSimilarRecordings:
|
||||
def setup_method(self):
|
||||
_mod._last_request = 0.0
|
||||
|
||||
def test_returns_dicts(self):
|
||||
data = {"recordings": [
|
||||
{
|
||||
"title": "Song A",
|
||||
"artist-credit": [{"name": "Other Artist"}],
|
||||
},
|
||||
{
|
||||
"title": "Song B",
|
||||
"artist-credit": [{"name": "Another Band"}],
|
||||
},
|
||||
]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
result = _mod.mb_find_similar_recordings(
|
||||
"Tool", ["rock", "metal"],
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert result[0] == {"artist": "Other Artist", "title": "Song A"}
|
||||
assert result[1] == {"artist": "Another Band", "title": "Song B"}
|
||||
|
||||
def test_excludes_original_artist(self):
|
||||
data = {"recordings": [
|
||||
{
|
||||
"title": "Own Song",
|
||||
"artist-credit": [{"name": "Tool"}],
|
||||
},
|
||||
{
|
||||
"title": "Other Song",
|
||||
"artist-credit": [{"name": "Deftones"}],
|
||||
},
|
||||
]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
result = _mod.mb_find_similar_recordings(
|
||||
"Tool", ["rock"],
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["artist"] == "Deftones"
|
||||
|
||||
def test_excludes_original_artist_case_insensitive(self):
|
||||
data = {"recordings": [
|
||||
{
|
||||
"title": "Song",
|
||||
"artist-credit": [{"name": "TOOL"}],
|
||||
},
|
||||
]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
result = _mod.mb_find_similar_recordings(
|
||||
"Tool", ["rock"],
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_deduplicates(self):
|
||||
data = {"recordings": [
|
||||
{
|
||||
"title": "Song A",
|
||||
"artist-credit": [{"name": "Band X"}],
|
||||
},
|
||||
{
|
||||
"title": "Song A",
|
||||
"artist-credit": [{"name": "Band X"}],
|
||||
},
|
||||
]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
result = _mod.mb_find_similar_recordings(
|
||||
"Other", ["rock"],
|
||||
)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_tags(self):
|
||||
result = _mod.mb_find_similar_recordings("Tool", [])
|
||||
assert result == []
|
||||
|
||||
def test_no_recordings(self):
|
||||
with patch.object(_mod, "_mb_request", return_value={"recordings": []}):
|
||||
result = _mod.mb_find_similar_recordings(
|
||||
"Tool", ["rock"],
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_empty_response(self):
|
||||
with patch.object(_mod, "_mb_request", return_value={}):
|
||||
result = _mod.mb_find_similar_recordings(
|
||||
"Tool", ["rock"],
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_skips_missing_title(self):
|
||||
data = {"recordings": [
|
||||
{
|
||||
"title": "",
|
||||
"artist-credit": [{"name": "Band"}],
|
||||
},
|
||||
]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
result = _mod.mb_find_similar_recordings(
|
||||
"Other", ["rock"],
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_skips_missing_artist_credit(self):
|
||||
data = {"recordings": [
|
||||
{"title": "Song", "artist-credit": []},
|
||||
{"title": "Song2"},
|
||||
]}
|
||||
with patch.object(_mod, "_mb_request", return_value=data):
|
||||
result = _mod.mb_find_similar_recordings(
|
||||
"Other", ["rock"],
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_uses_top_two_tags(self):
|
||||
"""Query should use at most 2 tags."""
|
||||
with patch.object(_mod, "_mb_request", return_value={}) as mock_req:
|
||||
_mod.mb_find_similar_recordings(
|
||||
"Tool", ["rock", "metal", "prog"],
|
||||
)
|
||||
call_args = mock_req.call_args
|
||||
args = call_args[1] or {}
|
||||
query = args.get("query") or call_args[0][1].get("query", "")
|
||||
# Verify the query contains both tag references
|
||||
assert "rock" in query or "metal" in query
|
||||
@@ -36,6 +36,20 @@ class TestDecorators:
|
||||
assert handler._derp_event == "PRIVMSG"
|
||||
|
||||
|
||||
def test_command_decorator_aliases(self):
|
||||
@command("skip", help="skip track", aliases=["next", "s"])
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
assert handler._derp_aliases == ["next", "s"]
|
||||
|
||||
def test_command_decorator_aliases_default(self):
|
||||
@command("ping", help="ping")
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
assert handler._derp_aliases == []
|
||||
|
||||
def test_command_decorator_admin(self):
|
||||
@command("secret", help="admin only", admin=True)
|
||||
async def handler(bot, msg):
|
||||
@@ -208,6 +222,46 @@ class TestRegistry:
|
||||
assert registry.commands["secret"].admin is True
|
||||
assert registry.commands["public"].admin is False
|
||||
|
||||
def test_load_plugin_aliases(self, tmp_path: Path):
|
||||
plugin_file = tmp_path / "aliased.py"
|
||||
plugin_file.write_text(textwrap.dedent("""\
|
||||
from derp.plugin import command
|
||||
|
||||
@command("skip", help="Skip track", aliases=["next", "s"])
|
||||
async def cmd_skip(bot, msg):
|
||||
pass
|
||||
"""))
|
||||
|
||||
registry = PluginRegistry()
|
||||
count = registry.load_plugin(plugin_file)
|
||||
assert count == 3 # primary + 2 aliases
|
||||
assert "skip" in registry.commands
|
||||
assert "next" in registry.commands
|
||||
assert "s" in registry.commands
|
||||
# Aliases point to the same callback
|
||||
assert registry.commands["next"].callback is registry.commands["skip"].callback
|
||||
assert registry.commands["s"].callback is registry.commands["skip"].callback
|
||||
# Alias help text references the primary command
|
||||
assert registry.commands["next"].help == "alias for !skip"
|
||||
|
||||
def test_unload_removes_aliases(self, tmp_path: Path):
|
||||
plugin_file = tmp_path / "aliased.py"
|
||||
plugin_file.write_text(textwrap.dedent("""\
|
||||
from derp.plugin import command
|
||||
|
||||
@command("skip", help="Skip track", aliases=["next"])
|
||||
async def cmd_skip(bot, msg):
|
||||
pass
|
||||
"""))
|
||||
|
||||
registry = PluginRegistry()
|
||||
registry.load_plugin(plugin_file)
|
||||
assert "next" in registry.commands
|
||||
|
||||
registry.unload_plugin("aliased")
|
||||
assert "skip" not in registry.commands
|
||||
assert "next" not in registry.commands
|
||||
|
||||
def test_load_plugin_stores_path(self, tmp_path: Path):
|
||||
plugin_file = tmp_path / "pathed.py"
|
||||
plugin_file.write_text(textwrap.dedent("""\
|
||||
@@ -677,6 +731,71 @@ class TestChannelFilter:
|
||||
assert bot._plugin_allowed("encode", "&local") is False
|
||||
|
||||
|
||||
class TestAliasDispatch:
|
||||
"""Test alias fallback in _dispatch_command."""
|
||||
|
||||
@staticmethod
|
||||
def _make_bot_with_alias(alias_name: str, target_cmd: str) -> tuple[Bot, list]:
|
||||
"""Create a Bot with a command and an alias pointing to it."""
|
||||
config = {
|
||||
"server": {"host": "localhost", "port": 6667, "tls": False,
|
||||
"nick": "test", "user": "test", "realname": "test"},
|
||||
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
|
||||
}
|
||||
registry = PluginRegistry()
|
||||
called = []
|
||||
|
||||
async def _handler(bot, msg):
|
||||
called.append(msg.text)
|
||||
|
||||
registry.register_command(target_cmd, _handler, plugin="test")
|
||||
bot = Bot("test", config, registry)
|
||||
bot.conn = _FakeConnection()
|
||||
bot.state.set("alias", alias_name, target_cmd)
|
||||
return bot, called
|
||||
|
||||
def test_alias_resolves_command(self):
|
||||
"""An alias triggers the target command handler."""
|
||||
bot, called = self._make_bot_with_alias("s", "skip")
|
||||
msg = Message(raw="", prefix="nick!u@h", nick="nick",
|
||||
command="PRIVMSG", params=["#ch", "!s"], tags={})
|
||||
|
||||
async def _run():
|
||||
bot._dispatch_command(msg)
|
||||
await asyncio.sleep(0.05) # let spawned task run
|
||||
|
||||
asyncio.run(_run())
|
||||
assert len(called) == 1
|
||||
|
||||
def test_alias_ignored_when_command_exists(self):
|
||||
"""Direct command match takes priority over alias."""
|
||||
bot, called = self._make_bot_with_alias("skip", "stop")
|
||||
# "skip" is both a real command and an alias to "stop"; real wins
|
||||
msg = Message(raw="", prefix="nick!u@h", nick="nick",
|
||||
command="PRIVMSG", params=["#ch", "!skip"], tags={})
|
||||
|
||||
async def _run():
|
||||
bot._dispatch_command(msg)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
asyncio.run(_run())
|
||||
assert len(called) == 1
|
||||
# Handler was the "skip" handler, not "stop"
|
||||
|
||||
def test_no_alias_no_crash(self):
|
||||
"""Unknown command with no alias silently returns."""
|
||||
config = {
|
||||
"server": {"host": "localhost", "port": 6667, "tls": False,
|
||||
"nick": "test", "user": "test", "realname": "test"},
|
||||
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
|
||||
}
|
||||
bot = Bot("test", config, PluginRegistry())
|
||||
bot.conn = _FakeConnection()
|
||||
msg = Message(raw="", prefix="nick!u@h", nick="nick",
|
||||
command="PRIVMSG", params=["#ch", "!nonexistent"], tags={})
|
||||
bot._dispatch_command(msg) # should not raise
|
||||
|
||||
|
||||
class TestSplitUtf8:
|
||||
"""Test UTF-8 safe message splitting."""
|
||||
|
||||
|
||||
797
tests/test_voice.py
Normal file
797
tests/test_voice.py
Normal file
@@ -0,0 +1,797 @@
|
||||
"""Tests for the voice STT/TTS plugin."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import io
|
||||
import sys
|
||||
import time
|
||||
import wave
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
# -- Load plugin module directly ---------------------------------------------
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("voice", "plugins/voice.py")
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["voice"] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
|
||||
# -- Fakes -------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeState:
|
||||
def __init__(self):
|
||||
self._store: dict[str, dict[str, str]] = {}
|
||||
|
||||
def get(self, ns: str, key: str) -> str | None:
|
||||
return self._store.get(ns, {}).get(key)
|
||||
|
||||
def set(self, ns: str, key: str, value: str) -> None:
|
||||
self._store.setdefault(ns, {})[key] = value
|
||||
|
||||
def delete(self, ns: str, key: str) -> None:
|
||||
self._store.get(ns, {}).pop(key, None)
|
||||
|
||||
def keys(self, ns: str) -> list[str]:
|
||||
return list(self._store.get(ns, {}).keys())
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
"""Minimal bot for voice plugin testing."""
|
||||
|
||||
def __init__(self, *, mumble: bool = True):
|
||||
self.sent: list[tuple[str, str]] = []
|
||||
self.replied: list[str] = []
|
||||
self.actions: list[tuple[str, str]] = []
|
||||
self.state = _FakeState()
|
||||
self.config: dict = {}
|
||||
self._pstate: dict = {}
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
self.nick = "derp"
|
||||
self._sound_listeners: list = []
|
||||
if mumble:
|
||||
self.stream_audio = AsyncMock()
|
||||
|
||||
async def send(self, target: str, text: str) -> None:
|
||||
self.sent.append((target, text))
|
||||
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
async def action(self, target: str, text: str) -> None:
|
||||
self.actions.append((target, text))
|
||||
|
||||
def _spawn(self, coro, *, name=None):
|
||||
task = asyncio.ensure_future(coro)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
return task
|
||||
|
||||
|
||||
class _Msg:
|
||||
"""Minimal message object."""
|
||||
|
||||
def __init__(self, text="!listen", nick="Alice", target="0",
|
||||
is_channel=True):
|
||||
self.text = text
|
||||
self.nick = nick
|
||||
self.target = target
|
||||
self.is_channel = is_channel
|
||||
self.prefix = nick
|
||||
self.command = "PRIVMSG"
|
||||
self.params = [target, text]
|
||||
self.tags = {}
|
||||
self.raw = {}
|
||||
|
||||
|
||||
class _FakeSoundChunk:
|
||||
"""Minimal sound chunk with PCM data."""
|
||||
|
||||
def __init__(self, pcm: bytes = b"\x00\x00" * 960):
|
||||
self.pcm = pcm
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMumbleGuard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMumbleGuard:
|
||||
def test_is_mumble_true(self):
|
||||
bot = _FakeBot(mumble=True)
|
||||
assert _mod._is_mumble(bot) is True
|
||||
|
||||
def test_is_mumble_false(self):
|
||||
bot = _FakeBot(mumble=False)
|
||||
assert _mod._is_mumble(bot) is False
|
||||
|
||||
def test_listen_non_mumble(self):
|
||||
bot = _FakeBot(mumble=False)
|
||||
msg = _Msg(text="!listen on")
|
||||
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||
assert any("Mumble-only" in r for r in bot.replied)
|
||||
|
||||
def test_say_non_mumble(self):
|
||||
bot = _FakeBot(mumble=False)
|
||||
msg = _Msg(text="!say hello")
|
||||
asyncio.run(_mod.cmd_say(bot, msg))
|
||||
assert any("Mumble-only" in r for r in bot.replied)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestListenCommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListenCommand:
|
||||
def test_listen_status(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!listen")
|
||||
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||
assert any("off" in r.lower() for r in bot.replied)
|
||||
|
||||
def test_listen_on(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!listen on")
|
||||
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||
ps = _mod._ps(bot)
|
||||
assert ps["listen"] is True
|
||||
assert any("Listening" in r for r in bot.replied)
|
||||
|
||||
def test_listen_off(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
ps["buffers"]["Alice"] = bytearray(b"\x00" * 100)
|
||||
ps["last_ts"]["Alice"] = time.monotonic()
|
||||
msg = _Msg(text="!listen off")
|
||||
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||
assert ps["listen"] is False
|
||||
assert ps["buffers"] == {}
|
||||
assert ps["last_ts"] == {}
|
||||
assert any("Stopped" in r for r in bot.replied)
|
||||
|
||||
def test_listen_invalid(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!listen maybe")
|
||||
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||
assert any("Usage" in r for r in bot.replied)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSayCommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSayCommand:
|
||||
def test_say_no_text(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!say")
|
||||
asyncio.run(_mod.cmd_say(bot, msg))
|
||||
assert any("Usage" in r for r in bot.replied)
|
||||
|
||||
def test_say_too_long(self):
|
||||
bot = _FakeBot()
|
||||
text = "x" * 501
|
||||
msg = _Msg(text=f"!say {text}")
|
||||
asyncio.run(_mod.cmd_say(bot, msg))
|
||||
assert any("too long" in r.lower() for r in bot.replied)
|
||||
|
||||
def test_say_spawns_task(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!say hello world")
|
||||
|
||||
spawned = []
|
||||
|
||||
def track_spawn(coro, *, name=None):
|
||||
spawned.append(name)
|
||||
coro.close()
|
||||
task = MagicMock()
|
||||
task.done.return_value = False
|
||||
return task
|
||||
|
||||
bot._spawn = track_spawn
|
||||
asyncio.run(_mod.cmd_say(bot, msg))
|
||||
assert "voice-tts" in spawned
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAudioBuffering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAudioBuffering:
|
||||
def test_accumulates_pcm(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
user = {"name": "Alice"}
|
||||
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||
_mod._on_voice(bot, user, chunk)
|
||||
assert "Alice" in ps["buffers"]
|
||||
assert len(ps["buffers"]["Alice"]) == 960
|
||||
|
||||
def test_ignores_own_nick(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
user = {"name": "derp"}
|
||||
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||
_mod._on_voice(bot, user, chunk)
|
||||
assert "derp" not in ps["buffers"]
|
||||
|
||||
def test_respects_listen_false(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = False
|
||||
user = {"name": "Alice"}
|
||||
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||
_mod._on_voice(bot, user, chunk)
|
||||
assert ps["buffers"] == {}
|
||||
|
||||
def test_caps_at_max_bytes(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
user = {"name": "Alice"}
|
||||
# Fill beyond max
|
||||
big_chunk = _FakeSoundChunk(b"\x00\x01" * (_mod._MAX_BYTES // 2 + 100))
|
||||
_mod._on_voice(bot, user, big_chunk)
|
||||
assert len(ps["buffers"]["Alice"]) <= _mod._MAX_BYTES
|
||||
|
||||
def test_empty_pcm_ignored(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
user = {"name": "Alice"}
|
||||
chunk = _FakeSoundChunk(b"")
|
||||
_mod._on_voice(bot, user, chunk)
|
||||
assert "Alice" not in ps["buffers"]
|
||||
|
||||
def test_none_user_ignored(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||
_mod._on_voice(bot, "not_a_dict", chunk)
|
||||
assert ps["buffers"] == {}
|
||||
|
||||
def test_updates_timestamp(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
user = {"name": "Alice"}
|
||||
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||
_mod._on_voice(bot, user, chunk)
|
||||
assert "Alice" in ps["last_ts"]
|
||||
ts1 = ps["last_ts"]["Alice"]
|
||||
_mod._on_voice(bot, user, chunk)
|
||||
assert ps["last_ts"]["Alice"] >= ts1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestFlushLogic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFlushLogic:
|
||||
def test_silence_gap_triggers_flush(self):
|
||||
"""Buffer is flushed and transcribed after silence gap."""
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
ps["silence_gap"] = 0.1 # very short for testing
|
||||
|
||||
# Pre-populate buffer with enough PCM (> _MIN_BYTES)
|
||||
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
||||
with ps["lock"]:
|
||||
ps["buffers"]["Alice"] = bytearray(pcm)
|
||||
ps["last_ts"]["Alice"] = time.monotonic() - 1.0 # already silent
|
||||
|
||||
async def _check():
|
||||
with patch.object(_mod, "_transcribe", return_value="hello"):
|
||||
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||
await asyncio.sleep(1.0)
|
||||
ps["listen"] = False # stop the monitor
|
||||
await asyncio.sleep(0.2)
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
assert any("hello" in a[1] for a in bot.actions)
|
||||
|
||||
asyncio.run(_check())
|
||||
|
||||
def test_min_duration_filter(self):
|
||||
"""Short utterances (< _MIN_BYTES) are discarded."""
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
ps["silence_gap"] = 0.1
|
||||
|
||||
# Buffer too small
|
||||
with ps["lock"]:
|
||||
ps["buffers"]["Alice"] = bytearray(b"\x00\x01" * 10)
|
||||
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
||||
|
||||
async def _check():
|
||||
with patch.object(_mod, "_transcribe", return_value="x") as mock_t:
|
||||
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||
await asyncio.sleep(0.5)
|
||||
ps["listen"] = False
|
||||
await asyncio.sleep(0.2)
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
mock_t.assert_not_called()
|
||||
|
||||
asyncio.run(_check())
|
||||
|
||||
def test_buffer_cleared_after_flush(self):
|
||||
"""Buffer and timestamp are removed after flushing."""
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
ps["silence_gap"] = 0.1
|
||||
|
||||
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
||||
with ps["lock"]:
|
||||
ps["buffers"]["Alice"] = bytearray(pcm)
|
||||
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
||||
|
||||
async def _check():
|
||||
with patch.object(_mod, "_transcribe", return_value="test"):
|
||||
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||
await asyncio.sleep(0.5)
|
||||
ps["listen"] = False
|
||||
await asyncio.sleep(0.2)
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
assert "Alice" not in ps["buffers"]
|
||||
assert "Alice" not in ps["last_ts"]
|
||||
|
||||
asyncio.run(_check())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestPcmToWav
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPcmToWav:
|
||||
def test_valid_wav(self):
|
||||
pcm = b"\x00\x00" * 48000 # 1 second of silence
|
||||
wav_data = _mod._pcm_to_wav(pcm)
|
||||
# Should start with RIFF header
|
||||
assert wav_data[:4] == b"RIFF"
|
||||
# Parse it back
|
||||
buf = io.BytesIO(wav_data)
|
||||
with wave.open(buf, "rb") as wf:
|
||||
assert wf.getnchannels() == 1
|
||||
assert wf.getsampwidth() == 2
|
||||
assert wf.getframerate() == 48000
|
||||
assert wf.getnframes() == 48000
|
||||
|
||||
def test_empty_pcm(self):
|
||||
wav_data = _mod._pcm_to_wav(b"")
|
||||
buf = io.BytesIO(wav_data)
|
||||
with wave.open(buf, "rb") as wf:
|
||||
assert wf.getnframes() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTranscribe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTranscribe:
|
||||
def test_parse_json_response(self):
|
||||
ps = {"whisper_url": "http://localhost:8080/inference"}
|
||||
pcm = b"\x00\x00" * 4800 # 0.1s
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b'{"text": "hello world"}'
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
text = _mod._transcribe(ps, pcm)
|
||||
assert text == "hello world"
|
||||
|
||||
def test_empty_text(self):
|
||||
ps = {"whisper_url": "http://localhost:8080/inference"}
|
||||
pcm = b"\x00\x00" * 4800
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b'{"text": ""}'
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
text = _mod._transcribe(ps, pcm)
|
||||
assert text == ""
|
||||
|
||||
def test_missing_text_key(self):
|
||||
ps = {"whisper_url": "http://localhost:8080/inference"}
|
||||
pcm = b"\x00\x00" * 4800
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b'{"result": "something"}'
|
||||
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||
text = _mod._transcribe(ps, pcm)
|
||||
assert text == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestPerBotState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPerBotState:
|
||||
def test_ps_initializes(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
assert ps["listen"] is False
|
||||
assert ps["buffers"] == {}
|
||||
assert ps["last_ts"] == {}
|
||||
|
||||
def test_ps_stable_reference(self):
|
||||
bot = _FakeBot()
|
||||
ps1 = _mod._ps(bot)
|
||||
ps2 = _mod._ps(bot)
|
||||
assert ps1 is ps2
|
||||
|
||||
def test_ps_isolated_per_bot(self):
|
||||
bot1 = _FakeBot()
|
||||
bot2 = _FakeBot()
|
||||
_mod._ps(bot1)["listen"] = True
|
||||
assert _mod._ps(bot2)["listen"] is False
|
||||
|
||||
def test_ps_config_override(self):
|
||||
bot = _FakeBot()
|
||||
bot.config = {"voice": {"silence_gap": 3.0}}
|
||||
ps = _mod._ps(bot)
|
||||
assert ps["silence_gap"] == 3.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestEnsureListener
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnsureListener:
|
||||
def test_registers_callback(self):
|
||||
bot = _FakeBot()
|
||||
_mod._ps(bot) # init state
|
||||
_mod._ensure_listener(bot)
|
||||
assert len(bot._sound_listeners) == 1
|
||||
ps = _mod._ps(bot)
|
||||
assert ps["_listener_registered"] is True
|
||||
|
||||
def test_idempotent(self):
|
||||
bot = _FakeBot()
|
||||
_mod._ps(bot)
|
||||
_mod._ensure_listener(bot)
|
||||
_mod._ensure_listener(bot)
|
||||
assert len(bot._sound_listeners) == 1
|
||||
|
||||
def test_no_listener_without_attr(self):
|
||||
bot = _FakeBot()
|
||||
del bot._sound_listeners
|
||||
_mod._ps(bot)
|
||||
_mod._ensure_listener(bot)
|
||||
# Should not raise, just skip
|
||||
|
||||
def test_callback_calls_on_voice(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
_mod._ensure_listener(bot)
|
||||
user = {"name": "Alice"}
|
||||
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||
bot._sound_listeners[0](user, chunk)
|
||||
assert "Alice" in ps["buffers"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestOnConnected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOnConnected:
|
||||
def test_reregisters_when_listening(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["listen"] = True
|
||||
|
||||
spawned = []
|
||||
|
||||
def fake_spawn(coro, *, name=None):
|
||||
task = MagicMock()
|
||||
task.done.return_value = False
|
||||
spawned.append(name)
|
||||
coro.close()
|
||||
return task
|
||||
|
||||
bot._spawn = fake_spawn
|
||||
asyncio.run(_mod.on_connected(bot))
|
||||
assert ps["_listener_registered"] is True
|
||||
assert "voice-flush-monitor" in spawned
|
||||
|
||||
def test_noop_when_not_listening(self):
|
||||
bot = _FakeBot()
|
||||
_mod._ps(bot) # init but listen=False
|
||||
|
||||
spawned = []
|
||||
|
||||
def fake_spawn(coro, *, name=None):
|
||||
spawned.append(name)
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
bot._spawn = fake_spawn
|
||||
asyncio.run(_mod.on_connected(bot))
|
||||
assert "voice-flush-monitor" not in spawned
|
||||
|
||||
def test_noop_non_mumble(self):
|
||||
bot = _FakeBot(mumble=False)
|
||||
asyncio.run(_mod.on_connected(bot))
|
||||
# Should not raise or register anything
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTriggerMode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTriggerMode:
|
||||
def test_trigger_config(self):
|
||||
"""_ps() reads trigger from config."""
|
||||
bot = _FakeBot()
|
||||
bot.config = {"voice": {"trigger": "claude"}}
|
||||
ps = _mod._ps(bot)
|
||||
assert ps["trigger"] == "claude"
|
||||
|
||||
def test_trigger_default_empty(self):
|
||||
"""trigger defaults to empty string (disabled)."""
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
assert ps["trigger"] == ""
|
||||
|
||||
def test_trigger_buffers_without_listen(self):
|
||||
"""_on_voice buffers when trigger is set, even with listen=False."""
|
||||
bot = _FakeBot()
|
||||
bot.config = {"voice": {"trigger": "claude"}}
|
||||
ps = _mod._ps(bot)
|
||||
assert ps["listen"] is False
|
||||
user = {"name": "Alice"}
|
||||
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||
_mod._on_voice(bot, user, chunk)
|
||||
assert "Alice" in ps["buffers"]
|
||||
assert len(ps["buffers"]["Alice"]) == 960
|
||||
|
||||
def test_trigger_detected_spawns_tts(self):
|
||||
"""Flush monitor detects trigger word and spawns TTS."""
|
||||
bot = _FakeBot()
|
||||
bot.config = {"voice": {"trigger": "claude"}}
|
||||
ps = _mod._ps(bot)
|
||||
ps["silence_gap"] = 0.1
|
||||
|
||||
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
||||
with ps["lock"]:
|
||||
ps["buffers"]["Alice"] = bytearray(pcm)
|
||||
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
||||
|
||||
spawned = []
|
||||
|
||||
async def _check():
|
||||
tts_hit = asyncio.Event()
|
||||
|
||||
def track_spawn(coro, *, name=None):
|
||||
spawned.append(name)
|
||||
if name == "voice-tts":
|
||||
tts_hit.set()
|
||||
coro.close()
|
||||
task = MagicMock()
|
||||
task.done.return_value = False
|
||||
return task
|
||||
|
||||
bot._spawn = track_spawn
|
||||
|
||||
with patch.object(_mod, "_transcribe",
|
||||
return_value="claude hello world"):
|
||||
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||
await asyncio.wait_for(tts_hit.wait(), timeout=5)
|
||||
ps["trigger"] = ""
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
assert "voice-tts" in spawned
|
||||
|
||||
asyncio.run(_check())
|
||||
|
||||
def test_trigger_strips_word(self):
|
||||
"""Trigger word is stripped; only remainder goes to TTS."""
|
||||
bot = _FakeBot()
|
||||
bot.config = {"voice": {"trigger": "claude"}}
|
||||
ps = _mod._ps(bot)
|
||||
ps["silence_gap"] = 0.1
|
||||
|
||||
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
||||
with ps["lock"]:
|
||||
ps["buffers"]["Alice"] = bytearray(pcm)
|
||||
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
||||
|
||||
tts_texts = []
|
||||
|
||||
async def _check():
|
||||
tts_hit = asyncio.Event()
|
||||
|
||||
async def _noop():
|
||||
pass
|
||||
|
||||
def capturing_tts(bot_, text):
|
||||
tts_texts.append(text)
|
||||
return _noop()
|
||||
|
||||
def track_spawn(coro, *, name=None):
|
||||
if name == "voice-tts":
|
||||
tts_hit.set()
|
||||
coro.close()
|
||||
task = MagicMock()
|
||||
task.done.return_value = False
|
||||
return task
|
||||
|
||||
bot._spawn = track_spawn
|
||||
original_tts = _mod._tts_play
|
||||
_mod._tts_play = capturing_tts
|
||||
|
||||
try:
|
||||
with patch.object(_mod, "_transcribe",
|
||||
return_value="Claude hello world"):
|
||||
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||
await asyncio.wait_for(tts_hit.wait(), timeout=5)
|
||||
ps["trigger"] = ""
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
finally:
|
||||
_mod._tts_play = original_tts
|
||||
assert tts_texts == ["hello world"]
|
||||
|
||||
asyncio.run(_check())
|
||||
|
||||
def test_no_trigger_discards(self):
|
||||
"""Non-triggered speech is silently discarded when only trigger active."""
|
||||
bot = _FakeBot()
|
||||
bot.config = {"voice": {"trigger": "claude"}}
|
||||
ps = _mod._ps(bot)
|
||||
ps["silence_gap"] = 0.1
|
||||
|
||||
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
||||
with ps["lock"]:
|
||||
ps["buffers"]["Alice"] = bytearray(pcm)
|
||||
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
||||
|
||||
async def _check():
|
||||
transcribed = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def mock_transcribe(ps_, pcm_):
|
||||
loop.call_soon_threadsafe(transcribed.set)
|
||||
return "hello world"
|
||||
|
||||
with patch.object(_mod, "_transcribe",
|
||||
side_effect=mock_transcribe):
|
||||
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||
await asyncio.wait_for(transcribed.wait(), timeout=5)
|
||||
# Give the monitor a moment to process the result
|
||||
await asyncio.sleep(0.2)
|
||||
ps["trigger"] = ""
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
assert bot.actions == []
|
||||
|
||||
asyncio.run(_check())
|
||||
|
||||
def test_on_connected_starts_with_trigger(self):
|
||||
"""Listener and flush task start on connect when trigger is set."""
|
||||
bot = _FakeBot()
|
||||
bot.config = {"voice": {"trigger": "claude"}}
|
||||
ps = _mod._ps(bot)
|
||||
|
||||
spawned = []
|
||||
|
||||
def fake_spawn(coro, *, name=None):
|
||||
task = MagicMock()
|
||||
task.done.return_value = False
|
||||
spawned.append(name)
|
||||
coro.close()
|
||||
return task
|
||||
|
||||
bot._spawn = fake_spawn
|
||||
asyncio.run(_mod.on_connected(bot))
|
||||
assert ps["_listener_registered"] is True
|
||||
assert "voice-flush-monitor" in spawned
|
||||
|
||||
def test_listen_status_shows_trigger(self):
|
||||
"""!listen status includes trigger info when set."""
|
||||
bot = _FakeBot()
|
||||
bot.config = {"voice": {"trigger": "claude"}}
|
||||
_mod._ps(bot)
|
||||
msg = _Msg(text="!listen")
|
||||
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||
assert any("Trigger: claude" in r for r in bot.replied)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGreeting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGreeting:
|
||||
def test_greet_on_first_connect(self):
|
||||
"""TTS greeting fires on first connect when configured."""
|
||||
bot = _FakeBot()
|
||||
bot.config = {"mumble": {"greet": "Hello there."}}
|
||||
bot._is_audio_ready = lambda: True
|
||||
|
||||
spawned = []
|
||||
|
||||
def fake_spawn(coro, *, name=None):
|
||||
spawned.append(name)
|
||||
coro.close()
|
||||
task = MagicMock()
|
||||
task.done.return_value = False
|
||||
return task
|
||||
|
||||
bot._spawn = fake_spawn
|
||||
asyncio.run(_mod.on_connected(bot))
|
||||
assert "voice-greet" in spawned
|
||||
|
||||
def test_greet_only_once(self):
|
||||
"""Greeting fires only on first connect, not on reconnect."""
|
||||
bot = _FakeBot()
|
||||
bot.config = {"mumble": {"greet": "Hello there."}}
|
||||
bot._is_audio_ready = lambda: True
|
||||
|
||||
spawned = []
|
||||
|
||||
def fake_spawn(coro, *, name=None):
|
||||
spawned.append(name)
|
||||
coro.close()
|
||||
task = MagicMock()
|
||||
task.done.return_value = False
|
||||
return task
|
||||
|
||||
bot._spawn = fake_spawn
|
||||
asyncio.run(_mod.on_connected(bot))
|
||||
assert spawned.count("voice-greet") == 1
|
||||
asyncio.run(_mod.on_connected(bot))
|
||||
assert spawned.count("voice-greet") == 1
|
||||
|
||||
def test_no_greet_without_config(self):
|
||||
"""No greeting when mumble.greet is not set."""
|
||||
bot = _FakeBot()
|
||||
bot.config = {}
|
||||
|
||||
spawned = []
|
||||
|
||||
def fake_spawn(coro, *, name=None):
|
||||
spawned.append(name)
|
||||
coro.close()
|
||||
task = MagicMock()
|
||||
task.done.return_value = False
|
||||
return task
|
||||
|
||||
bot._spawn = fake_spawn
|
||||
asyncio.run(_mod.on_connected(bot))
|
||||
assert "voice-greet" not in spawned
|
||||
|
||||
def test_no_greet_non_mumble(self):
|
||||
"""Greeting skipped for non-Mumble bots."""
|
||||
bot = _FakeBot(mumble=False)
|
||||
bot.config = {"mumble": {"greet": "Hello there."}}
|
||||
asyncio.run(_mod.on_connected(bot))
|
||||
# Should not raise or try to greet
|
||||
38
tools/_common.sh
Normal file
38
tools/_common.sh
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers for derp container tools.
|
||||
# Sourced, not executed.
|
||||
# shellcheck disable=SC2034
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[1]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Compose command detection
|
||||
if podman compose version &>/dev/null; then
|
||||
COMPOSE="podman compose"
|
||||
elif command -v podman-compose &>/dev/null; then
|
||||
COMPOSE="podman-compose"
|
||||
else
|
||||
echo "error: podman compose or podman-compose required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONTAINER_NAME="derp"
|
||||
# podman-compose names images <project>_<service>
|
||||
IMAGE_NAME="derp_derp"
|
||||
|
||||
# Colors (suppressed if NO_COLOR is set or stdout isn't a tty)
|
||||
if [[ -z "${NO_COLOR:-}" ]] && [[ -t 1 ]]; then
|
||||
GRN='\e[38;5;108m'
|
||||
RED='\e[38;5;131m'
|
||||
BLU='\e[38;5;110m'
|
||||
DIM='\e[2m'
|
||||
RST='\e[0m'
|
||||
else
|
||||
GRN='' RED='' BLU='' DIM='' RST=''
|
||||
fi
|
||||
|
||||
info() { printf "${GRN}%s${RST} %s\n" "✓" "$*"; }
|
||||
err() { printf "${RED}%s${RST} %s\n" "✗" "$*" >&2; }
|
||||
dim() { printf "${DIM} %s${RST}\n" "$*"; }
|
||||
21
tools/build
Executable file
21
tools/build
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build or rebuild the derp container image.
|
||||
# Usage: tools/build [--no-cache]
|
||||
|
||||
# shellcheck source=tools/_common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||
cd "$PROJECT_DIR" || exit 1
|
||||
|
||||
args=()
|
||||
[[ "${1:-}" == "--no-cache" ]] && args+=(--no-cache)
|
||||
|
||||
dim "Building image..."
|
||||
$COMPOSE build "${args[@]}"
|
||||
|
||||
size=$(podman image inspect "$IMAGE_NAME" --format '{{.Size}}' 2>/dev/null || true)
|
||||
if [[ -n "$size" ]]; then
|
||||
human=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size} bytes")
|
||||
info "Image built ($human)"
|
||||
else
|
||||
info "Image built"
|
||||
fi
|
||||
9
tools/logs
Executable file
9
tools/logs
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tail container logs.
|
||||
# Usage: tools/logs [N]
|
||||
|
||||
# shellcheck source=tools/_common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||
|
||||
tail_n="${1:-30}"
|
||||
podman logs -f --tail "$tail_n" "$CONTAINER_NAME"
|
||||
26
tools/nuke
Executable file
26
tools/nuke
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Full teardown: stop container and remove image.
|
||||
# Usage: tools/nuke
|
||||
|
||||
# shellcheck source=tools/_common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||
cd "$PROJECT_DIR" || exit 1
|
||||
|
||||
dim "Stopping container..."
|
||||
$COMPOSE down 2>/dev/null || true
|
||||
|
||||
before=$(podman system df --format '{{.Size}}' 2>/dev/null | head -1 || true)
|
||||
|
||||
dim "Removing image..."
|
||||
podman rmi "$IMAGE_NAME" 2>/dev/null || true
|
||||
# Also remove any dangling derp images
|
||||
podman images --filter "reference=*derp*" --format '{{.ID}}' 2>/dev/null | \
|
||||
xargs -r podman rmi 2>/dev/null || true
|
||||
|
||||
after=$(podman system df --format '{{.Size}}' 2>/dev/null | head -1 || true)
|
||||
|
||||
if [[ -n "$before" && -n "$after" ]]; then
|
||||
info "Teardown complete (images: $before -> $after)"
|
||||
else
|
||||
info "Teardown complete"
|
||||
fi
|
||||
97
tools/profile
Executable file
97
tools/profile
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Analyze cProfile data from the bot process.
|
||||
# Usage: tools/profile [OPTIONS] [FILE]
|
||||
#
|
||||
# Options:
|
||||
# -n NUM Show top NUM entries (default: 30)
|
||||
# -s SORT Sort by: cumtime, tottime, calls, name (default: cumtime)
|
||||
# -f PATTERN Filter to entries matching PATTERN
|
||||
# -c Callers view (who calls the hot functions)
|
||||
# -h Show this help
|
||||
#
|
||||
# Examples:
|
||||
# tools/profile # top 30 by cumulative time
|
||||
# tools/profile -s tottime -n 20 # top 20 by total time
|
||||
# tools/profile -f mumble # only mumble-related functions
|
||||
# tools/profile -c -f stream_audio # who calls stream_audio
|
||||
# tools/profile data/old.prof # analyze a specific file
|
||||
|
||||
# shellcheck source=tools/_common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||
|
||||
DEFAULT_PROF="$PROJECT_DIR/data/derp.prof"
|
||||
TOP=30
|
||||
SORT="cumtime"
|
||||
PATTERN=""
|
||||
CALLERS=false
|
||||
|
||||
usage() {
|
||||
sed -n '2,/^$/s/^# \?//p' "$0"
|
||||
exit 0
|
||||
}
|
||||
|
||||
while getopts ":n:s:f:ch" opt; do
|
||||
case $opt in
|
||||
n) TOP="$OPTARG" ;;
|
||||
s) SORT="$OPTARG" ;;
|
||||
f) PATTERN="$OPTARG" ;;
|
||||
c) CALLERS=true ;;
|
||||
h) usage ;;
|
||||
:) err "option -$OPTARG requires an argument"; exit 2 ;;
|
||||
*) err "unknown option -$OPTARG"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND - 1))
|
||||
|
||||
PROF="${1:-$DEFAULT_PROF}"
|
||||
|
||||
if [[ ! -f "$PROF" ]]; then
|
||||
err "profile not found: $PROF"
|
||||
dim "run the bot with --cprofile and stop it gracefully"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate sort key
|
||||
case "$SORT" in
|
||||
cumtime|tottime|calls|name) ;;
|
||||
*) err "invalid sort key: $SORT (use cumtime, tottime, calls, name)"; exit 2 ;;
|
||||
esac
|
||||
|
||||
# Profile metadata
|
||||
size=$(stat -c %s "$PROF" 2>/dev/null || stat -f %z "$PROF" 2>/dev/null)
|
||||
human=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
|
||||
modified=$(stat -c %y "$PROF" 2>/dev/null | cut -d. -f1)
|
||||
|
||||
printf '%b%s%b\n' "$BLU" "Profile" "$RST"
|
||||
dim "$PROF ($human, $modified)"
|
||||
echo
|
||||
|
||||
# Build pstats script
|
||||
read -r -d '' PYSCRIPT << 'PYEOF' || true
|
||||
import pstats
|
||||
import sys
|
||||
import io
|
||||
|
||||
prof_path = sys.argv[1]
|
||||
sort_key = sys.argv[2]
|
||||
top_n = int(sys.argv[3])
|
||||
pattern = sys.argv[4]
|
||||
callers = sys.argv[5] == "1"
|
||||
|
||||
p = pstats.Stats(prof_path, stream=sys.stdout)
|
||||
p.strip_dirs()
|
||||
p.sort_stats(sort_key)
|
||||
|
||||
if pattern:
|
||||
if callers:
|
||||
p.print_callers(pattern, top_n)
|
||||
else:
|
||||
p.print_stats(pattern, top_n)
|
||||
else:
|
||||
if callers:
|
||||
p.print_callers(top_n)
|
||||
else:
|
||||
p.print_stats(top_n)
|
||||
PYEOF
|
||||
|
||||
exec python3 -c "$PYSCRIPT" "$PROF" "$SORT" "$TOP" "$PATTERN" "$( $CALLERS && echo 1 || echo 0 )"
|
||||
15
tools/restart
Executable file
15
tools/restart
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop, rebuild, and start the derp container.
|
||||
# Usage: tools/restart [--no-cache]
|
||||
|
||||
# shellcheck source=tools/_common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||
|
||||
args=()
|
||||
[[ "${1:-}" == "--no-cache" ]] && args+=("--no-cache")
|
||||
|
||||
"$SCRIPT_DIR/stop"
|
||||
echo
|
||||
"$SCRIPT_DIR/build" "${args[@]}"
|
||||
echo
|
||||
"$SCRIPT_DIR/start"
|
||||
23
tools/start
Executable file
23
tools/start
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the derp container.
|
||||
# Usage: tools/start
|
||||
|
||||
# shellcheck source=tools/_common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||
cd "$PROJECT_DIR" || exit 1
|
||||
|
||||
# Build first if no image exists
|
||||
if ! podman image exists "$IMAGE_NAME" 2>/dev/null; then
|
||||
dim "No image found, building..."
|
||||
"$SCRIPT_DIR/build"
|
||||
echo
|
||||
fi
|
||||
|
||||
dim "Starting container..."
|
||||
$COMPOSE up -d
|
||||
|
||||
sleep 3
|
||||
dim "Recent logs:"
|
||||
podman logs --tail 15 "$CONTAINER_NAME" 2>&1 || true
|
||||
echo
|
||||
info "Container started"
|
||||
46
tools/status
Executable file
46
tools/status
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Show container and image state.
|
||||
# Usage: tools/status
|
||||
|
||||
# shellcheck source=tools/_common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||
|
||||
# -- Container ----------------------------------------------------------------
|
||||
printf '%b%s%b\n' "$BLU" "Container" "$RST"
|
||||
state=$(podman inspect "$CONTAINER_NAME" --format '{{.State.Status}}' 2>/dev/null || true)
|
||||
if [[ -z "$state" ]]; then
|
||||
dim "absent"
|
||||
elif [[ "$state" == "running" ]]; then
|
||||
uptime=$(podman inspect "$CONTAINER_NAME" --format '{{.State.StartedAt}}' 2>/dev/null || true)
|
||||
info "running (since ${uptime%.*})"
|
||||
else
|
||||
info "$state"
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
# -- Image --------------------------------------------------------------------
|
||||
printf '%b%s%b\n' "$BLU" "Image" "$RST"
|
||||
if podman image exists "$IMAGE_NAME" 2>/dev/null; then
|
||||
img_info=$(podman image inspect "$IMAGE_NAME" --format '{{.Created}} {{.Size}}' 2>/dev/null || true)
|
||||
created="${img_info%% *}"
|
||||
size="${img_info##* }"
|
||||
human=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
|
||||
info "$IMAGE_NAME ($human, ${created%T*})"
|
||||
else
|
||||
dim "no image"
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
# -- Volumes ------------------------------------------------------------------
|
||||
printf '%b%s%b\n' "$BLU" "Mounts" "$RST"
|
||||
mounts=(src plugins config/derp.toml data secrets)
|
||||
for m in "${mounts[@]}"; do
|
||||
path="$PROJECT_DIR/$m"
|
||||
if [[ -e "$path" ]]; then
|
||||
info "$m"
|
||||
else
|
||||
err "$m (missing)"
|
||||
fi
|
||||
done
|
||||
11
tools/stop
Executable file
11
tools/stop
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop and remove the derp container.
|
||||
# Usage: tools/stop
|
||||
|
||||
# shellcheck source=tools/_common.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||
cd "$PROJECT_DIR" || exit 1
|
||||
|
||||
dim "Stopping container..."
|
||||
$COMPOSE down
|
||||
info "Container stopped"
|
||||
Reference in New Issue
Block a user