Default !similar now discovers similar artists/tracks, resolves each
against YouTube in parallel via ThreadPoolExecutor, fades out current
playback, and starts the new playlist. Old display behavior moves to
!similar list subcommand.
New helpers: _search_queries() normalizes Last.fm/MB results into search
strings, _resolve_playlist() resolves queries to _Track objects in
parallel. Falls back to display mode when music plugin not loaded.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Plugin name at column 0, command at indent 4, docstring at indent 8.
Single-command paste keeps command at 0, docstring at 4.
Only paste when actual docstring content exists.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Command name stays flush-left, docstring lines indented 4 spaces.
Plugin descriptions in the full reference also indented under headers.
Adds test_help_paste_body_indented to verify formatting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
!help <cmd> now pastes the command's docstring and appends the URL.
!help <plugin> pastes detail for all plugin commands.
!help (no args) pastes a full reference grouped by plugin.
Falls back gracefully when flaskpaste is not loaded or paste fails.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
_current_meta now checks registry._bots for peer bot music state
when the current bot has nothing playing. Fixes merlin reporting
"Nothing playing" while derp is actively streaming.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove early return on missing Last.fm API key. Both commands now
fall back to MusicBrainz (mb_search_artist -> mb_artist_tags ->
mb_find_similar_recordings) when no API key is configured or when
Last.fm returns empty results. Same pattern used by discover_similar.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Admin-only command with subcommand dispatch for server management:
kick, ban, mute/unmute, deafen/undeafen, move, users, channels,
mkchan, rmchan, rename, desc. Auto-loads on merlin via except_plugins.
Every Nth autoplay pick (configurable via discover_ratio), query Last.fm
for similar tracks. When Last.fm has no key or returns nothing, fall back
to MusicBrainz tag-based recording search (no API key needed). Discovered
tracks are resolved via yt-dlp and deduplicated within the session. If
discovery fails, the kept-deck shuffle continues as before.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
derp now handles both listening and speaking, so audition no longer
needs cross-bot lookup or dual-play through merlin.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add !playlist import <name> <url> to resolve and save tracks from a URL
without queueing them. Add !playlist show <name> to display tracks in a
saved playlist via long_reply (auto-pastes on overflow). Add optional
'shuffle' keyword to !playlist load for randomized playback order.
- Move TTS greeting from mumble._play_greet to voice.on_connected
(fires once on first connect, gated on _is_audio_ready)
- Add initial_prompt multipart field to Whisper STT for trigger word
bias (auto-generated from trigger config, overridable)
- Enhanced !queue: elapsed/total on now-playing, per-track durations,
footer with track count and total time
- New !playlist command: save/load/list/del named playlists via
bot.state persistence (playlist:<name> keys)
- Fix duck floor test (1% -> 2% to match default change)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add ascending two-tone chime (880Hz/1320Hz) before TTS playback
as audible acknowledgment that the voice trigger was recognized
- Signal music ducking 1.5s before TTS starts so music is already
lowered when audio begins playing
- Snap duck volume to floor instantly on voice packet or user unmute
via pymumble callback, eliminating the 1s poll delay
- Register USERUPDATED callback to preemptively duck when a user
unmutes (they're about to speak)
- Strip leading punctuation from trigger remainder (Whisper artifacts)
- _cleanup_track: never delete files from kept directory (data/music/)
even when track.keep=False -- fixes kept files vanishing on replay
- !kept rm: skip to next track if removing the currently playing one
- !skip: silent (no reply), restarts play loop for autoplay on empty queue
- TTS plays through merlin's own connection instead of derp's, preventing
choppy audio when music and TTS compete for the same output buffer
- !play recognizes bare YouTube video IDs (11-char alphanumeric)
- !kept rm <id> subcommand for removing individual kept tracks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When the initial download failed during playback and the track streamed
directly from URL, !keep would refuse with "No local file". Now it
downloads the track on the spot before keeping it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Music:
- #random URL fragment shuffles playlist tracks before enqueuing
- Lazy playlist resolution: first 10 tracks resolve immediately,
remaining are fetched in a background task
- !kept repair re-downloads kept tracks with missing local files
- !kept shows [MISSING] marker for tracks without local files
- TTS ducking: music ducks when merlin speaks via voice peer,
smooth restore after TTS finishes
Performance (from profiling):
- Connection pool: preload_content=True for SOCKS connection reuse
- Pool tuning: 30 pools / 8 connections (up from 20/4)
- _PooledResponse wrapper for stdlib-compatible read interface
- Iterative _extract_videos (replace 51K-deep recursion with stack)
- proxy=False for local SearXNG
Voice + multi-bot:
- Per-bot voice config lookup ([<username>.voice] in TOML)
- Mute detection: skip duck silence when all users muted
- Autoplay shuffle deck (no repeats until full cycle)
- Seek clamp to track duration (prevent seek-past-end stall)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add rubberband package to container for pitch-shifting FX
- Split FX chain: rubberband CLI for pitch, ffmpeg for filters
- Configurable voice profile (voice, fx, piper params) in [voice]
- Extra bots inherit voice config (minus trigger) for own TTS
- Greeting is voice-only, spoken directly by the greeting bot
- Per-bot only_plugins/except_plugins filtering on Mumble
- Alias plugin, core plugin tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add fade_step parameter to stream_audio for fast volume ramps
- _fade_and_cancel helper: smooth ~0.8s fade before track switch
- !skip, !stop, !seek now fade out instead of cutting instantly
- !prev command: go back to previous track (10-track history stack)
- !keep fetches title/artist/duration via yt-dlp, stores in bot.state
- !kept displays metadata (title, artist, duration, file size)
- !kept clear also removes stored metadata
- 29 new tests for fade, prev, history, keep metadata
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The else branch (no active task) only cleared current, task, and
duck_vol. Now resets all play state fields to match _play_loop's
finally block: done_event, duck_task, progress, and cur_seek.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
task.cancel() triggers _play_loop's finally block asynchronously.
When cmd_skip or cmd_seek called _ensure_loop before the finally
block ran, the old task's cleanup would overwrite the new task's
state -- causing !np to report "Nothing playing" while audio
was still streaming.
Now await the cancelled task before restarting the loop, ensuring
the finally block completes first.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Single-shot (!ask) and conversational (!chat) LLM commands backed by
OpenRouter's API. Per-user history (20 msg cap), 5s cooldown, reasoning
model fallback, and model switching via subcommands.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Piper is on 192.168.129.9:5100, not :5000. It expects POST with
JSON body {"text": "..."}, not GET with query params. Also update
Whisper default to 192.168.129.9.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Support [[mumble.extra]] config for additional Mumble identities that
inherit connection settings from the main [mumble] section. Extra bots
get their own state DB and do not run the voice trigger by default.
Add TTS greeting on first connect via mumble.greet config option.
Merlin joins as a second identity with his own client certificate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pymumble passes a User object, not a dict. The isinstance(user, dict)
check returned False, setting name to None and silently discarding
every voice packet. Use try/except for dict-like access instead.
Seek to absolute or relative positions mid-track via !seek. Supports
M:SS and plain seconds with +/- prefixes. Volume is now saved to
bot.state and restored on connect.
When [voice] trigger is set in config, the bot continuously listens and
transcribes voice. Speech starting with the trigger word is stripped and
echoed back via TTS. Non-triggered speech is silently discarded unless
!listen is also active.
Whisper STT: buffers incoming voice PCM per user, transcribes on
silence gap via local whisper.cpp endpoint, posts results as actions.
Piper TTS: !say fetches WAV from local Piper endpoint and plays via
stream_audio(). 37 tests cover buffering, flush logic, transcription,
WAV encoding, commands, and lifecycle.
Audio is now downloaded to data/music/ before playback begins,
eliminating CDN hiccups mid-stream. Falls back to streaming on
download failure. Files are deleted after playback unless marked
with !keep. stream_audio detects local files and uses a direct
ffmpeg pipeline (no yt-dlp).
Guard stream_audio with _is_audio_ready() so that PCM frames are
dropped (not crashed on) when pymumble recreates SoundOutput with
encoder=None during reconnect. The ffmpeg pipeline stays alive,
position tracking remains accurate, and audio feeding resumes once
the codec is negotiated. Listeners hear brief silence instead of
a 30+ second restart with URL re-resolution.
Also adds chat messages to _auto_resume so users see what the bot
intends ("Resuming 'X' at M:SS in a moment" / "...aborted").
Auto-resume: save playback position on stream errors and cancellation,
restore automatically after reconnect or container restart once the
channel is silent. Plugin lifecycle hook (on_connected) ensures the
reconnect watcher starts without waiting for user commands.
Sorcerer tier: new permission level between oper and admin. Configured
via [mumble] sorcerers list in derp.toml.
Mumble cert auth: pass certfile/keyfile to pymumble for client
certificate authentication.
Fixes: stream_audio now re-raises CancelledError and Exception so
_play_loop detects failures correctly. Subprocess cleanup uses 3s
timeout. Graceful shutdown cancels background tasks before stopping
pymumble. Safe getattr for _opers in core plugin.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
!volume +10 increases by 10, !volume -5 decreases by 5.
Out-of-range results (below 0 or above 100) are rejected.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tracks playback position via frame counting in stream_audio().
On stop/skip, saves URL + elapsed time to bot.state (SQLite).
!resume reloads the track and seeks to the saved position via
ffmpeg -ss. State persists across bot restarts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Non-URL input (e.g. !play classical music) searches YouTube for 10
results and picks one randomly. Also fixes --flat-playlist returning
"NA" as the URL for single videos by falling back to the original
input URL.
_resolve_title replaced with _resolve_tracks using --flat-playlist to
enumerate playlist entries. cmd_play enqueues each track individually,
with truncation when the queue is nearly full. Single-video behavior
unchanged.
stream_audio now accepts a callable for volume, re-read on each PCM
frame instead of capturing a static float at track start.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
asyncio's SSL memory-BIO transport silently drops voice packets even
though text works fine. pymumble uses blocking ssl.SSLSocket.send()
which reliably delivers voice data.
- Rewrite MumbleBot to use pymumble for connection, SSL, ping, and
voice encoding/sending
- Bridge pymumble thread callbacks to asyncio via
run_coroutine_threadsafe for text dispatch
- Voice via sound_output.add_sound(pcm) -- pymumble handles Opus
encoding, packetization, and timing
- Remove custom protobuf codec, voice varint, and opus ctypes wrapper
- Add container patches for pymumble ssl.wrap_socket (Python 3.13) and
opuslib find_library (musl/Alpine)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Connect to multiple IRC servers concurrently from a single config file.
Plugins are loaded once and shared; per-server state is isolated via
separate SQLite databases and per-bot runtime state (bot._pstate).
- Add build_server_configs() for [servers.*] config layout
- Bot.__init__ gains name parameter, _pstate dict for plugin isolation
- cli.py runs multiple bots via asyncio.gather
- 9 stateful plugins migrated from module-level dicts to _ps(bot) pattern
- Backward compatible: legacy [server] config works unchanged
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
HTTP POST endpoint for external services (CI, monitoring, GitHub).
HMAC-SHA256 auth, JSON body, single POST endpoint at /.
- asyncio.start_server with raw HTTP parsing (zero deps)
- Body validation: channel prefix, non-empty text, 64KB cap
- !webhook admin command shows address, request count, uptime
- Module-level server guard prevents duplicates on reconnect
- 22 test cases in test_webhook.py
4-tier permission model: user < trusted < oper < admin.
Commands specify a required tier via tier= parameter.
Backward compatible: admin=True maps to tier="admin".
- TIERS constant and Handler.tier field in plugin.py
- _get_tier() method in bot.py with pattern matching
- _is_admin() preserved as thin wrapper
- operators/trusted config lists in config.py
- whoami shows tier, admins shows all configured tiers
- 32 test cases in test_acl.py
Admin-only plugin for interval-based command execution.
Supports add/del/list, 1m-7d intervals, 20 jobs/channel.
Persists via bot.state, restores on reconnect.
Includes test_cron.py (~38 cases).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bot.shorten_url() method delegates to flaskpaste plugin when loaded.
RSS, YouTube, and pastemoni announcements auto-shorten links.
Includes test_flaskpaste.py (9 cases) and FakeBot updates in 3 test files.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add cmd_paste to flaskpaste plugin (create paste, return URL).
Add test suites for encode, hash, defang, cidr, and dns plugins
(83 new test cases, 1093 total).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
canary: generate realistic fake credentials (token/aws/basic) for
planting as canary tripwires. Per-channel state persistence.
tcping: TCP connect latency probe through SOCKS5 proxy with
min/avg/max reporting. Proxy-compatible alternative to traceroute.
archive: save URLs to Wayback Machine via Save Page Now API,
routed through SOCKS5 proxy.
resolve: bulk DNS resolution (up to 10 hosts) via TCP DNS through
SOCKS5 proxy with concurrent asyncio.gather.
83 new tests (1010 total), docs updated.
YouTube's InnerTube /player endpoint now returns LOGIN_REQUIRED for the
WEB client. Switch to ANDROID client context which still returns full
videoDetails. Fixes missing duration in announcements and broken channel
resolution from video URLs.
Extract shared _innertube_player() helper to deduplicate payload
construction between _resolve_via_innertube and _fetch_duration.
Free, keyless API returning open ports, hostnames, CPEs, tags, and
known CVEs for any public IP. All requests routed through SOCKS5.
21 test cases (927 total).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Drop GeoLite2-ASN.mmdb dependency (required license key) in favor of
iptoasn.com ip2asn-v4.tsv (no auth, public domain). Bisect-based
lookup in pure stdlib, downloaded via SOCKS5 in update-data.sh.
Adds 30 test cases covering load, lookup, and command handler.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>