Compare commits

...

93 Commits

Author SHA1 Message Date
user
6e1c32f22c fix: add pythonpath to pytest config for CI plugin imports
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Successful in 23s
CI / test (3.11) (push) Failing after 2m48s
CI / test (3.13) (push) Successful in 2m50s
CI / test (3.12) (push) Successful in 2m52s
CI / build (push) Has been skipped
The patch("plugins._musicbrainz...") calls in tests need plugins/
on sys.path. Works locally from repo root but fails in CI containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:57:00 +01:00
user
f470d6d958 fix: lint errors in test_musicbrainz (unused import, line length)
Some checks failed
CI / gitleaks (push) Failing after 4s
CI / lint (push) Successful in 23s
CI / test (3.11) (push) Failing after 2m43s
CI / test (3.13) (push) Failing after 2m47s
CI / test (3.12) (push) Failing after 2m50s
CI / build (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:40:43 +01:00
user
28f4c63e99 fix: delegate !similar playback to music bot, not calling bot
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
When merlin ran !similar, music operations (fade, queue, play loop)
targeted merlin instead of derp, causing audio to play over derp's
stream. New _music_bot() helper resolves the DJ bot via active state
or plugin filter config, so playback always routes to derp.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:39:38 +01:00
user
dd4c6b95b7 feat: rework !similar to build and play discovery playlists
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>
2026-02-23 23:56:51 +01:00
user
b658053711 docs: describe 3-level help paste hierarchy
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
USAGE.md: add Detailed Help section with format example.
CHEATSHEET.md: note hierarchy layout in help description.
TASKS.md: update sprint with hierarchy task and test count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:22:49 +01:00
user
20c1d738be fix: 3-level hierarchy in help paste output
Some checks failed
CI / gitleaks (push) Failing after 4s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
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>
2026-02-23 23:21:10 +01:00
user
ecfa7cea39 fix: indent docstring body in help paste output
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
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>
2026-02-23 23:12:10 +01:00
user
ef18915807 docs: update help command docs for FlaskPaste detail pages
Some checks failed
CI / gitleaks (push) Failing after 4s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
USAGE.md, CHEATSHEET.md, and TASKS.md updated to reflect enhanced
!help output that pastes detailed docstrings via FlaskPaste.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:04:36 +01:00
user
69976196cd feat: paste detailed help via FlaskPaste for !help command
!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>
2026-02-23 22:51:55 +01:00
user
c851e82990 fix: let extra bots see peer music state for !similar/!tags
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
_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>
2026-02-23 22:17:29 +01:00
user
ad12843e75 docs: add music discovery, autoplay, and Mumble admin sections
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
- USAGE.md: music discovery (!similar, !tags), autoplay discovery
  config, Mumble admin (!mu) command reference
- CHEATSHEET.md: music discovery and Mumble admin quick reference
- ROADMAP.md: mark v2.4.0 as done, add MB fallback + !mu + autoplay
- TODO.md: mark music discovery and performance items as done
- PROJECT.md: update plugin categories table
- TASKS.md: close open doc items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:04:03 +01:00
user
62a4191200 docs: update TASKS.md with MusicBrainz fallback sprint
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Failing after 25s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:00:02 +01:00
user
135a3791e2 feat: add MusicBrainz fallback to !similar and !tags commands
Some checks failed
CI / gitleaks (push) Failing after 2s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
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>
2026-02-23 21:56:39 +01:00
user
a87f75adf1 feat: add Mumble server admin plugin (!mu)
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.
2026-02-23 21:44:38 +01:00
user
da9ed51c74 feat: auto-discover similar tracks during autoplay via Last.fm/MusicBrainz
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>
2026-02-23 21:19:41 +01:00
user
56f6b9822f fix: revert pymumble protocol version patch that broke audio
Reporting version 1.5.0 to mumble-server caused it to expect the newer
audio frame format (terminator bit on opus length varint). pymumble
1.6.1 does not implement this, so the server silently dropped every
UDPTunnel audio packet. Revert to native 1.2.4 -- audio works, only
side effect is a cosmetic ChannelListener warning.

Also remove --cprofile from docker-compose command.
2026-02-23 18:51:53 +01:00
user
09880624d5 fix: add bot name to stream_audio log lines
Multi-bot setups need to know which bot is streaming. Prefixes all
stream_audio log messages with [username] for clarity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:15:46 +01:00
user
3c475107e3 refactor: simplify audition to single-bot playback
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>
2026-02-22 23:07:08 +01:00
user
b3006b02e2 feat: auto-register mumble bots on first connect
Self-register with the Mumble server on initial connection so both
derp and merlin get persistent identities (cert-bound user_id).
Skips registration on reconnect or if already registered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:32:36 +01:00
user
8b504364a9 fix: patch pymumble protocol version and harden mumble connections
- Update reported protocol version from 1.2.4 to 1.5.0 so modern
  Murmur servers treat PyMumble as a compatible client
- Fix OS string to report actual platform instead of "PyMumble 1.6.1"
  (was shown as [Invalid] by Murmur)
- Raise pymumble reconnect retry interval to 15s to prevent autoban
  when running multiple bots from the same IP
- Enable TCP keepalive on control socket (10s idle) to prevent NAT
  gateways from dropping long-lived connections

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:28:57 +01:00
user
40c6bf8c53 feat: playlist import, show, and shuffle-on-load
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.
2026-02-22 20:31:54 +01:00
user
a76d46b1de fix: graceful SIGTERM shutdown for IRC and Mumble bots
Send IRC QUIT before closing the socket so the server releases the nick
immediately. Wrap readline() in wait_for(timeout=2.0) so the _running
check triggers even when the server is quiet. Explicitly stop pymumble
in the signal handler to accelerate Mumble teardown. Together these
eliminate the 10s podman grace-period SIGKILL.
2026-02-22 20:31:49 +01:00
user
0ffddb8e41 fix: write cProfile to data/ volume for host access
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:02:14 +01:00
user
62b01c76f7 fix: reduce cProfile dump interval to 10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:00:06 +01:00
user
e0db0ad567 fix: periodic cProfile dump every 60s (survives hard kills)
Replace cProfile.runctx with Profile() + daemon thread that dumps
stats to disk every 60 seconds. Final dump on clean exit. Profile
data no longer lost on container stop, OOM, or SIGKILL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:56:21 +01:00
user
c41035ceca test: add test_lastfm.py (50 cases)
Covers API helpers (_get_similar_artists, _get_top_tags,
_get_similar_tracks, _search_track), config resolution, metadata
extraction from track titles, match score formatting, and both
commands (!similar, !tags) including play mode delegation, fallback
from track to artist similarity, and current-track integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:52:16 +01:00
user
cd4124e07a fix: route alert YouTube/SearXNG through pooled urlopen
- YouTube InnerTube search: urllib.request.urlopen -> _urlopen (gets
  connection pooling + SOCKS5 proxy)
- SearXNG search: urllib.request.urlopen -> _urlopen(proxy=False)
  (local service, skip proxy, get pooling)
- Update 5 tests to patch _urlopen instead of urllib.request.urlopen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:39:38 +01:00
user
717bf59a05 feat: playlist save/load, queue durations, whisper bias, greet fix
- 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>
2026-02-22 19:23:03 +01:00
user
5d0e200fbe docs: update TASKS.md for voice/duck sprint
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Successful in 23s
CI / test (3.11) (push) Failing after 2m40s
CI / test (3.13) (push) Failing after 2m43s
CI / test (3.12) (push) Failing after 2m45s
CI / build (push) Has been skipped
2026-02-22 18:55:00 +01:00
user
8d54322ce1 fix: raise duck floor default from 1% to 2%
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Successful in 23s
CI / test (3.11) (push) Has started running
CI / test (3.12) (push) Has started running
CI / build (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
Keep music audible during voice ducking instead of near-silent.
2026-02-22 18:53:42 +01:00
user
e920ec5f10 fix: duck on audio packets only, remove unmute-based ducking
Instant ducking is purely packet-based now -- _instant_duck() fires
on _on_sound_received, not on user state changes. Removes the
USERUPDATED callback that preemptively ducked on unmute.
2026-02-22 18:51:38 +01:00
user
c522d30c36 feat: ack tone, duck-before-TTS, instant ducking on voice/unmute
- 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)
2026-02-22 18:46:33 +01:00
user
068734d931 fix: kept file protection, skip/autoplay, TTS routing, video ID expansion
- _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>
2026-02-22 18:00:23 +01:00
user
36da191b45 fix: download track on !keep when local file is missing
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>
2026-02-22 17:01:44 +01:00
user
6083de13f9 feat: playlist shuffle, lazy resolution, TTS ducking, kept repair
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Successful in 22s
CI / test (3.11) (push) Failing after 2m47s
CI / test (3.13) (push) Failing after 2m52s
CI / test (3.12) (push) Failing after 2m54s
CI / build (push) Has been skipped
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>
2026-02-22 16:21:47 +01:00
user
6d6b957557 docs: update roadmap, tasks, and backlog for v2.3.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:10:28 +01:00
user
f72f55148b fix: ignore bot audio in sound callback, self-mute support
- _on_sound_received skips audio from our own bots entirely,
  preventing self-ducking and STT transcribing bot TTS/music
- New self_mute config: mute on connect, unmute before stream_audio,
  re-mute 3s after audio finishes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:09:30 +01:00
user
e9d17e8b00 feat: voice profiles, rubberband FX, per-bot plugin filtering
- 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>
2026-02-22 11:41:00 +01:00
user
3afeace6e7 feat: container management tools in tools/
Shell scripts for build, start, stop, restart, nuke, logs, status.
Shared helpers in _common.sh (colours, compose detection, project root).
Updated CHEATSHEET.md with new tool references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 11:40:53 +01:00
user
b88a459142 feat: music library management, smooth fades, clickable URLs
- Audio-only downloads (-x), resume (-c), skip existing (--no-overwrites)
- Title-based filenames (e.g. never-gonna-give-you-up.opus)
- Separate cache (data/music/cache/) from kept tracks (data/music/)
- Kept track IDs: !keep assigns #id, !play #id, !kept shows IDs
- Linear fade-in (5s) and fade-out (3s) with volume-proportional step
- Fix ramp click: threshold-based convergence instead of float equality
- Clean up cache files for skipped/stopped tracks
- Auto-linkify URLs in Mumble text chat (clickable <a> tags)
- FlaskPaste links use /raw endpoint for direct content access
- Metadata fetch uses --no-playlist for reliable results

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:11:29 +01:00
user
ad1de1653e fix: gitleaks clone depth and opuslib discovery on musl
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Successful in 27s
CI / test (3.11) (push) Successful in 2m13s
CI / test (3.13) (push) Successful in 2m18s
CI / test (3.12) (push) Successful in 2m20s
CI / build (push) Has been skipped
Remove invalid --depth=0 from gitleaks checkout (needs full history).
Add opus symlink and pymumble/opuslib patch step to test jobs so
ctypes.find_library works on Alpine/musl.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:11:50 +01:00
user
8f1df167b9 feat: fade-out on skip/stop/prev, song metadata on keep
Some checks failed
CI / gitleaks (push) Failing after 4s
CI / lint (push) Successful in 24s
CI / test (3.11) (push) Failing after 30s
CI / test (3.13) (push) Failing after 34s
CI / test (3.12) (push) Failing after 36s
CI / build (push) Has been skipped
- 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>
2026-02-22 06:38:25 +01:00
user
de2d1fdf15 fix: replace actions/checkout with git clone in container jobs
Some checks failed
CI / gitleaks (push) Failing after 4s
CI / lint (push) Successful in 25s
CI / test (3.11) (push) Failing after 32s
CI / test (3.13) (push) Failing after 35s
CI / test (3.12) (push) Failing after 39s
CI / build (push) Has been skipped
actions/checkout@v4 requires node, which isn't available in
alpine or gitleaks images. Use plain git clone instead for
containerized jobs; keep actions/checkout for the host build job.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 06:23:49 +01:00
user
82f5984631 ci: use 'linux' runner label
Some checks failed
CI / gitleaks (push) Failing after 5s
CI / lint (push) Failing after 9s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
2026-02-22 06:20:48 +01:00
user
1744e7087f ci: re-trigger pipeline
Some checks failed
CI / gitleaks (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
CI / build (push) Has been cancelled
2026-02-22 06:18:27 +01:00
user
0c0adef90d feat: run CI jobs in podman containers, add requirements-dev.txt
Some checks failed
CI / gitleaks (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
CI / build (push) Has been cancelled
- Use container: directive for gitleaks, lint, and test jobs
- Build job stays on host (needs podman for image build/push)
- Add requirements-dev.txt for unified dev/test dependency install

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 06:15:42 +01:00
user
3dada3fc06 fix: allowlist tests/ in gitleaks, add libopus for CI test job
Some checks failed
CI / gitleaks (push) Successful in 12s
CI / lint (push) Successful in 18s
CI / test (3.11) (push) Successful in 2m5s
CI / test (3.12) (push) Successful in 2m5s
CI / test (3.13) (push) Successful in 2m3s
CI / build (push) Failing after 12s
- gitleaks flagged fake api_key in test fixtures as a secret leak;
  allowlist tests/ directory since it contains only mock data
- Install libopus0 in test runner for pymumble/opuslib import chain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 06:09:28 +01:00
user
6e40daa8a9 fix: resolve CI gitleaks download and missing pymumble dep
Some checks failed
CI / gitleaks (push) Failing after 12s
CI / lint (push) Successful in 17s
CI / test (3.11) (push) Failing after 25s
CI / test (3.12) (push) Failing after 28s
CI / test (3.13) (push) Failing after 27s
CI / build (push) Has been skipped
- Dynamically resolve latest gitleaks version from GitHub releases
  instead of hardcoded tarball URL that 404'd
- Add pymumble to test job install (needed by derp.mumble import
  chain, not in pyproject.toml base deps)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 06:06:47 +01:00
user
ba1af461de fix: use gitleaks CLI instead of licensed action, fix lint errors
Some checks failed
CI / gitleaks (push) Failing after 10s
CI / lint (push) Successful in 18s
CI / test (3.11) (push) Failing after 19s
CI / test (3.12) (push) Failing after 20s
CI / test (3.13) (push) Failing after 20s
CI / build (push) Has been skipped
- Replace gitleaks-action (requires paid license) with direct CLI
  invocation -- same engine, no license needed
- Fix ruff I001 import sorting in voice.py and test_llm.py
- Remove unused imports: _chat_request (test_llm), Path (test_music)
- Remove unused assignment: original_spawn (test_voice)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 05:58:10 +01:00
user
004656a64f feat: add Harbor image build+push to CI pipeline
Some checks failed
CI / gitleaks (push) Failing after 10s
CI / lint (push) Failing after 17s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
Build and push to harbor.mymx.me/library/derp after gitleaks
and test jobs pass. Only runs on push to master. Tags with
short SHA and latest.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 05:55:34 +01:00
user
192ea717a7 feat: split CI into gitleaks, lint, and test jobs
Some checks failed
CI / gitleaks (push) Failing after 15s
CI / lint (push) Failing after 17s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
- Add gitleaks secret scanning (full history)
- Separate lint (ruff, Python 3.13 only) from test matrix
- Test job gates on lint; gitleaks runs in parallel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 05:51:53 +01:00
user
7a4aa65882 fix: align cmd_stop else branch with _play_loop finally cleanup
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>
2026-02-22 05:49:53 +01:00
user
2cd1d5efb1 fix: race condition in skip/seek/stop losing track state
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>
2026-02-22 05:45:00 +01:00
user
95981275b5 feat: add OpenRouter LLM chat plugin (!ask, !chat)
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>
2026-02-22 05:39:11 +01:00
user
66116d2caf docs: update Piper TTS endpoint and document available voices
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 05:01:57 +01:00
user
eded764f6a fix: update Piper TTS endpoint and request format
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>
2026-02-22 04:56:24 +01:00
user
9783365b1e feat: add extra Mumble bot instances and TTS greeting
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>
2026-02-22 04:34:10 +01:00
user
165938a801 fix: mumble disconnect loop from stale socket and dead parent thread
Two issues causing ~2min reconnect cycles:

1. pymumble captures threading.current_thread() as parent_thread at
   init. Since we construct it in a run_in_executor thread, the parent
   dies after _connect_sync returns, triggering pymumble's loop exit.
   Fix: point parent_thread at threading.main_thread().

2. pymumble's init_connection() drops the control_socket reference
   without closing it. The lingering TCP connection makes Murmur kick
   the new session with "connected from another device". Fix: patch
   init_connection to close the old socket before reconnecting.
2026-02-22 04:24:23 +01:00
user
221cb1f06b fix: voice trigger not receiving audio from pymumble
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.
2026-02-22 04:02:23 +01:00
user
c4908f2a63 docs: document seek command and volume persistence 2026-02-22 03:31:39 +01:00
user
c493583a71 feat: add !seek command and persist volume across restarts
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.
2026-02-22 03:31:35 +01:00
user
7c099d8cf0 docs: document voice trigger configuration 2026-02-22 03:24:07 +01:00
user
e127f72660 feat: add always-on voice trigger mode with TTS echo
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.
2026-02-22 03:24:03 +01:00
user
7b9359c152 docs: document voice plugin commands
Add Voice STT/TTS section covering !listen, !say, and optional
[voice] config block with whisper_url, piper_url, silence_gap.
2026-02-22 03:08:10 +01:00
user
9fbf45f67d feat: add voice plugin with STT and TTS
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.
2026-02-22 03:08:02 +01:00
user
039f060b50 feat: add sound listener hook to MumbleBot
Allow plugins to register callbacks for incoming voice PCM via
bot._sound_listeners. Empty list by default = zero overhead.
2026-02-22 03:07:55 +01:00
user
df20c154ca feat: download audio before playback, add !keep and !kept commands
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).
2026-02-22 02:52:51 +01:00
user
ab924444de fix: survive mumble disconnects without restarting audio stream
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").
2026-02-22 02:41:44 +01:00
user
ec55c2aef1 feat: auto-resume music on reconnect, sorcerer tier, cert auth
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>
2026-02-22 02:14:43 +01:00
user
f899241d73 feat: support relative volume adjustment (+N/-N)
!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>
2026-02-22 00:18:43 +01:00
user
f189cbd290 feat: add !resume to continue playback from last interruption
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>
2026-02-22 00:15:39 +01:00
user
9d58a5d073 fix: slow volume ramp to 1s for smoother transitions
Reduce _max_step from 0.1 to 0.02 per frame, extending the full
0-to-1 volume ramp from ~200ms to ~1 second.
2026-02-21 23:56:04 +01:00
user
e4e1e219f0 feat: add YouTube search to !play and fix NA URL fallback
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.
2026-02-21 23:52:01 +01:00
user
6b7d733650 feat: smooth volume ramping over 200ms in audio streaming
Some checks failed
CI / test (3.11) (push) Failing after 22s
CI / test (3.12) (push) Failing after 22s
CI / test (3.13) (push) Failing after 22s
Volume changes now ramp linearly per-sample via _scale_pcm_ramp instead
of jumping abruptly. Each frame steps _cur_vol toward target by at most
0.1, giving ~200ms for a full 0-to-1 sweep. Fast path unchanged when
volume is stable.
2026-02-21 23:32:22 +01:00
user
c5c61e63cc feat: expand YouTube playlists into individual queue tracks
_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.
2026-02-21 23:32:16 +01:00
user
67b2dc827d fix: make !volume apply immediately during playback
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>
2026-02-21 23:20:17 +01:00
user
eae36aa1f9 docs: update Mumble docs for pymumble transport
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:16:56 +01:00
user
d884d2bb55 refactor: switch Mumble voice to pymumble transport
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>
2026-02-21 23:15:42 +01:00
user
d756e7c020 fix: add opus, ffmpeg, yt-dlp to container image
Music playback requires system libraries that were missing from the
Alpine-based container.
2026-02-21 21:47:49 +01:00
user
7206b27fb0 docs: add music playback documentation
USAGE.md music section under Mumble, CHEATSHEET.md music commands,
TASKS.md sprint update for v2.3.0.
2026-02-21 21:42:33 +01:00
user
47b13c3f1f feat: add Mumble music playback with Opus streaming
ctypes libopus encoder (src/derp/opus.py), voice varint/packet builder
and stream_audio method on MumbleBot (src/derp/mumble.py), music plugin
with play/stop/skip/queue/np/volume commands (plugins/music.py).
Audio pipeline: yt-dlp|ffmpeg subprocess -> PCM -> Opus -> UDPTunnel.
67 new tests (1561 total).
2026-02-21 21:42:28 +01:00
user
b074356ec6 fix: always pass server_hostname for Mumble TLS on pre-connected socket
asyncio.open_connection(sock=..., ssl=...) requires server_hostname
even when check_hostname is disabled. Pass self._host unconditionally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:21:33 +01:00
user
9d4cb09069 feat: make SOCKS5 proxy configurable per adapter
Add `proxy` config option to server (IRC), teams, telegram, and mumble
sections. IRC defaults to false (preserving current direct-connect
behavior); all others default to true. The `derp.http` module now
accepts `proxy=True/False` on urlopen, create_connection,
open_connection, and build_opener -- when false, uses stdlib directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:19:22 +01:00
user
ca46042c41 docs: update docs for Mumble integration
Add Mumble sections to USAGE.md, CHEATSHEET.md, API.md, README.md.
Mark Mumble done in ROADMAP.md and TODO.md. Update TASKS.md sprint.
2026-02-21 21:02:46 +01:00
user
37c858f4d7 feat: add Mumble bot adapter with minimal protobuf codec
TCP/TLS connection over SOCKS5 proxy to Mumble servers for text chat.
Minimal varint/field protobuf encoder/decoder (no external dep) handles
Version, Authenticate, Ping, ServerSync, ChannelState, UserState, and
TextMessage message types. MumbleBot exposes the same duck-typed plugin
API as Bot/TeamsBot/TelegramBot. 93 new tests (1470 total).
2026-02-21 21:02:41 +01:00
user
0d92e6ed31 docs: update docs for Telegram integration 2026-02-21 20:06:29 +01:00
user
3bcba8b0a9 feat: add Telegram bot support via long-polling
TelegramBot adapter with getUpdates long-polling, all HTTP through
SOCKS5 proxy. Duck-typed TelegramMessage compatible with IRC Message.
Message splitting at 4096 chars, @botusername suffix stripping,
permission tiers via user IDs. 75 test cases.
2026-02-21 20:06:25 +01:00
user
4a304f2498 fix: route Teams send() through SOCKS5 proxy
Teams send() used urllib.request.urlopen directly, bypassing the SOCKS5
proxy. Replace with derp.http.urlopen to match all other outbound HTTP.
2026-02-21 20:06:20 +01:00
user
4a165e8b28 docs: update docs for Teams integration
- USAGE.md: Teams Integration section (config, setup, compat matrix)
- CHEATSHEET.md: Teams config snippet
- API.md: TeamsBot and TeamsMessage reference
- README.md: Teams in features list
- ROADMAP.md: v2.1.0 milestone
- TODO.md/TASKS.md: Teams items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:52:39 +01:00
user
014b609686 feat: add Microsoft Teams support via outgoing webhooks
TeamsBot adapter exposes the same plugin API as IRC Bot so ~90% of
plugins work without modification.  Uses raw asyncio HTTP server
(no MS SDK dependency) with HMAC-SHA256 signature validation.

- TeamsMessage dataclass duck-typed with IRC Message
- Permission tiers via AAD object IDs (exact match)
- Reply buffer collected and returned as HTTP JSON response
- Incoming webhook support for proactive send()
- IRC-only methods (join/part/kick/mode) as no-ops
- 74 new tests (1302 total)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:52:33 +01:00
user
c8879f6089 feat: add stable plugin API reference and bump to v2.0.0
Document the full public plugin surface (decorators, bot methods, IRC
primitives, state store, HTTP/DNS helpers) with semver stability
guarantees and breaking-change policy. Bump version from 0.1.0 to 2.0.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:22:47 +01:00
user
144193e3bb docs: update docs for multi-server support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:04:23 +01:00
user
073659607e feat: add multi-server support
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>
2026-02-21 19:04:20 +01:00
82 changed files with 18617 additions and 859 deletions

View File

@@ -4,17 +4,80 @@ on:
branches: [master] branches: [master]
pull_request: pull_request:
branches: [master] branches: [master]
env:
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
jobs: 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: test:
runs-on: ubuntu-latest runs-on: linux
needs: [lint]
strategy: strategy:
matrix: matrix:
python-version: ["3.11", "3.12", "3.13"] 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - name: Login to Harbor
with: run: >-
python-version: ${{ matrix.python-version }} podman login harbor.mymx.me
- run: pip install -e . && pip install pytest ruff -u "${{ secrets.HARBOR_USER }}"
- run: ruff check src/ tests/ plugins/ -p "${{ secrets.HARBOR_PASS }}"
- run: pytest -v - 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
View File

@@ -0,0 +1,2 @@
[allowlist]
paths = ["tests/"]

View File

@@ -1,10 +1,17 @@
FROM python:3.13-alpine FROM python:3.13-alpine
RUN apk add --no-cache opus ffmpeg yt-dlp rubberband && \
ln -s /usr/lib/libopus.so.0 /usr/lib/libopus.so
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Patch pymumble for Python 3.13 (ssl.wrap_socket was removed)
COPY patches/apply_pymumble_ssl.py /tmp/apply_pymumble_ssl.py
RUN python3 /tmp/apply_pymumble_ssl.py && rm /tmp/apply_pymumble_ssl.py
ENV PYTHONPATH=/app/src ENV PYTHONPATH=/app/src
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENTRYPOINT ["python", "-m", "derp"] ENTRYPOINT ["python", "-m", "derp"]

View File

@@ -28,10 +28,13 @@ CLI (argparse) -> Config (TOML) -> Bot (orchestrator)
| Category | Plugins | Purpose | | Category | Plugins | Purpose |
|----------|---------|---------| |----------|---------|---------|
| Core | core | Bot management, help, plugin lifecycle | | 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 | | Red Team | revshell, encode, hash | Offensive tooling |
| OPSEC | defang | Safe IOC handling | | 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 ### Key Design Decisions

View File

@@ -24,9 +24,12 @@ make down # Stop
## Features ## Features
- Async IRC over plain TCP or TLS (SASL PLAIN auth, IRCv3 CAP negotiation) - Async IRC over plain TCP or TLS (SASL PLAIN auth, IRCv3 CAP negotiation)
- Microsoft Teams support via outgoing webhooks (no SDK dependency)
- Telegram support via long-polling (no SDK dependency, SOCKS5 proxied)
- Mumble support via TCP/TLS protobuf control channel (text only, SOCKS5 proxied)
- Plugin system with `@command` and `@event` decorators - Plugin system with `@command` and `@event` decorators
- Hot-reload: load, unload, reload plugins at runtime - Hot-reload: load, unload, reload plugins at runtime
- Admin permission system (hostmask patterns + IRCOP detection) - Admin permission system (hostmask patterns + IRCOP detection + AAD IDs)
- Command shorthand: `!h` resolves to `!help` (unambiguous prefix matching) - Command shorthand: `!h` resolves to `!help` (unambiguous prefix matching)
- TOML configuration with sensible defaults - TOML configuration with sensible defaults
- Rate limiting, CTCP responses, auto reconnect - Rate limiting, CTCP responses, auto reconnect
@@ -104,6 +107,7 @@ async def on_join(bot, message):
## Documentation ## Documentation
- [Plugin API Reference](docs/API.md)
- [Installation](docs/INSTALL.md) - [Installation](docs/INSTALL.md)
- [Usage Guide](docs/USAGE.md) - [Usage Guide](docs/USAGE.md)
- [Cheatsheet](docs/CHEATSHEET.md) - [Cheatsheet](docs/CHEATSHEET.md)

View File

@@ -110,8 +110,8 @@
## v2.0.0 -- Multi-Server + Integrations ## v2.0.0 -- Multi-Server + Integrations
- [ ] Multi-server support (per-server config, shared plugins) - [x] Multi-server support (per-server config, shared plugins)
- [ ] Stable plugin API (versioned, breaking change policy) - [x] Stable plugin API (versioned, breaking change policy)
- [x] Paste overflow (auto-paste long output to FlaskPaste, return link) - [x] Paste overflow (auto-paste long output to FlaskPaste, return link)
- [x] URL shortener integration (shorten URLs in subscription announcements) - [x] URL shortener integration (shorten URLs in subscription announcements)
- [x] Webhook listener (HTTP endpoint for push events to channels) - [x] Webhook listener (HTTP endpoint for push events to channels)
@@ -128,3 +128,78 @@
- [x] `cron` plugin (scheduled bot commands on a timer) - [x] `cron` plugin (scheduled bot commands on a timer)
- [x] Plugin command unit tests (encode, hash, dns, cidr, defang) - [x] Plugin command unit tests (encode, hash, dns, cidr, defang)
- [x] CI pipeline (Gitea Actions, Python 3.11-3.13, ruff + pytest) - [x] CI pipeline (Gitea Actions, Python 3.11-3.13, ruff + pytest)
## v2.1.0 -- Teams + Telegram Integration
- [x] Microsoft Teams adapter via outgoing webhooks (no SDK)
- [x] `TeamsBot` class with same plugin API as IRC `Bot`
- [x] `TeamsMessage` dataclass duck-typed with IRC `Message`
- [x] HMAC-SHA256 webhook signature validation
- [x] Permission tiers via AAD object IDs
- [x] IRC-only methods as no-ops (join, part, kick, mode, set_topic)
- [x] Incoming webhook support for `send()` (proactive messages)
- [x] Paste overflow via FlaskPaste (same as IRC)
- [x] Teams `send()` routed through SOCKS5 proxy (bug fix)
- [x] Telegram adapter via long-polling (`getUpdates`, no SDK)
- [x] `TelegramBot` class with same plugin API as IRC `Bot`
- [x] `TelegramMessage` dataclass duck-typed with IRC `Message`
- [x] All Telegram HTTP through SOCKS5 proxy
- [x] Message splitting at 4096-char limit
- [x] `@botusername` suffix stripping in groups
- [ ] Adaptive Cards for richer formatting (Teams)
- [ ] Graph API integration for DMs and richer channel access (Teams)
- [ ] Teams event handlers (member join/leave)
## v2.2.0 -- Protocol Expansion
- [x] Mumble adapter via TCP/TLS protobuf control channel (text chat only)
- [ ] Discord adapter via WebSocket gateway + REST API
- [ ] Matrix adapter via long-poll `/sync` endpoint
- [ ] XMPP adapter via persistent TCP + XML stanzas (MUC support)
- [ ] 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)

182
TASKS.md
View File

@@ -1,6 +1,181 @@
# derp - Tasks # derp - Tasks
## Current Sprint -- v2.0.0 ACL + Webhook (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 |
|-----|--------|------|
| P0 | [x] | `src/derp/mumble.py` -- rewrite to pymumble transport (voice + text) |
| P0 | [x] | `plugins/music.py` -- play/stop/skip/queue/np/volume commands |
| P0 | [x] | Container patches for pymumble ssl + opuslib musl |
| P1 | [x] | Tests: `test_mumble.py` (62 cases), `test_music.py` (28 cases) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md) |
## Previous Sprint -- v2.2.0 Configurable Proxy (2026-02-21)
| Pri | Status | Task |
|-----|--------|------|
| P0 | [x] | `src/derp/http.py` -- `proxy` parameter on all public functions |
| P0 | [x] | `src/derp/config.py` -- `proxy` defaults per adapter section |
| P0 | [x] | `src/derp/irc.py` -- optional SOCKS5 for IRC connections |
| P0 | [x] | `src/derp/telegram.py` -- pass proxy config to HTTP calls |
| P0 | [x] | `src/derp/teams.py` -- pass proxy config to HTTP calls |
| P0 | [x] | `src/derp/mumble.py` -- pass proxy config to TCP calls |
| P1 | [x] | Tests: proxy toggle paths (24 new cases, 1494 total) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, API.md) |
## Previous Sprint -- v2.2.0 Mumble Adapter (2026-02-21)
| Pri | Status | Task |
|-----|--------|------|
| P0 | [x] | `src/derp/mumble.py` -- MumbleBot, MumbleMessage, protobuf codec |
| P0 | [x] | TCP/TLS connection through SOCKS5 proxy |
| P0 | [x] | Minimal protobuf encoder/decoder (no external protobuf dep) |
| P0 | [x] | Mumble protocol: Version, Authenticate, Ping, TextMessage |
| P0 | [x] | Channel/user state tracking from ChannelState/UserState messages |
| P0 | [x] | `src/derp/config.py` -- `[mumble]` defaults |
| P0 | [x] | `src/derp/cli.py` -- conditionally start MumbleBot |
| P1 | [x] | Tests: `test_mumble.py` (93 cases, 1470 total) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, API.md, README.md, ROADMAP.md) |
## Previous Sprint -- v2.1.0 Telegram Integration (2026-02-21)
| Pri | Status | Task |
|-----|--------|------|
| P0 | [x] | Fix `src/derp/teams.py` -- route `send()` through SOCKS5 proxy |
| P0 | [x] | `src/derp/telegram.py` -- TelegramBot, TelegramMessage, long-polling |
| P0 | [x] | `src/derp/config.py` -- `[telegram]` defaults |
| P0 | [x] | `src/derp/cli.py` -- conditionally start TelegramBot |
| P0 | [x] | All Telegram HTTP through SOCKS5 proxy (`derp.http.urlopen`) |
| P0 | [x] | Permission tiers via user IDs (exact match) |
| P0 | [x] | @botusername suffix stripping, message splitting (4096 chars) |
| P1 | [x] | Tests: `test_telegram.py` (75 cases) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, API.md, README.md, ROADMAP.md) |
## Previous Sprint -- v2.1.0 Teams Integration (2026-02-21)
| Pri | Status | Task |
|-----|--------|------|
| P0 | [x] | `src/derp/teams.py` -- TeamsBot, TeamsMessage, HTTP handler |
| P0 | [x] | `src/derp/config.py` -- `[teams]` defaults |
| P0 | [x] | `src/derp/cli.py` -- conditionally start TeamsBot alongside IRC bots |
| P0 | [x] | HMAC-SHA256 signature validation (base64 key, `Authorization: HMAC` header) |
| P0 | [x] | Permission tiers via AAD object IDs (exact match, not fnmatch) |
| P0 | [x] | IRC no-ops: join, part, kick, mode, set_topic (debug log) |
| P1 | [x] | Tests: `test_teams.py` (74 cases, 1302 total) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, API.md, README.md, ROADMAP.md) |
## Previous Sprint -- v2.0.0 Stable API (2026-02-21)
| Pri | Status | Task |
|-----|--------|------|
| P0 | [x] | Version bump to 2.0.0 (`__init__.py`, `pyproject.toml`) |
| P0 | [x] | `docs/API.md` -- plugin API reference with semver policy |
| P2 | [x] | Documentation update (README.md, ROADMAP.md, TODO.md, TASKS.md) |
## Previous Sprint -- v2.0.0 Multi-Server (2026-02-21)
| Pri | Status | Task |
|-----|--------|------|
| P0 | [x] | `build_server_configs()` in `src/derp/config.py` (legacy + multi layout) |
| P0 | [x] | `Bot.__init__` signature: `name`, `_pstate`, per-server state DB path |
| P0 | [x] | `cli.py` multi-bot loop: concurrent `asyncio.gather`, shared registry |
| P0 | [x] | 9 stateful plugins migrated to `_ps(bot)` pattern (rss, yt, twitch, alert, cron, pastemoni, remind, webhook, urltitle) |
| P0 | [x] | `core.py` -- `!version` shows `bot.name` |
| P1 | [x] | All affected tests updated (Bot signature, FakeBot._pstate, state access) |
| P1 | [x] | New tests: `TestServerName` (6), `TestBuildServerConfigs` (10) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, ROADMAP.md, TODO.md) |
## Previous Sprint -- v2.0.0 ACL + Webhook (2026-02-21)
| Pri | Status | Task | | Pri | Status | Task |
|-----|--------|------| |-----|--------|------|
@@ -157,6 +332,11 @@
| Date | Task | | 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.3 (paste overflow with FlaskPaste integration) |
| 2026-02-17 | v1.2.1 (HTTP opener cache, alert perf, concurrent multi-instance, tracemalloc) | | 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) | | 2026-02-16 | v1.2.0 (subscriptions, alerts, proxy, reminders) |

116
TODO.md
View File

@@ -2,8 +2,8 @@
## Core ## Core
- [ ] Multi-server support (per-server config, shared plugins) - [x] Multi-server support (per-server config, shared plugins)
- [ ] Stable plugin API (versioned, breaking change policy) - [x] Stable plugin API (versioned, breaking change policy)
- [x] Paste overflow (auto-paste long output to FlaskPaste) - [x] Paste overflow (auto-paste long output to FlaskPaste)
- [x] URL shortener integration (shorten URLs in subscription announcements) - [x] URL shortener integration (shorten URLs in subscription announcements)
- [x] Webhook listener (HTTP endpoint for push events to channels) - [x] Webhook listener (HTTP endpoint for push events to channels)
@@ -82,6 +82,118 @@ is preserved in git history for reference.
- [x] `shorten` -- manual URL shortening - [x] `shorten` -- manual URL shortening
- [x] `cron` -- scheduled bot commands on a timer - [x] `cron` -- scheduled bot commands on a timer
## Teams
- [x] Microsoft Teams adapter via outgoing webhooks
- [x] TeamsBot + TeamsMessage (duck-typed with IRC Message)
- [x] HMAC-SHA256 webhook validation
- [x] Permission tiers via AAD object IDs
- [x] Route `send()` through SOCKS5 proxy (bug fix)
- [ ] Adaptive Cards for richer formatting
- [ ] Graph API integration for DMs
- [ ] Teams event handlers (member join/leave)
## Telegram
- [x] Telegram adapter via long-polling (no SDK)
- [x] TelegramBot + TelegramMessage (duck-typed with IRC Message)
- [x] All HTTP through SOCKS5 proxy
- [x] Message splitting at 4096-char limit
- [x] @botusername suffix stripping in groups
- [x] Permission tiers via user IDs
- [ ] Inline keyboard support for interactive replies
- [ ] Markdown/HTML formatting mode toggle
- [ ] Webhook mode (for setWebhook instead of getUpdates)
## Discord
- [ ] Discord adapter via WebSocket gateway + REST API (no SDK)
- [ ] DiscordBot + DiscordMessage (duck-typed with IRC Message)
- [ ] Gateway intents for message content
- [ ] Message splitting at 2000-char limit
- [ ] Permission tiers via user/role IDs
- [ ] Slash command registration (optional)
## Matrix
- [ ] Matrix adapter via long-poll `/sync` endpoint (no SDK)
- [ ] MatrixBot + MatrixMessage (duck-typed with IRC Message)
- [ ] Room-based messaging (rooms map to channels)
- [ ] Power levels map to permission tiers
- [ ] E2EE support (optional, requires libolm)
## XMPP
- [ ] XMPP adapter via persistent TCP + XML stanzas (no SDK)
- [ ] XMPPBot + XMPPMessage (duck-typed with IRC Message)
- [ ] MUC (Multi-User Chat) support -- rooms map to channels
- [ ] 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)
- [x] MumbleBot + MumbleMessage (duck-typed with IRC Message)
- [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
- [ ] Slack adapter via Socket Mode WebSocket (no SDK)
- [ ] SlackBot + SlackMessage (duck-typed with IRC Message)
- [ ] OAuth token + WebSocket for events
- [ ] Channel/DM messaging
- [ ] Permission tiers via user IDs
## Mattermost
- [ ] Mattermost adapter via WebSocket API (no SDK)
- [ ] MattermostBot + MattermostMessage (duck-typed with IRC Message)
- [ ] Self-hosted Slack alternative
- [ ] Channel/DM messaging
## Bluesky
- [ ] Bluesky adapter via AT Protocol firehose + REST API (no SDK)
- [ ] BlueskyBot + BlueskyMessage (duck-typed with IRC Message)
- [ ] Mention-based command dispatch
- [ ] Post/reply via `com.atproto.repo.createRecord`
## Testing ## Testing
- [x] Plugin command unit tests (encode, hash, dns, cidr, defang) - [x] Plugin command unit tests (encode, hash, dns, cidr, defang)

View File

@@ -16,4 +16,6 @@ services:
- ./config/derp.toml:/app/config/derp.toml:ro,Z - ./config/derp.toml:/app/config/derp.toml:ro,Z
- ./data:/app/data:Z - ./data:/app/data:Z
- ./secrets:/app/secrets:ro,Z - ./secrets:/app/secrets:ro,Z
environment:
- OPENROUTER_API_KEY
command: ["--verbose"] command: ["--verbose"]

515
docs/API.md Normal file
View File

@@ -0,0 +1,515 @@
# derp - Plugin API Reference
Stable public surface for plugin authors. Covers decorators, bot
methods, IRC primitives, state persistence, and network helpers.
## Stability Guarantee
Symbols documented below follow [semver](https://semver.org/):
| Change type | Allowed in |
|-------------|------------|
| Breaking (remove, rename, change signature) | Major only (3.0, 4.0, ...) |
| Additions (new functions, parameters, fields) | Minor (2.1, 2.2, ...) |
| Bug fixes (behavior corrections) | Patch (2.0.1, 2.0.2, ...) |
**Deprecation policy:** deprecated in a minor release, removed in the
next major. Deprecated symbols emit a log warning.
**Extension points:** attributes prefixed with `_` are documented for
reference but considered unstable -- they may change in minor releases.
---
## `derp.plugin` -- Decorators & Registry
### Decorators
```python
@command(name: str, help: str = "", admin: bool = False, tier: str = "")
```
Register an async function as a bot command. If `tier` is empty, it
defaults to `"admin"` when `admin=True`, otherwise `"user"`.
```python
@event(event_type: str)
```
Register an async function as an IRC event handler. The `event_type`
is uppercased automatically (e.g. `"join"` becomes `"JOIN"`).
### Constants
| Name | Type | Value |
|------|------|-------|
| `TIERS` | `tuple[str, ...]` | `("user", "trusted", "oper", "admin")` |
### `Handler` dataclass
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `name` | `str` | -- | Command or event name |
| `callback` | `Callable` | -- | Async handler function |
| `help` | `str` | `""` | Help text |
| `plugin` | `str` | `""` | Source plugin name |
| `admin` | `bool` | `False` | Legacy admin flag |
| `tier` | `str` | `"user"` | Required permission tier |
### `PluginRegistry`
| Method | Signature | Description |
|--------|-----------|-------------|
| `register_command` | `(name, callback, help="", plugin="", admin=False, tier="user")` | Register a command handler |
| `register_event` | `(event_type, callback, plugin="")` | Register an event handler |
| `load_plugin` | `(path: Path) -> int` | Load plugin from `.py` file; returns handler count or -1 |
| `load_directory` | `(dir_path: Path) -> None` | Load all `.py` plugins from a directory |
| `unload_plugin` | `(name: str) -> bool` | Unload plugin (refuses `core`); returns success |
| `reload_plugin` | `(name: str) -> tuple[bool, str]` | Reload from original path; returns `(ok, reason)` |
**Extension points (unstable):**
| Attribute | Type | Description |
|-----------|------|-------------|
| `_modules` | `dict[str, Any]` | Loaded plugin modules by name |
| `_paths` | `dict[str, Path]` | File paths of loaded plugins |
---
## `derp.bot` -- Bot Instance
### Stable Attributes
| Attribute | Type | Description |
|-----------|------|-------------|
| `name` | `str` | Server/bot instance name |
| `config` | `dict` | Merged TOML configuration |
| `nick` | `str` | Current IRC nick |
| `prefix` | `str` | Command prefix (e.g. `"!"`) |
| `state` | `StateStore` | Persistent key-value storage |
| `registry` | `PluginRegistry` | Command and event registry |
### Sending Messages
| Method | Signature | Description |
|--------|-----------|-------------|
| `send` | `(target: str, text: str) -> None` | Send PRIVMSG (rate-limited, auto-split) |
| `reply` | `(msg: Message, text: str) -> None` | Reply to channel or PM source |
| `long_reply` | `(msg: Message, lines: list[str], *, label: str = "") -> None` | Reply with paste overflow for long output |
| `action` | `(target: str, text: str) -> None` | Send CTCP ACTION (/me) |
### IRC Control
| Method | Signature | Description |
|--------|-----------|-------------|
| `join` | `(channel: str) -> None` | Join a channel |
| `part` | `(channel: str, reason: str = "") -> None` | Part a channel |
| `quit` | `(reason: str = "bye") -> None` | Quit server and stop bot |
| `kick` | `(channel: str, nick: str, reason: str = "") -> None` | Kick user from channel |
| `mode` | `(target: str, mode_str: str, *args: str) -> None` | Set channel/user mode |
| `set_topic` | `(channel: str, topic: str) -> None` | Set channel topic |
### Utility
| Method | Signature | Description |
|--------|-----------|-------------|
| `shorten_url` | `(url: str) -> str` | Shorten URL via FlaskPaste; returns original on failure |
| `load_plugins` | `(plugins_dir: str \| Path \| None = None) -> None` | Load all plugins from directory |
| `load_plugin` | `(name: str) -> tuple[bool, str]` | Hot-load a plugin by name |
| `reload_plugin` | `(name: str) -> tuple[bool, str]` | Reload a plugin |
| `unload_plugin` | `(name: str) -> tuple[bool, str]` | Unload a plugin |
### Extension Points (unstable)
| Attribute / Method | Description |
|--------------------|-------------|
| `_pstate` | Per-bot plugin runtime state dict |
| `_get_tier(msg)` | Determine sender's permission tier |
| `_is_admin(msg)` | Check if sender is admin |
| `_dispatch_command(msg)` | Parse and dispatch a command from PRIVMSG |
| `_spawn(coro, *, name=)` | Spawn a tracked background task |
| `registry._modules` | Direct access to loaded plugin modules |
---
## `derp.irc` -- IRC Protocol
### `Message` dataclass
| Field | Type | Description |
|-------|------|-------------|
| `raw` | `str` | Original IRC line |
| `prefix` | `str \| None` | Sender prefix (`nick!user@host`) |
| `nick` | `str \| None` | Sender nick (extracted from prefix) |
| `command` | `str` | IRC command (uppercased) |
| `params` | `list[str]` | Command parameters |
| `tags` | `dict[str, str]` | IRCv3 message tags |
| Property | Type | Description |
|----------|------|-------------|
| `target` | `str \| None` | First param (channel or nick) |
| `text` | `str \| None` | Trailing text (last param) |
| `is_channel` | `bool` | Whether target starts with `#` or `&` |
### Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| `parse` | `(line: str) -> Message` | Parse a raw IRC line |
| `format_msg` | `(command: str, *params: str) -> str` | Format an IRC command |
### `IRCConnection`
| Method | Signature | Description |
|--------|-----------|-------------|
| `connect` | `() -> None` | Open TCP/TLS connection |
| `send` | `(line: str) -> None` | Send raw IRC line (appends CRLF) |
| `readline` | `() -> str \| None` | Read one line; `None` on EOF |
| `close` | `() -> None` | Close connection |
| `connected` | *(property)* `-> bool` | Whether connection is open |
---
## `derp.state` -- Persistent Storage
### `StateStore`
SQLite-backed key-value store. Each plugin gets its own namespace.
| Method | Signature | Description |
|--------|-----------|-------------|
| `get` | `(plugin: str, key: str, default: str \| None = None) -> str \| None` | Get a value |
| `set` | `(plugin: str, key: str, value: str) -> None` | Set a value (upsert) |
| `delete` | `(plugin: str, key: str) -> bool` | Delete a key; returns `True` if removed |
| `keys` | `(plugin: str) -> list[str]` | List all keys for a plugin |
| `clear` | `(plugin: str) -> int` | Delete all state for a plugin; returns count |
| `close` | `() -> None` | Close the database connection |
---
## `derp.http` -- HTTP & Network
HTTP/TCP helpers with optional SOCKS5 proxy routing. All functions accept
a `proxy` parameter (default `True`) to toggle SOCKS5.
| Function | Signature | Description |
|----------|-----------|-------------|
| `urlopen` | `(req, *, timeout=None, context=None, retries=None, proxy=True)` | HTTP request with optional SOCKS5, connection pooling, retries |
| `build_opener` | `(*handlers, context=None, proxy=True)` | Build URL opener, optionally with SOCKS5 handler |
| `create_connection` | `(address, *, timeout=None, proxy=True)` | TCP `socket.create_connection` with optional SOCKS5, retries |
| `open_connection` | `(host, port, *, timeout=None, proxy=True)` | Async `asyncio.open_connection` with optional SOCKS5, retries |
---
## `derp.dns` -- DNS Helpers
Wire-format encode/decode for raw DNS queries and responses.
### Constants
| Name | Type | Description |
|------|------|-------------|
| `QTYPES` | `dict[str, int]` | Query type name to number (`A`, `NS`, `CNAME`, `SOA`, `PTR`, `MX`, `TXT`, `AAAA`) |
| `QTYPE_NAMES` | `dict[int, str]` | Reverse mapping (number to name) |
| `RCODES` | `dict[int, str]` | Response code to name |
| `TOR_DNS_ADDR` | `str` | Tor DNS resolver address |
| `TOR_DNS_PORT` | `int` | Tor DNS resolver port |
### Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| `get_resolver` | `() -> str` | First IPv4 nameserver from `/etc/resolv.conf` |
| `encode_name` | `(name: str) -> bytes` | Encode domain to DNS wire format |
| `decode_name` | `(data: bytes, offset: int) -> tuple[str, int]` | Decode DNS name with pointer compression |
| `build_query` | `(name: str, qtype: int) -> bytes` | Build a DNS query packet |
| `parse_rdata` | `(rtype: int, data: bytes, offset: int, rdlength: int) -> str` | Parse an RR's rdata to string |
| `parse_response` | `(data: bytes) -> tuple[int, list[str]]` | Parse DNS response; returns `(rcode, values)` |
| `reverse_name` | `(addr: str) -> str` | Convert IP to reverse DNS name |
---
## `derp.teams` -- Teams Adapter
Alternative bot adapter for Microsoft Teams via outgoing/incoming webhooks.
Exposes the same plugin API as `derp.bot.Bot` so protocol-agnostic plugins
work without modification.
### `TeamsMessage` dataclass
Duck-typed compatible with IRC `Message`:
| Field | Type | Description |
|-------|------|-------------|
| `raw` | `dict` | Original Activity JSON |
| `nick` | `str \| None` | Sender display name |
| `prefix` | `str \| None` | Sender AAD object ID (for ACL) |
| `text` | `str \| None` | Message body (stripped of @mention) |
| `target` | `str \| None` | Conversation/channel ID |
| `is_channel` | `bool` | Always `True` (outgoing webhooks) |
| `command` | `str` | Always `"PRIVMSG"` (compat shim) |
| `params` | `list[str]` | `[target, text]` |
| `tags` | `dict[str, str]` | Empty dict (no IRCv3 tags) |
| `_replies` | `list[str]` | Reply buffer (unstable) |
### `TeamsBot`
Same stable attributes and methods as `Bot`:
| Attribute | Type | Description |
|-----------|------|-------------|
| `name` | `str` | Always `"teams"` |
| `config` | `dict` | Merged TOML configuration |
| `nick` | `str` | Bot display name (`teams.bot_name`) |
| `prefix` | `str` | Command prefix (from `[bot]`) |
| `state` | `StateStore` | Persistent key-value storage |
| `registry` | `PluginRegistry` | Shared command and event registry |
**Sending messages** -- same signatures, different transport:
| Method | Behaviour |
|--------|-----------|
| `send(target, text)` | POST to incoming webhook URL |
| `reply(msg, text)` | Append to `msg._replies` (HTTP response) |
| `long_reply(msg, lines, *, label="")` | Paste overflow, appends to replies |
| `action(target, text)` | Italic text via incoming webhook |
| `shorten_url(url)` | Same FlaskPaste integration |
**IRC no-ops** (debug log, no error):
`join`, `part`, `kick`, `mode`, `set_topic`
**Plugin management** -- delegates to shared registry:
`load_plugins`, `load_plugin`, `reload_plugin`, `unload_plugin`
**Permission tiers** -- same model, exact AAD object ID matching:
`_get_tier(msg)`, `_is_admin(msg)`
---
## `derp.telegram` -- Telegram Adapter
Alternative bot adapter for Telegram via long-polling (`getUpdates`).
All HTTP routed through SOCKS5 proxy. Exposes the same plugin API as
`derp.bot.Bot` so protocol-agnostic plugins work without modification.
### `TelegramMessage` dataclass
Duck-typed compatible with IRC `Message`:
| Field | Type | Description |
|-------|------|-------------|
| `raw` | `dict` | Original Telegram Update |
| `nick` | `str \| None` | Sender first_name (or username fallback) |
| `prefix` | `str \| None` | Sender user_id as string (for ACL) |
| `text` | `str \| None` | Message body (stripped of @bot suffix) |
| `target` | `str \| None` | chat_id as string |
| `is_channel` | `bool` | `True` for groups, `False` for DMs |
| `command` | `str` | Always `"PRIVMSG"` (compat shim) |
| `params` | `list[str]` | `[target, text]` |
| `tags` | `dict[str, str]` | Empty dict (no IRCv3 tags) |
### `TelegramBot`
Same stable attributes and methods as `Bot`:
| Attribute | Type | Description |
|-----------|------|-------------|
| `name` | `str` | Always `"telegram"` |
| `config` | `dict` | Merged TOML configuration |
| `nick` | `str` | Bot display name (from `getMe`) |
| `prefix` | `str` | Command prefix (from `[telegram]` or `[bot]`) |
| `state` | `StateStore` | Persistent key-value storage |
| `registry` | `PluginRegistry` | Shared command and event registry |
**Sending messages** -- same signatures, Telegram API transport:
| Method | Behaviour |
|--------|-----------|
| `send(target, text)` | `sendMessage` API call (proxied, rate-limited) |
| `reply(msg, text)` | `send(msg.target, text)` |
| `long_reply(msg, lines, *, label="")` | Paste overflow, same logic as IRC |
| `action(target, text)` | Italic Markdown text via `sendMessage` |
| `shorten_url(url)` | Same FlaskPaste integration |
**Message splitting**: messages > 4096 chars split at line boundaries.
**IRC no-ops** (debug log, no error):
`join`, `part`, `kick`, `mode`, `set_topic`
**Plugin management** -- delegates to shared registry:
`load_plugins`, `load_plugin`, `reload_plugin`, `unload_plugin`
**Permission tiers** -- same model, exact user_id string matching:
`_get_tier(msg)`, `_is_admin(msg)`
### Helper Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| `_strip_bot_suffix` | `(text: str, bot_username: str) -> str` | Strip `@username` from command text |
| `_build_telegram_message` | `(update: dict, bot_username: str) -> TelegramMessage \| None` | Parse Telegram Update into message |
| `_split_message` | `(text: str, max_len: int = 4096) -> list[str]` | Split long text at line boundaries |
---
## `derp.mumble` -- Mumble Adapter
Alternative bot adapter for Mumble via TCP/TLS protobuf control channel
(text chat only). All TCP routed through SOCKS5 proxy. Uses a minimal
built-in protobuf encoder/decoder (no external dependency). Exposes the
same plugin API as `derp.bot.Bot`.
### `MumbleMessage` dataclass
Duck-typed compatible with IRC `Message`:
| Field | Type | Description |
|-------|------|-------------|
| `raw` | `dict` | Decoded protobuf fields |
| `nick` | `str \| None` | Sender username (from session lookup) |
| `prefix` | `str \| None` | Sender username (for ACL) |
| `text` | `str \| None` | Message body (HTML stripped) |
| `target` | `str \| None` | channel_id as string (or `"dm"`) |
| `is_channel` | `bool` | `True` for channel msgs, `False` for DMs |
| `command` | `str` | Always `"PRIVMSG"` (compat shim) |
| `params` | `list[str]` | `[target, text]` |
| `tags` | `dict[str, str]` | Empty dict (no IRCv3 tags) |
### `MumbleBot`
Same stable attributes and methods as `Bot`:
| Attribute | Type | Description |
|-----------|------|-------------|
| `name` | `str` | Always `"mumble"` |
| `config` | `dict` | Merged TOML configuration |
| `nick` | `str` | Bot username (from config) |
| `prefix` | `str` | Command prefix (from `[mumble]` or `[bot]`) |
| `state` | `StateStore` | Persistent key-value storage |
| `registry` | `PluginRegistry` | Shared command and event registry |
**Sending messages** -- same signatures, Mumble protobuf transport:
| Method | Behaviour |
|--------|-----------|
| `send(target, text)` | TextMessage to channel (HTML-escaped) |
| `reply(msg, text)` | `send(msg.target, text)` |
| `long_reply(msg, lines, *, label="")` | Paste overflow, same logic as IRC |
| `action(target, text)` | Italic HTML text (`<i>...</i>`) |
| `shorten_url(url)` | Same FlaskPaste integration |
**IRC no-ops** (debug log, no error):
`join`, `part`, `kick`, `mode`, `set_topic`
**Plugin management** -- delegates to shared registry:
`load_plugins`, `load_plugin`, `reload_plugin`, `unload_plugin`
**Permission tiers** -- same model, exact username string matching:
`_get_tier(msg)`, `_is_admin(msg)`
### Protobuf Codec (internal)
Minimal protobuf wire format helpers -- not part of the stable API:
| Function | Description |
|----------|-------------|
| `_encode_varint(value)` | Encode unsigned int as protobuf varint |
| `_decode_varint(data, offset)` | Decode varint, returns `(value, offset)` |
| `_encode_field(num, wire_type, value)` | Encode a single protobuf field |
| `_decode_fields(data)` | Decode payload into `{field_num: [values]}` |
| `_pack_msg(msg_type, payload)` | Wrap payload in 6-byte Mumble header |
| `_unpack_header(data)` | Unpack header into `(msg_type, length)` |
### Helper Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| `_strip_html` | `(text: str) -> str` | Strip HTML tags and unescape entities |
| `_escape_html` | `(text: str) -> str` | Escape text for Mumble HTML messages |
| `_build_mumble_message` | `(fields, users, our_session) -> MumbleMessage \| None` | Build message from decoded TextMessage fields |
---
## Handler Signatures
All command and event handlers are async functions receiving `bot` and
`message`:
```python
async def cmd_name(bot: Bot, message: Message) -> None: ...
async def on_event(bot: Bot, message: Message) -> None: ...
```
The `message.text` contains the full message text including the command
prefix and name. To extract arguments:
```python
args = message.text.split(None, 1)[1] if " " in message.text else ""
```
---
## Plugin Boilerplate
### Minimal command plugin
```python
from derp.plugin import command
@command("greet", help="Say hello")
async def cmd_greet(bot, message):
await bot.reply(message, f"Hello, {message.nick}!")
```
### Stateful plugin with `_ps(bot)` pattern
Plugins that need per-bot runtime state use a `_ps(bot)` helper to
namespace state in `bot._pstate`:
```python
from derp.plugin import command, event
_NS = "myplugin"
def _ps(bot):
"""Return per-bot plugin state, initialising on first call."""
if _NS not in bot._pstate:
bot._pstate[_NS] = {"counter": 0}
return bot._pstate[_NS]
@command("count", help="Increment counter")
async def cmd_count(bot, message):
ps = _ps(bot)
ps["counter"] += 1
await bot.reply(message, f"Count: {ps['counter']}")
@event("JOIN")
async def on_join(bot, message):
if message.nick != bot.nick:
ps = _ps(bot)
ps["counter"] += 1
```
### Persistent state
Use `bot.state` for data that survives restarts:
```python
@command("note", help="Save a note")
async def cmd_note(bot, message):
args = message.text.split(None, 2)
if len(args) < 3:
await bot.reply(message, "Usage: !note <key> <value>")
return
bot.state.set("note", args[1], args[2])
await bot.reply(message, f"Saved: {args[1]}")
```

333
docs/AUDIO.md Normal file
View 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.

View File

@@ -53,28 +53,53 @@ format = "json" # JSONL output (default: "text")
## Container ## Container
```bash ```bash
make build # Build image (only for dep changes) tools/build # Build image
make up # Start (podman-compose) tools/build --no-cache # Rebuild from scratch
make down # Stop tools/start # Start (builds if no image)
make logs # Follow logs 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, plugins, config, and data are bind-mounted. No rebuild needed for
code changes -- restart the container or use `!reload` for plugins. 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 ## Bot Commands
``` ```
!ping # Pong !ping # Pong
!help # List commands !help # List commands + paste full reference
!help <cmd> # Command help !help <cmd> # Command help + paste docstring detail
!help <plugin> # Plugin description + commands !help <plugin> # Plugin info + paste command details
!version # Bot version !version # Bot version
!uptime # Bot uptime !uptime # Bot uptime
!echo <text> # Echo text back !echo <text> # Echo text back
!h # Shorthand (any unambiguous prefix works) !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 ## Permission Tiers
``` ```
@@ -482,6 +507,144 @@ curl -X POST http://127.0.0.1:8080/ \
POST JSON: `{"channel":"#chan","text":"msg"}`. Optional `"action":true`. POST JSON: `{"channel":"#chan","text":"msg"}`. Optional `"action":true`.
Auth: HMAC-SHA256 via `X-Signature` header. Starts on IRC connect. Auth: HMAC-SHA256 via `X-Signature` header. Starts on IRC connect.
## Teams Integration
```toml
# config/derp.toml
[teams]
enabled = true
proxy = true # SOCKS5 proxy for outbound HTTP
bot_name = "derp"
bind = "127.0.0.1"
port = 8081
webhook_secret = "base64-secret-from-teams"
incoming_webhook_url = "" # optional, for proactive msgs
admins = ["aad-object-id-uuid"] # AAD object IDs
operators = []
trusted = []
```
Expose via Cloudflare Tunnel: `cloudflared tunnel --url http://127.0.0.1:8081`
Teams endpoint: `POST /api/messages`. HMAC-SHA256 auth via `Authorization: HMAC <sig>`.
Replies returned as JSON in HTTP response. IRC-only commands (kick, ban, topic) are no-ops.
~90% of plugins work without modification.
## Telegram Integration
```toml
# config/derp.toml
[telegram]
enabled = true
proxy = true # SOCKS5 proxy for HTTP
bot_token = "123456:ABC-DEF..." # from @BotFather
poll_timeout = 30 # long-poll seconds
admins = [123456789] # Telegram user IDs
operators = []
trusted = []
```
Long-polling via `getUpdates` -- no public endpoint needed. HTTP through
SOCKS5 proxy by default (`proxy = true`). Strips `@botusername` suffix in groups. Messages
split at 4096 chars. IRC-only commands are no-ops. ~90% of plugins work.
## Mumble Integration
```toml
# config/derp.toml
[mumble]
enabled = true
proxy = false # pymumble connects directly
host = "mumble.example.com"
port = 64738
username = "derp"
password = ""
admins = ["admin_user"] # Mumble usernames
operators = []
trusted = []
```
Uses pymumble for protocol handling (connection, voice, Opus encoding).
HTML stripped on receive, escaped on send. IRC-only commands are no-ops.
~90% of plugins work.
## Music (Mumble only)
```
!play <url|playlist> # Play audio (YouTube, SoundCloud, etc.)
!play <playlist-url> # Playlist tracks expanded into 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.
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 ## Plugin Template
```python ```python
@@ -510,6 +673,35 @@ msg.params # All params list
msg.tags # IRCv3 tags dict msg.tags # IRCv3 tags dict
``` ```
## Multi-Server
```toml
# config/derp.toml
[bot]
prefix = "!" # Shared defaults
plugins_dir = "plugins"
[servers.libera]
host = "irc.libera.chat"
port = 6697
nick = "derp"
channels = ["#test"]
[servers.oftc]
host = "irc.oftc.net"
port = 6697
nick = "derpbot"
channels = ["#derp"]
admins = ["*!~admin@oftc.host"] # Per-server override
```
Per-server blocks accept both server keys (host, port, nick, tls, ...)
and bot overrides (prefix, channels, admins, ...). Unset keys inherit
from `[bot]`/`[server]` defaults. Legacy `[server]` config still works.
State isolated per server: `data/state-libera.db`, `data/state-oftc.db`.
Plugins loaded once, shared across all servers.
## Config Locations ## Config Locations
``` ```

View File

@@ -23,13 +23,16 @@ derp --config /path/to/derp.toml --verbose
## Configuration ## Configuration
All settings in `config/derp.toml`: All settings in `config/derp.toml`.
### Single-Server (Legacy)
```toml ```toml
[server] [server]
host = "irc.libera.chat" # IRC server hostname host = "irc.libera.chat" # IRC server hostname
port = 6697 # Port (6697 = TLS, 6667 = plain) port = 6697 # Port (6697 = TLS, 6667 = plain)
tls = true # Enable TLS encryption tls = true # Enable TLS encryption
proxy = false # Route through SOCKS5 proxy (default: false)
nick = "derp" # Bot nickname nick = "derp" # Bot nickname
user = "derp" # Username (ident) user = "derp" # Username (ident)
realname = "derp IRC bot" # Real name field realname = "derp IRC bot" # Real name field
@@ -67,14 +70,63 @@ port = 8080 # Bind port
secret = "" # HMAC-SHA256 shared secret (empty = no auth) secret = "" # HMAC-SHA256 shared secret (empty = no auth)
``` ```
### Multi-Server
Connect to multiple IRC servers from a single config. Plugins are loaded
once and shared; state is isolated per server (`data/state-<name>.db`).
```toml
[bot]
prefix = "!" # Shared defaults for all servers
plugins_dir = "plugins"
admins = ["*!~root@*.ops.net"]
[servers.libera]
host = "irc.libera.chat"
port = 6697
tls = true
nick = "derp"
channels = ["#test", "#ops"]
[servers.oftc]
host = "irc.oftc.net"
port = 6697
tls = true
nick = "derpbot"
channels = ["#derp"]
admins = ["*!~admin@oftc.host"] # Override shared admins
[logging]
level = "info"
format = "json"
[webhook]
enabled = true
port = 8080
secret = "shared-secret"
```
Each `[servers.<name>]` block may contain both server-level keys (host,
port, tls, nick, etc.) and bot-level overrides (prefix, channels, admins,
operators, trusted, rate_limit, rate_burst, paste_threshold). Unset keys
inherit from the shared `[bot]` and `[server]` defaults.
The server name (e.g. `libera`, `oftc`) is used for:
- Log prefixes and `!version` output
- State DB path (`data/state-libera.db`)
- Plugin runtime state isolation
Existing single-server configs (`[server]` section) continue to work
unchanged. The server name is derived from the hostname automatically.
## Built-in Commands ## Built-in Commands
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `!ping` | Bot responds with "pong" | | `!ping` | Bot responds with "pong" |
| `!help` | List all available commands | | `!help` | List all commands + paste full reference |
| `!help <cmd>` | Show help for a specific command | | `!help <cmd>` | Show help + paste detailed docstring |
| `!help <plugin>` | Show plugin description and its commands | | `!help <plugin>` | Show plugin description + paste command details |
| `!version` | Show bot version | | `!version` | Show bot version |
| `!uptime` | Show how long the bot has been running | | `!uptime` | Show how long the bot has been running |
| `!echo <text>` | Echo back text (example plugin) | | `!echo <text>` | Echo back text (example plugin) |
@@ -135,6 +187,8 @@ secret = "" # HMAC-SHA256 shared secret (empty = no auth)
| `!username list` | Show available services by category | | `!username list` | Show available services by category |
| `!alert <add\|del\|list\|check\|info\|history>` | Keyword alert subscriptions across platforms | | `!alert <add\|del\|list\|check\|info\|history>` | Keyword alert subscriptions across platforms |
| `!searx <query>` | Search SearXNG and show top results | | `!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 | | `!jwt <token>` | Decode JWT header, claims, and flag issues |
| `!mac <address\|random\|update>` | MAC OUI vendor lookup / random MAC | | `!mac <address\|random\|update>` | MAC OUI vendor lookup / random MAC |
| `!abuse <ip> [ip2 ...]` | AbuseIPDB reputation check | | `!abuse <ip> [ip2 ...]` | AbuseIPDB reputation check |
@@ -152,6 +206,30 @@ secret = "" # HMAC-SHA256 shared secret (empty = no auth)
| `!cron <add\|del\|list>` | Scheduled command execution (admin) | | `!cron <add\|del\|list>` | Scheduled command execution (admin) |
| `!webhook` | Show webhook listener status (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 ### Command Shorthand
Commands can be abbreviated to any unambiguous prefix: Commands can be abbreviated to any unambiguous prefix:
@@ -358,8 +436,8 @@ keys = bot.state.keys("myplugin")
bot.state.clear("myplugin") bot.state.clear("myplugin")
``` ```
Data is stored in `data/state.db` (SQLite). Each plugin gets its own Data is stored in `data/state-<name>.db` (SQLite, one per server). Each
namespace so keys never collide. plugin gets its own namespace so keys never collide.
### Inspection Commands (admin) ### Inspection Commands (admin)
@@ -763,6 +841,55 @@ Title Two -- https://example.com/page2
Title Three -- https://example.com/page3 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 ### `!alert` -- Keyword Alert Subscriptions
Search keywords across 27 platforms and announce new results. Unlike Search keywords across 27 platforms and announce new results. Unlike
@@ -1250,3 +1377,577 @@ timeout = 10 # HTTP fetch timeout
max_urls = 3 # max URLs to preview per message max_urls = 3 # max URLs to preview per message
ignore_hosts = [] # additional hostnames to skip ignore_hosts = [] # additional hostnames to skip
``` ```
## Teams Integration
Connect derp to Microsoft Teams via outgoing webhooks. The bot runs an HTTP
server that receives messages from Teams and replies inline. No Microsoft SDK
required -- raw asyncio HTTP, same pattern as the webhook plugin.
### How It Works
1. **Outgoing webhook** (Teams -> bot): Teams POSTs an Activity JSON to the
bot's HTTP endpoint when a user @mentions the bot. The bot dispatches the
command through the shared plugin registry and returns the reply as the
HTTP response body.
2. **Incoming webhook** (bot -> Teams, optional): For proactive messages
(alerts, subscriptions), the bot POSTs to a Teams incoming webhook URL.
### Configuration
```toml
[teams]
enabled = true
proxy = true # Route outbound HTTP through SOCKS5
bot_name = "derp" # outgoing webhook display name
bind = "127.0.0.1" # HTTP listen address
port = 8081 # HTTP listen port
webhook_secret = "" # HMAC-SHA256 secret from Teams
incoming_webhook_url = "" # for proactive messages (optional)
admins = [] # AAD object IDs (UUID format)
operators = [] # AAD object IDs
trusted = [] # AAD object IDs
```
### Teams Setup
1. **Create an outgoing webhook** in a Teams channel:
- Channel settings -> Connectors -> Outgoing Webhook
- Set the callback URL to your bot's endpoint (e.g.
`https://derp.example.com/api/messages`)
- Copy the HMAC secret and set `webhook_secret` in config
2. **Expose the bot** via Cloudflare Tunnel or reverse proxy:
```bash
cloudflared tunnel --url http://127.0.0.1:8081
```
3. **Configure permissions** using AAD object IDs from the Activity JSON.
The AAD object ID is sent in `from.aadObjectId` on every message. Use
`!whoami` to discover your ID.
### Permission Tiers
Same 4-tier model as IRC, but matches exact AAD object IDs instead of
fnmatch hostmask patterns:
```toml
[teams]
admins = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"]
operators = ["yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"]
trusted = ["zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz"]
```
### Plugin Compatibility
~90% of plugins work on Teams without modification -- any plugin that uses
only `bot.send()`, `bot.reply()`, `bot.state`, `message.text`, `.nick`,
and `.target`.
| Feature | IRC | Teams |
|---------|-----|-------|
| `bot.reply()` | Sends PRIVMSG | Appends to HTTP response |
| `bot.send()` | Sends PRIVMSG | POSTs to incoming webhook |
| `bot.action()` | CTCP ACTION | Italic text via incoming webhook |
| `bot.long_reply()` | Paste overflow | Paste overflow (same logic) |
| `bot.state` | Per-server SQLite | Per-server SQLite |
| `bot.join/part/kick/mode` | IRC commands | No-op (logged at debug) |
| Event handlers (JOIN, etc.) | Fired on IRC events | Not triggered |
| Hostmask ACL | fnmatch patterns | Exact AAD object IDs |
| Passive monitoring | All channel messages | @mention only |
### HMAC Verification
Teams outgoing webhooks sign requests with HMAC-SHA256. The secret is
base64-encoded when you create the webhook. The `Authorization` header
format is `HMAC <base64(hmac-sha256(b64decode(secret), body))>`.
If `webhook_secret` is empty, no authentication is performed (useful for
development but not recommended for production).
### Endpoint
Single endpoint: `POST /api/messages`
The bot returns a JSON response:
```json
{"type": "message", "text": "reply text here"}
```
Multiple reply lines are joined with `\n`.
## Telegram Integration
Connect derp to Telegram via long-polling (`getUpdates`). All outbound HTTP
is routed through the SOCKS5 proxy. No public endpoint required, no Telegram
SDK dependency.
### How It Works
The bot calls `getUpdates` in a loop with a long-poll timeout (default 30s).
When a message arrives with the configured prefix, it is dispatched through
the shared plugin registry. Replies are sent immediately via `sendMessage`.
### Configuration
```toml
[telegram]
enabled = true
proxy = true # Route HTTP through SOCKS5
bot_token = "123456:ABC-DEF..." # from @BotFather
poll_timeout = 30 # long-poll timeout in seconds
admins = [123456789] # Telegram user IDs (numeric)
operators = [] # Telegram user IDs
trusted = [] # Telegram user IDs
```
### Telegram Setup
1. **Create a bot** via [@BotFather](https://t.me/BotFather):
- `/newbot` and follow the prompts
- Copy the bot token and set `bot_token` in config
2. **Add the bot** to a group or send it a DM
3. **Configure permissions** using Telegram user IDs. Use `!whoami` to
discover your numeric user ID.
### Permission Tiers
Same 4-tier model as IRC, but matches exact Telegram user IDs (numeric
strings) instead of fnmatch hostmask patterns:
```toml
[telegram]
admins = [123456789]
operators = [987654321]
trusted = [111222333]
```
### Plugin Compatibility
Same compatibility as Teams -- ~90% of plugins work without modification.
| Feature | IRC | Telegram |
|---------|-----|----------|
| `bot.reply()` | Sends PRIVMSG | `sendMessage` API call |
| `bot.send()` | Sends PRIVMSG | `sendMessage` API call |
| `bot.action()` | CTCP ACTION | Italic Markdown text |
| `bot.long_reply()` | Paste overflow | Paste overflow (same logic) |
| `bot.state` | Per-server SQLite | Per-server SQLite |
| `bot.join/part/kick/mode` | IRC commands | No-op (logged at debug) |
| Event handlers (JOIN, etc.) | Fired on IRC events | Not triggered |
| Hostmask ACL | fnmatch patterns | Exact user IDs |
| Message limit | 512 bytes (IRC) | 4096 chars (Telegram) |
### Group Commands
In groups, Telegram appends `@botusername` to commands. The bot strips
this automatically: `!help@mybot` becomes `!help`.
### Transport
All HTTP traffic (API calls, long-polling) routes through the SOCKS5
proxy at `127.0.0.1:1080` via `derp.http.urlopen` when `proxy = true`
(default). Set `proxy = false` to connect directly.
## Mumble Integration
Connect derp to a Mumble server with text chat and voice playback.
Uses [pymumble](https://github.com/azlux/pymumble) for the Mumble
protocol (connection, SSL, voice encoding). Text commands are bridged
from pymumble's thread callbacks to asyncio for plugin dispatch.
### How It Works
pymumble handles the Mumble protocol: TLS connection, ping keepalives,
channel/user tracking, and Opus voice encoding. The bot registers
callbacks for text messages and connection events, then bridges them
to asyncio via `run_coroutine_threadsafe()`. Voice playback feeds raw
PCM to `sound_output.add_sound()` -- pymumble handles Opus encoding,
packetization, and timing.
### Configuration
```toml
[mumble]
enabled = true
proxy = false # SOCKS5 proxy (pymumble connects directly)
host = "mumble.example.com" # Mumble server hostname
port = 64738 # Default Mumble port
username = "derp" # Bot username
password = "" # Server password (optional)
admins = ["admin_user"] # Mumble usernames
operators = [] # Mumble usernames
trusted = [] # Mumble usernames
```
### Mumble Setup
1. **Ensure a Mumble server** (Murmur/Mumble-server) is running
2. **Configure the bot** with the server hostname, port, and credentials
3. **Configure permissions** using Mumble registered usernames. Use
`!whoami` to discover your username as the bot sees it.
### Permission Tiers
Same 4-tier model as IRC, but matches exact Mumble usernames instead of
fnmatch hostmask patterns:
```toml
[mumble]
admins = ["admin_user"]
operators = ["oper_user"]
trusted = ["trusted_user"]
```
### Plugin Compatibility
Same compatibility as Teams/Telegram -- ~90% of plugins work without
modification.
| Feature | IRC | Mumble |
|---------|-----|--------|
| `bot.reply()` | Sends PRIVMSG | TextMessage to channel |
| `bot.send()` | Sends PRIVMSG | TextMessage to channel |
| `bot.action()` | CTCP ACTION | Italic HTML text (`<i>...</i>`) |
| `bot.long_reply()` | Paste overflow | Paste overflow (same logic) |
| `bot.state` | Per-server SQLite | Per-server SQLite |
| `bot.join/part/kick/mode` | IRC commands | No-op (logged at debug) |
| Event handlers (JOIN, etc.) | Fired on IRC events | Not triggered |
| Hostmask ACL | fnmatch patterns | Exact usernames |
### Text Encoding
Mumble uses HTML for text messages. On receive, the bot strips tags and
unescapes entities. On send, text is HTML-escaped. Action messages use
`<i>` tags for italic formatting.
### Music Playback
Stream audio from YouTube, SoundCloud, and other yt-dlp-supported sites
into the Mumble voice channel. Audio is decoded to PCM via a
`yt-dlp | ffmpeg` subprocess pipeline; pymumble handles Opus encoding
and voice transmission.
**System dependencies** (container image includes these):
- `yt-dlp` -- audio stream extraction
- `ffmpeg` -- decode to 48kHz mono s16le PCM
- `libopus` -- Opus codec (used by pymumble/opuslib)
```
!play <url|playlist> Play audio or add to queue (playlists expanded)
!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 (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
- `!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 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
```

View File

@@ -0,0 +1,73 @@
"""Patch pymumble deps for Python 3.13+ / musl (Alpine).
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
import sysconfig
site = sysconfig.get_path("purelib")
# -- pymumble: replace ssl.wrap_socket with SSLContext --
p = pathlib.Path(f"{site}/pymumble_py3/mumble.py")
src = p.read_text()
old = """\
try:
self.control_socket = ssl.wrap_socket(std_sock, certfile=self.certfile, keyfile=self.keyfile, ssl_version=ssl.PROTOCOL_TLS)
except AttributeError:
self.control_socket = ssl.wrap_socket(std_sock, certfile=self.certfile, keyfile=self.keyfile, ssl_version=ssl.PROTOCOL_TLSv1)
try:"""
new = """\
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
if self.certfile:
ctx.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile)
self.control_socket = ctx.wrap_socket(std_sock, server_hostname=self.host)
try:"""
assert old in src, "pymumble ssl patch target not found"
p.write_text(src.replace(old, new))
print("pymumble ssl patch applied")
# -- opuslib: find_library fails on musl, use direct CDLL fallback --
p = pathlib.Path(f"{site}/opuslib/api/__init__.py")
src = p.read_text()
old_opus = "lib_location = find_library('opus')"
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
View 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

View File

@@ -77,12 +77,19 @@ _DEVTO_API = "https://dev.to/api/articles"
_MEDIUM_FEED_URL = "https://medium.com/feed/tag" _MEDIUM_FEED_URL = "https://medium.com/feed/tag"
_HUGGINGFACE_API = "https://huggingface.co/api/models" _HUGGINGFACE_API = "https://huggingface.co/api/models"
# -- Module-level tracking --------------------------------------------------- # -- Per-bot plugin runtime state --------------------------------------------
_pollers: dict[str, asyncio.Task] = {}
_subscriptions: dict[str, dict] = {} def _ps(bot):
_errors: dict[str, dict[str, int]] = {} """Per-bot plugin runtime state."""
_poll_count: dict[str, int] = {} return bot._pstate.setdefault("alert", {
"pollers": {},
"subs": {},
"errors": {},
"poll_count": {},
"db_conn": None,
"db_path": "data/alert_history.db",
})
# -- Concurrent fetch helper ------------------------------------------------- # -- Concurrent fetch helper -------------------------------------------------
@@ -121,18 +128,16 @@ def _fetch_many(targets, *, build_req, timeout, parse):
# -- History database -------------------------------------------------------- # -- History database --------------------------------------------------------
_DB_PATH = Path("data/alert_history.db")
_conn: sqlite3.Connection | None = None
def _db(bot) -> sqlite3.Connection:
def _db() -> sqlite3.Connection:
"""Lazy-init the history database connection and schema.""" """Lazy-init the history database connection and schema."""
global _conn ps = _ps(bot)
if _conn is not None: if ps["db_conn"] is not None:
return _conn return ps["db_conn"]
_DB_PATH.parent.mkdir(parents=True, exist_ok=True) db_path = Path(ps.get("db_path", "data/alert_history.db"))
_conn = sqlite3.connect(str(_DB_PATH)) db_path.parent.mkdir(parents=True, exist_ok=True)
_conn.execute(""" conn = sqlite3.connect(str(db_path))
conn.execute("""
CREATE TABLE IF NOT EXISTS results ( CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL, channel TEXT NOT NULL,
@@ -152,34 +157,35 @@ def _db() -> sqlite3.Connection:
("extra", "''"), ("extra", "''"),
]: ]:
try: try:
_conn.execute( conn.execute(
f"ALTER TABLE results ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}" f"ALTER TABLE results ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}"
) )
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass # column already exists pass # column already exists
_conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_results_alert ON results(channel, alert)" "CREATE INDEX IF NOT EXISTS idx_results_alert ON results(channel, alert)"
) )
_conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_results_short_id ON results(short_id)" "CREATE INDEX IF NOT EXISTS idx_results_short_id ON results(short_id)"
) )
# Backfill short_id for rows that predate the column # Backfill short_id for rows that predate the column
for row_id, backend, item_id in _conn.execute( for row_id, backend, item_id in conn.execute(
"SELECT id, backend, item_id FROM results WHERE short_id = ''" "SELECT id, backend, item_id FROM results WHERE short_id = ''"
).fetchall(): ).fetchall():
_conn.execute( conn.execute(
"UPDATE results SET short_id = ? WHERE id = ?", "UPDATE results SET short_id = ? WHERE id = ?",
(_make_short_id(backend, item_id), row_id), (_make_short_id(backend, item_id), row_id),
) )
_conn.commit() conn.commit()
return _conn ps["db_conn"] = conn
return conn
def _save_result(channel: str, alert: str, backend: str, item: dict, def _save_result(bot, channel: str, alert: str, backend: str, item: dict,
short_url: str = "") -> str: short_url: str = "") -> str:
"""Persist a matched result to the history database. Returns short_id.""" """Persist a matched result to the history database. Returns short_id."""
short_id = _make_short_id(backend, item.get("id", "")) short_id = _make_short_id(backend, item.get("id", ""))
db = _db() db = _db(bot)
db.execute( db.execute(
"INSERT INTO results" "INSERT INTO results"
" (channel, alert, backend, item_id, title, url, date, found_at," " (channel, alert, backend, item_id, title, url, date, found_at,"
@@ -362,45 +368,56 @@ def _fetch_og_batch(urls: list[str]) -> dict[str, tuple[str, str, str]]:
# -- YouTube InnerTube search (blocking) ------------------------------------ # -- YouTube InnerTube search (blocking) ------------------------------------
def _extract_videos(obj: object, depth: int = 0) -> list[dict]: 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. Finds all objects containing both 'videoId' and 'title' keys.
Resilient to YouTube rearranging wrapper layers. 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: _MAX_DEPTH = 20
return [] results: list[dict] = []
results = [] # Stack of (node, depth) tuples
if isinstance(obj, dict): stack: list[tuple[object, int]] = [(obj, 0)]
video_id = obj.get("videoId") while stack:
title_obj = obj.get("title") node, d = stack.pop()
if isinstance(video_id, str) and video_id and title_obj is not None: if d > _MAX_DEPTH:
if isinstance(title_obj, dict): continue
runs = title_obj.get("runs", []) if isinstance(node, dict):
title = "".join(r.get("text", "") for r in runs if isinstance(r, dict)) video_id = node.get("videoId")
elif isinstance(title_obj, str): title_obj = node.get("title")
title = title_obj if isinstance(video_id, str) and video_id and title_obj is not None:
else: if isinstance(title_obj, dict):
title = "" runs = title_obj.get("runs", [])
if title: title = "".join(
# Extract relative publish time (e.g. "2 days ago") r.get("text", "") for r in runs if isinstance(r, dict)
pub_obj = obj.get("publishedTimeText") )
date = "" elif isinstance(title_obj, str):
if isinstance(pub_obj, dict): title = title_obj
date = pub_obj.get("simpleText", "") else:
elif isinstance(pub_obj, str): title = ""
date = pub_obj if title:
results.append({ pub_obj = node.get("publishedTimeText")
"id": video_id, date = ""
"title": title, if isinstance(pub_obj, dict):
"url": f"https://www.youtube.com/watch?v={video_id}", date = pub_obj.get("simpleText", "")
"date": date, elif isinstance(pub_obj, str):
"extra": "", date = pub_obj
}) results.append({
for val in obj.values(): "id": video_id,
results.extend(_extract_videos(val, depth + 1)) "title": title,
elif isinstance(obj, list): "url": f"https://www.youtube.com/watch?v={video_id}",
for item in obj: "date": date,
results.extend(_extract_videos(item, depth + 1)) "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 return results
@@ -419,7 +436,7 @@ def _search_youtube(keyword: str) -> list[dict]:
req = urllib.request.Request(_YT_SEARCH_URL, data=payload, method="POST") req = urllib.request.Request(_YT_SEARCH_URL, data=payload, method="POST")
req.add_header("Content-Type", "application/json") req.add_header("Content-Type", "application/json")
resp = urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) resp = _urlopen(req, timeout=_FETCH_TIMEOUT)
raw = resp.read() raw = resp.read()
resp.close() resp.close()
@@ -528,7 +545,7 @@ def _search_searx(keyword: str) -> list[dict]:
}) })
req = urllib.request.Request(f"{_SEARX_URL}?{params}", method="GET") req = urllib.request.Request(f"{_SEARX_URL}?{params}", method="GET")
try: try:
resp = urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) resp = _urlopen(req, timeout=_FETCH_TIMEOUT, proxy=False)
raw = resp.read() raw = resp.read()
resp.close() resp.close()
except Exception as exc: except Exception as exc:
@@ -1814,19 +1831,20 @@ def _delete(bot, key: str) -> None:
async def _poll_once(bot, key: str, announce: bool = True) -> None: async def _poll_once(bot, key: str, announce: bool = True) -> None:
"""Single poll cycle for one alert subscription (all backends).""" """Single poll cycle for one alert subscription (all backends)."""
data = _subscriptions.get(key) ps = _ps(bot)
data = ps["subs"].get(key)
if data is None: if data is None:
data = _load(bot, key) data = _load(bot, key)
if data is None: if data is None:
return return
_subscriptions[key] = data ps["subs"][key] = data
keyword = data["keyword"] keyword = data["keyword"]
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
data["last_poll"] = now data["last_poll"] = now
cycle = _poll_count[key] = _poll_count.get(key, 0) + 1 cycle = ps["poll_count"][key] = ps["poll_count"].get(key, 0) + 1
tag_errors = _errors.setdefault(key, {}) tag_errors = ps["errors"].setdefault(key, {})
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
for tag, backend in _BACKENDS.items(): for tag, backend in _BACKENDS.items():
@@ -1917,7 +1935,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
except Exception: except Exception:
pass pass
short_id = _save_result( short_id = _save_result(
channel, name, tag, item, short_url=short_url, bot, channel, name, tag, item, short_url=short_url,
) )
title = item["title"] or "(no title)" title = item["title"] or "(no title)"
extra = item.get("extra", "") extra = item.get("extra", "")
@@ -1938,7 +1956,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
seen_list = seen_list[-_MAX_SEEN:] seen_list = seen_list[-_MAX_SEEN:]
data.setdefault("seen", {})[tag] = seen_list data.setdefault("seen", {})[tag] = seen_list
_subscriptions[key] = data ps["subs"][key] = data
_save(bot, key, data) _save(bot, key, data)
@@ -1946,7 +1964,7 @@ async def _poll_loop(bot, key: str) -> None:
"""Infinite poll loop for one alert subscription.""" """Infinite poll loop for one alert subscription."""
try: try:
while True: while True:
data = _subscriptions.get(key) or _load(bot, key) data = _ps(bot)["subs"].get(key) or _load(bot, key)
if data is None: if data is None:
return return
interval = data.get("interval", _DEFAULT_INTERVAL) interval = data.get("interval", _DEFAULT_INTERVAL)
@@ -1958,35 +1976,38 @@ async def _poll_loop(bot, key: str) -> None:
def _start_poller(bot, key: str) -> None: def _start_poller(bot, key: str) -> None:
"""Create and track a poller task.""" """Create and track a poller task."""
existing = _pollers.get(key) ps = _ps(bot)
existing = ps["pollers"].get(key)
if existing and not existing.done(): if existing and not existing.done():
return return
task = asyncio.create_task(_poll_loop(bot, key)) task = asyncio.create_task(_poll_loop(bot, key))
_pollers[key] = task ps["pollers"][key] = task
def _stop_poller(key: str) -> None: def _stop_poller(bot, key: str) -> None:
"""Cancel and remove a poller task.""" """Cancel and remove a poller task."""
task = _pollers.pop(key, None) ps = _ps(bot)
task = ps["pollers"].pop(key, None)
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
_subscriptions.pop(key, None) ps["subs"].pop(key, None)
_errors.pop(key, None) ps["errors"].pop(key, None)
_poll_count.pop(key, None) ps["poll_count"].pop(key, None)
# -- Restore on connect ----------------------------------------------------- # -- Restore on connect -----------------------------------------------------
def _restore(bot) -> None: def _restore(bot) -> None:
"""Rebuild pollers from persisted state.""" """Rebuild pollers from persisted state."""
ps = _ps(bot)
for key in bot.state.keys("alert"): for key in bot.state.keys("alert"):
existing = _pollers.get(key) existing = ps["pollers"].get(key)
if existing and not existing.done(): if existing and not existing.done():
continue continue
data = _load(bot, key) data = _load(bot, key)
if data is None: if data is None:
continue continue
_subscriptions[key] = data ps["subs"][key] = data
_start_poller(bot, key) _start_poller(bot, key)
@@ -2056,9 +2077,9 @@ async def cmd_alert(bot, message):
if data is None: if data is None:
await bot.reply(message, f"No alert '{name}' in this channel") await bot.reply(message, f"No alert '{name}' in this channel")
return return
_subscriptions[key] = data _ps(bot)["subs"][key] = data
await _poll_once(bot, key, announce=True) await _poll_once(bot, key, announce=True)
data = _subscriptions.get(key, data) data = _ps(bot)["subs"].get(key, data)
errs = data.get("last_errors", {}) errs = data.get("last_errors", {})
if errs: if errs:
tags = ", ".join(sorted(errs)) tags = ", ".join(sorted(errs))
@@ -2087,7 +2108,7 @@ async def cmd_alert(bot, message):
limit = max(1, min(int(parts[3]), 20)) limit = max(1, min(int(parts[3]), 20))
except ValueError: except ValueError:
limit = 5 limit = 5
db = _db() db = _db(bot)
rows = db.execute( rows = db.execute(
"SELECT id, backend, title, url, date, found_at, short_id," "SELECT id, backend, title, url, date, found_at, short_id,"
" short_url, extra FROM results" " short_url, extra FROM results"
@@ -2141,7 +2162,7 @@ async def cmd_alert(bot, message):
return return
short_id = parts[2].lower() short_id = parts[2].lower()
channel = message.target channel = message.target
db = _db() db = _db(bot)
row = db.execute( row = db.execute(
"SELECT alert, backend, title, url, date, found_at, short_id," "SELECT alert, backend, title, url, date, found_at, short_id,"
" extra" " extra"
@@ -2216,7 +2237,7 @@ async def cmd_alert(bot, message):
"seen": {}, "seen": {},
} }
_save(bot, key, data) _save(bot, key, data)
_subscriptions[key] = data _ps(bot)["subs"][key] = data
# Seed seen IDs in background (silent poll), then start the poller # Seed seen IDs in background (silent poll), then start the poller
async def _seed(): async def _seed():
@@ -2251,7 +2272,7 @@ async def cmd_alert(bot, message):
await bot.reply(message, f"No alert '{name}' in this channel") await bot.reply(message, f"No alert '{name}' in this channel")
return return
_stop_poller(key) _stop_poller(bot, key)
_delete(bot, key) _delete(bot, key)
await bot.reply(message, f"Removed '{name}'") await bot.reply(message, f"Removed '{name}'")
return return

85
plugins/alias.py Normal file
View 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]")

View File

@@ -1,11 +1,42 @@
"""Core plugin: ping, help, version, plugin management.""" """Core plugin: ping, help, version, plugin management."""
import asyncio
import textwrap
from collections import Counter from collections import Counter
from derp import __version__ from derp import __version__
from derp.plugin import command 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") @command("ping", help="Check if the bot is alive")
async def cmd_ping(bot, message): async def cmd_ping(bot, message):
"""Respond with pong.""" """Respond with pong."""
@@ -27,7 +58,13 @@ async def cmd_help(bot, message):
handler = bot.registry.commands.get(name) handler = bot.registry.commands.get(name)
if handler and bot._plugin_allowed(handler.plugin, channel): if handler and bot._plugin_allowed(handler.plugin, channel):
help_text = handler.help or "No help available." 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 return
# Check plugin # Check plugin
@@ -41,7 +78,24 @@ async def cmd_help(bot, message):
lines = [f"{name} -- {desc}" if desc else name] lines = [f"{name} -- {desc}" if desc else name]
if cmds: if cmds:
lines.append(f"Commands: {', '.join(bot.prefix + c for c in 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 return
await bot.reply(message, f"Unknown command or plugin: {name}") await bot.reply(message, f"Unknown command or plugin: {name}")
@@ -52,13 +106,37 @@ async def cmd_help(bot, message):
k for k, v in bot.registry.commands.items() k for k, v in bot.registry.commands.items()
if bot._plugin_allowed(v.plugin, channel) 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") @command("version", help="Show bot version")
async def cmd_version(bot, message): async def cmd_version(bot, message):
"""Report the running version.""" """Report the running version."""
await bot.reply(message, f"derp {__version__}") await bot.reply(message, f"derp {__version__} ({bot.name})")
@command("uptime", help="Show how long the bot has been running") @command("uptime", help="Show how long the bot has been running")
@@ -145,7 +223,8 @@ async def cmd_whoami(bot, message):
prefix = message.prefix or "unknown" prefix = message.prefix or "unknown"
tier = bot._get_tier(message) tier = bot._get_tier(message)
tags = [tier] 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") tags.append("IRCOP")
await bot.reply(message, f"{prefix} [{', '.join(tags)}]") 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)}") parts.append(f"Admin: {', '.join(bot._admins)}")
else: else:
parts.append("Admin: (none)") parts.append("Admin: (none)")
sorcerers = getattr(bot, "_sorcerers", [])
if sorcerers:
parts.append(f"Sorcerer: {', '.join(sorcerers)}")
if bot._operators: if bot._operators:
parts.append(f"Oper: {', '.join(bot._operators)}") parts.append(f"Oper: {', '.join(bot._operators)}")
if bot._trusted: if bot._trusted:
parts.append(f"Trusted: {', '.join(bot._trusted)}") parts.append(f"Trusted: {', '.join(bot._trusted)}")
if bot._opers: opers = getattr(bot, "_opers", set())
parts.append(f"IRCOPs: {', '.join(sorted(bot._opers))}") if opers:
parts.append(f"IRCOPs: {', '.join(sorted(opers))}")
else: else:
parts.append("IRCOPs: (none)") parts.append("IRCOPs: (none)")
await bot.reply(message, " | ".join(parts)) 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) @command("state", help="Inspect plugin state: !state <list|get|del|clear> ...", admin=True)
async def cmd_state(bot, message): async def cmd_state(bot, message):
"""Manage the plugin state store. """Manage the plugin state store.

View File

@@ -18,10 +18,15 @@ _MIN_INTERVAL = 60
_MAX_INTERVAL = 604800 # 7 days _MAX_INTERVAL = 604800 # 7 days
_MAX_JOBS = 20 _MAX_JOBS = 20
# -- Module-level tracking --------------------------------------------------- # -- Per-bot plugin runtime state --------------------------------------------
_jobs: dict[str, dict] = {}
_tasks: dict[str, asyncio.Task] = {} def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("cron", {
"jobs": {},
"tasks": {},
})
# -- Pure helpers ------------------------------------------------------------ # -- Pure helpers ------------------------------------------------------------
@@ -101,7 +106,7 @@ async def _cron_loop(bot, key: str) -> None:
"""Repeating loop: sleep, then dispatch the stored command.""" """Repeating loop: sleep, then dispatch the stored command."""
try: try:
while True: while True:
data = _jobs.get(key) data = _ps(bot)["jobs"].get(key)
if not data: if not data:
return return
await asyncio.sleep(data["interval"]) await asyncio.sleep(data["interval"])
@@ -118,33 +123,36 @@ async def _cron_loop(bot, key: str) -> None:
def _start_job(bot, key: str) -> None: def _start_job(bot, key: str) -> None:
"""Create and track a cron task.""" """Create and track a cron task."""
existing = _tasks.get(key) ps = _ps(bot)
existing = ps["tasks"].get(key)
if existing and not existing.done(): if existing and not existing.done():
return return
task = asyncio.create_task(_cron_loop(bot, key)) task = asyncio.create_task(_cron_loop(bot, key))
_tasks[key] = task ps["tasks"][key] = task
def _stop_job(key: str) -> None: def _stop_job(bot, key: str) -> None:
"""Cancel and remove a cron task.""" """Cancel and remove a cron task."""
task = _tasks.pop(key, None) ps = _ps(bot)
task = ps["tasks"].pop(key, None)
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
_jobs.pop(key, None) ps["jobs"].pop(key, None)
# -- Restore on connect ----------------------------------------------------- # -- Restore on connect -----------------------------------------------------
def _restore(bot) -> None: def _restore(bot) -> None:
"""Rebuild cron tasks from persisted state.""" """Rebuild cron tasks from persisted state."""
ps = _ps(bot)
for key in bot.state.keys("cron"): for key in bot.state.keys("cron"):
existing = _tasks.get(key) existing = ps["tasks"].get(key)
if existing and not existing.done(): if existing and not existing.done():
continue continue
data = _load(bot, key) data = _load(bot, key)
if data is None: if data is None:
continue continue
_jobs[key] = data ps["jobs"][key] = data
_start_job(bot, key) _start_job(bot, key)
@@ -211,7 +219,7 @@ async def cmd_cron(bot, message):
if not found_key: if not found_key:
await bot.reply(message, f"No cron job #{cron_id}") await bot.reply(message, f"No cron job #{cron_id}")
return return
_stop_job(found_key) _stop_job(bot, found_key)
_delete(bot, found_key) _delete(bot, found_key)
await bot.reply(message, f"Removed cron #{cron_id}") await bot.reply(message, f"Removed cron #{cron_id}")
return return
@@ -275,7 +283,7 @@ async def cmd_cron(bot, message):
"added_by": message.nick, "added_by": message.nick,
} }
_save(bot, key, data) _save(bot, key, data)
_jobs[key] = data _ps(bot)["jobs"][key] = data
_start_job(bot, key) _start_job(bot, key)
fmt_interval = _format_duration(interval) fmt_interval = _format_duration(interval)

View File

@@ -114,7 +114,7 @@ def _create_paste(base_url: str, content: str) -> str:
body = json.loads(resp.read()) body = json.loads(resp.read())
paste_id = body.get("id", "") paste_id = body.get("id", "")
if paste_id: if paste_id:
return f"{base_url}/{paste_id}" return f"{base_url}/{paste_id}/raw"
return body.get("url", "") return body.get("url", "")

492
plugins/lastfm.py Normal file
View 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
View 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
View 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:])

1924
plugins/music.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -28,11 +28,15 @@ _MAX_MONITORS = 20
_MAX_SNIPPET_LEN = 80 _MAX_SNIPPET_LEN = 80
_MAX_TITLE_LEN = 60 _MAX_TITLE_LEN = 60
# -- Module-level tracking --------------------------------------------------- # -- Per-bot runtime state ---------------------------------------------------
_pollers: dict[str, asyncio.Task] = {} def _ps(bot):
_monitors: dict[str, dict] = {} """Per-bot plugin runtime state."""
_errors: dict[str, int] = {} return bot._pstate.setdefault("pastemoni", {
"pollers": {},
"monitors": {},
"errors": {},
})
# -- Pure helpers ------------------------------------------------------------ # -- Pure helpers ------------------------------------------------------------
@@ -239,12 +243,13 @@ _BACKENDS: dict[str, callable] = {
async def _poll_once(bot, key: str, announce: bool = True) -> None: async def _poll_once(bot, key: str, announce: bool = True) -> None:
"""Single poll cycle for one monitor (all backends).""" """Single poll cycle for one monitor (all backends)."""
data = _monitors.get(key) ps = _ps(bot)
data = ps["monitors"].get(key)
if data is None: if data is None:
data = _load(bot, key) data = _load(bot, key)
if data is None: if data is None:
return return
_monitors[key] = data ps["monitors"][key] = data
keyword = data["keyword"] keyword = data["keyword"]
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
@@ -294,11 +299,11 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
data.setdefault("seen", {})[tag] = seen_list data.setdefault("seen", {})[tag] = seen_list
if had_success: if had_success:
_errors[key] = 0 ps["errors"][key] = 0
else: else:
_errors[key] = _errors.get(key, 0) + 1 ps["errors"][key] = ps["errors"].get(key, 0) + 1
_monitors[key] = data ps["monitors"][key] = data
_save(bot, key, data) _save(bot, key, data)
@@ -306,11 +311,12 @@ async def _poll_loop(bot, key: str) -> None:
"""Infinite poll loop for one monitor.""" """Infinite poll loop for one monitor."""
try: try:
while True: while True:
data = _monitors.get(key) or _load(bot, key) ps = _ps(bot)
data = ps["monitors"].get(key) or _load(bot, key)
if data is None: if data is None:
return return
interval = data.get("interval", _DEFAULT_INTERVAL) interval = data.get("interval", _DEFAULT_INTERVAL)
errs = _errors.get(key, 0) errs = ps["errors"].get(key, 0)
if errs >= 5: if errs >= 5:
interval = min(interval * 2, _MAX_INTERVAL) interval = min(interval * 2, _MAX_INTERVAL)
await asyncio.sleep(interval) await asyncio.sleep(interval)
@@ -321,34 +327,37 @@ async def _poll_loop(bot, key: str) -> None:
def _start_poller(bot, key: str) -> None: def _start_poller(bot, key: str) -> None:
"""Create and track a poller task.""" """Create and track a poller task."""
existing = _pollers.get(key) ps = _ps(bot)
existing = ps["pollers"].get(key)
if existing and not existing.done(): if existing and not existing.done():
return return
task = asyncio.create_task(_poll_loop(bot, key)) task = asyncio.create_task(_poll_loop(bot, key))
_pollers[key] = task ps["pollers"][key] = task
def _stop_poller(key: str) -> None: def _stop_poller(bot, key: str) -> None:
"""Cancel and remove a poller task.""" """Cancel and remove a poller task."""
task = _pollers.pop(key, None) ps = _ps(bot)
task = ps["pollers"].pop(key, None)
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
_monitors.pop(key, None) ps["monitors"].pop(key, None)
_errors.pop(key, 0) ps["errors"].pop(key, 0)
# -- Restore on connect ----------------------------------------------------- # -- Restore on connect -----------------------------------------------------
def _restore(bot) -> None: def _restore(bot) -> None:
"""Rebuild pollers from persisted state.""" """Rebuild pollers from persisted state."""
ps = _ps(bot)
for key in bot.state.keys("pastemoni"): for key in bot.state.keys("pastemoni"):
existing = _pollers.get(key) existing = ps["pollers"].get(key)
if existing and not existing.done(): if existing and not existing.done():
continue continue
data = _load(bot, key) data = _load(bot, key)
if data is None: if data is None:
continue continue
_monitors[key] = data ps["monitors"][key] = data
_start_poller(bot, key) _start_poller(bot, key)
@@ -417,9 +426,9 @@ async def cmd_pastemoni(bot, message):
if data is None: if data is None:
await bot.reply(message, f"No monitor '{name}' in this channel") await bot.reply(message, f"No monitor '{name}' in this channel")
return return
_monitors[key] = data _ps(bot)["monitors"][key] = data
await _poll_once(bot, key, announce=True) await _poll_once(bot, key, announce=True)
data = _monitors.get(key, data) data = _ps(bot)["monitors"].get(key, data)
errs = data.get("last_errors", {}) errs = data.get("last_errors", {})
if errs: if errs:
tags = ", ".join(sorted(errs)) tags = ", ".join(sorted(errs))
@@ -480,7 +489,7 @@ async def cmd_pastemoni(bot, message):
"seen": {}, "seen": {},
} }
_save(bot, key, data) _save(bot, key, data)
_monitors[key] = data _ps(bot)["monitors"][key] = data
async def _seed(): async def _seed():
await _poll_once(bot, key, announce=False) await _poll_once(bot, key, announce=False)
@@ -514,7 +523,7 @@ async def cmd_pastemoni(bot, message):
await bot.reply(message, f"No monitor '{name}' in this channel") await bot.reply(message, f"No monitor '{name}' in this channel")
return return
_stop_poller(key) _stop_poller(bot, key)
_delete(bot, key) _delete(bot, key)
await bot.reply(message, f"Removed '{name}'") await bot.reply(message, f"Removed '{name}'")
return return

View File

@@ -118,32 +118,35 @@ def _delete_saved(bot, rid: str) -> None:
bot.state.delete("remind", rid) bot.state.delete("remind", rid)
# ---- In-memory tracking ----------------------------------------------------- # ---- Per-bot runtime state --------------------------------------------------
# {rid: (task, target, nick, label, created, repeating)} def _ps(bot):
_reminders: dict[str, tuple[asyncio.Task, str, str, str, str, bool]] = {} """Per-bot plugin runtime state."""
# Reverse lookup: (target, nick) -> [rid, ...] return bot._pstate.setdefault("remind", {
_by_user: dict[tuple[str, str], list[str]] = {} "reminders": {},
# Calendar-based rids (persisted) "by_user": {},
_calendar: set[str] = set() "calendar": set(),
})
def _cleanup(rid: str, target: str, nick: str) -> None: def _cleanup(bot, rid: str, target: str, nick: str) -> None:
"""Remove a reminder from tracking structures.""" """Remove a reminder from tracking structures."""
_reminders.pop(rid, None) ps = _ps(bot)
_calendar.discard(rid) ps["reminders"].pop(rid, None)
ps["calendar"].discard(rid)
ukey = (target, nick) ukey = (target, nick)
if ukey in _by_user: if ukey in ps["by_user"]:
_by_user[ukey] = [r for r in _by_user[ukey] if r != rid] ps["by_user"][ukey] = [r for r in ps["by_user"][ukey] if r != rid]
if not _by_user[ukey]: if not ps["by_user"][ukey]:
del _by_user[ukey] del ps["by_user"][ukey]
def _track(rid: str, task: asyncio.Task, target: str, nick: str, def _track(bot, rid: str, task: asyncio.Task, target: str, nick: str,
label: str, created: str, repeating: bool) -> None: label: str, created: str, repeating: bool) -> None:
"""Add a reminder to in-memory tracking.""" """Add a reminder to in-memory tracking."""
_reminders[rid] = (task, target, nick, label, created, repeating) ps = _ps(bot)
_by_user.setdefault((target, nick), []).append(rid) ps["reminders"][rid] = (task, target, nick, label, created, repeating)
ps["by_user"].setdefault((target, nick), []).append(rid)
# ---- Coroutines ------------------------------------------------------------- # ---- Coroutines -------------------------------------------------------------
@@ -159,7 +162,7 @@ async def _remind_once(bot, rid: str, target: str, nick: str, label: str,
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
finally: finally:
_cleanup(rid, target, nick) _cleanup(bot, rid, target, nick)
async def _remind_repeat(bot, rid: str, target: str, nick: str, label: str, async def _remind_repeat(bot, rid: str, target: str, nick: str, label: str,
@@ -174,7 +177,7 @@ async def _remind_repeat(bot, rid: str, target: str, nick: str, label: str,
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
finally: finally:
_cleanup(rid, target, nick) _cleanup(bot, rid, target, nick)
async def _schedule_at(bot, rid: str, target: str, nick: str, label: str, async def _schedule_at(bot, rid: str, target: str, nick: str, label: str,
@@ -191,7 +194,7 @@ async def _schedule_at(bot, rid: str, target: str, nick: str, label: str,
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
finally: finally:
_cleanup(rid, target, nick) _cleanup(bot, rid, target, nick)
async def _schedule_yearly(bot, rid: str, target: str, nick: str, async def _schedule_yearly(bot, rid: str, target: str, nick: str,
@@ -219,16 +222,17 @@ async def _schedule_yearly(bot, rid: str, target: str, nick: str,
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
finally: finally:
_cleanup(rid, target, nick) _cleanup(bot, rid, target, nick)
# ---- Restore on connect ----------------------------------------------------- # ---- Restore on connect -----------------------------------------------------
def _restore(bot) -> None: def _restore(bot) -> None:
"""Restore persisted calendar reminders from bot.state.""" """Restore persisted calendar reminders from bot.state."""
ps = _ps(bot)
for rid in bot.state.keys("remind"): for rid in bot.state.keys("remind"):
# Skip if already active # Skip if already active
entry = _reminders.get(rid) entry = ps["reminders"].get(rid)
if entry and not entry[0].done(): if entry and not entry[0].done():
continue continue
raw = bot.state.get("remind", rid) raw = bot.state.get("remind", rid)
@@ -272,8 +276,8 @@ def _restore(bot) -> None:
else: else:
continue continue
_calendar.add(rid) ps["calendar"].add(rid)
_track(rid, task, target, nick, label, created, rtype == "yearly") _track(bot, rid, task, target, nick, label, created, rtype == "yearly")
@event("001") @event("001")
@@ -311,12 +315,13 @@ async def cmd_remind(bot, message):
# ---- List ---------------------------------------------------------------- # ---- List ----------------------------------------------------------------
if sub == "list": if sub == "list":
rids = _by_user.get(ukey, []) ps = _ps(bot)
rids = ps["by_user"].get(ukey, [])
active = [] active = []
for rid in rids: for rid in rids:
entry = _reminders.get(rid) entry = ps["reminders"].get(rid)
if entry and not entry[0].done(): if entry and not entry[0].done():
if rid in _calendar: if rid in ps["calendar"]:
# Show next fire time # Show next fire time
raw = bot.state.get("remind", rid) raw = bot.state.get("remind", rid)
if raw: if raw:
@@ -347,7 +352,7 @@ async def cmd_remind(bot, message):
if not rid: if not rid:
await bot.reply(message, "Usage: !remind cancel <id>") await bot.reply(message, "Usage: !remind cancel <id>")
return return
entry = _reminders.get(rid) entry = _ps(bot)["reminders"].get(rid)
if entry and not entry[0].done() and entry[2] == nick: if entry and not entry[0].done() and entry[2] == nick:
entry[0].cancel() entry[0].cancel()
_delete_saved(bot, rid) _delete_saved(bot, rid)
@@ -397,11 +402,11 @@ async def cmd_remind(bot, message):
"created": created, "created": created,
} }
_save(bot, rid, data) _save(bot, rid, data)
_calendar.add(rid) _ps(bot)["calendar"].add(rid)
task = asyncio.create_task( task = asyncio.create_task(
_schedule_at(bot, rid, target, nick, label, fire_utc, created), _schedule_at(bot, rid, target, nick, label, fire_utc, created),
) )
_track(rid, task, target, nick, label, created, False) _track(bot, rid, task, target, nick, label, created, False)
local_str = fire_dt.strftime("%Y-%m-%d %H:%M") local_str = fire_dt.strftime("%Y-%m-%d %H:%M")
await bot.reply(message, f"Reminder #{rid} set (at {local_str})") await bot.reply(message, f"Reminder #{rid} set (at {local_str})")
return return
@@ -459,12 +464,12 @@ async def cmd_remind(bot, message):
"created": created, "created": created,
} }
_save(bot, rid, data) _save(bot, rid, data)
_calendar.add(rid) _ps(bot)["calendar"].add(rid)
task = asyncio.create_task( task = asyncio.create_task(
_schedule_yearly(bot, rid, target, nick, label, fire_utc, _schedule_yearly(bot, rid, target, nick, label, fire_utc,
month, day_raw, hour, minute, tz, created), month, day_raw, hour, minute, tz, created),
) )
_track(rid, task, target, nick, label, created, True) _track(bot, rid, task, target, nick, label, created, True)
local_str = fire_dt.strftime("%Y-%m-%d %H:%M") local_str = fire_dt.strftime("%Y-%m-%d %H:%M")
await bot.reply(message, f"Reminder #{rid} set (yearly {month_day}, next {local_str})") await bot.reply(message, f"Reminder #{rid} set (yearly {month_day}, next {local_str})")
return return
@@ -501,7 +506,7 @@ async def cmd_remind(bot, message):
_remind_once(bot, rid, target, nick, label, duration, created), _remind_once(bot, rid, target, nick, label, duration, created),
) )
_track(rid, task, target, nick, label, created, repeating) _track(bot, rid, task, target, nick, label, created, repeating)
kind = f"every {_format_duration(duration)}" if repeating else _format_duration(duration) kind = f"every {_format_duration(duration)}" if repeating else _format_duration(duration)
await bot.reply(message, f"Reminder #{rid} set ({kind})") await bot.reply(message, f"Reminder #{rid} set ({kind})")

View File

@@ -27,11 +27,15 @@ _MAX_FEEDS = 20
_ATOM_NS = "{http://www.w3.org/2005/Atom}" _ATOM_NS = "{http://www.w3.org/2005/Atom}"
_DC_NS = "{http://purl.org/dc/elements/1.1/}" _DC_NS = "{http://purl.org/dc/elements/1.1/}"
# -- Module-level tracking --------------------------------------------------- # -- Per-bot runtime state ---------------------------------------------------
_pollers: dict[str, asyncio.Task] = {} def _ps(bot):
_feeds: dict[str, dict] = {} """Per-bot plugin runtime state."""
_errors: dict[str, int] = {} return bot._pstate.setdefault("rss", {
"pollers": {},
"feeds": {},
"errors": {},
})
# -- Pure helpers ------------------------------------------------------------ # -- Pure helpers ------------------------------------------------------------
@@ -209,12 +213,13 @@ def _parse_feed(body: bytes) -> tuple[str, list[dict]]:
async def _poll_once(bot, key: str, announce: bool = True) -> None: async def _poll_once(bot, key: str, announce: bool = True) -> None:
"""Single poll cycle for one feed.""" """Single poll cycle for one feed."""
data = _feeds.get(key) ps = _ps(bot)
data = ps["feeds"].get(key)
if data is None: if data is None:
data = _load(bot, key) data = _load(bot, key)
if data is None: if data is None:
return return
_feeds[key] = data ps["feeds"][key] = data
url = data["url"] url = data["url"]
etag = data.get("etag", "") etag = data.get("etag", "")
@@ -230,16 +235,16 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
if result["error"]: if result["error"]:
data["last_error"] = result["error"] data["last_error"] = result["error"]
_errors[key] = _errors.get(key, 0) + 1 ps["errors"][key] = ps["errors"].get(key, 0) + 1
_feeds[key] = data ps["feeds"][key] = data
_save(bot, key, data) _save(bot, key, data)
return return
# HTTP 304 -- not modified # HTTP 304 -- not modified
if result["status"] == 304: if result["status"] == 304:
data["last_error"] = "" data["last_error"] = ""
_errors[key] = 0 ps["errors"][key] = 0
_feeds[key] = data ps["feeds"][key] = data
_save(bot, key, data) _save(bot, key, data)
return return
@@ -247,14 +252,14 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
data["etag"] = result["etag"] data["etag"] = result["etag"]
data["last_modified"] = result["last_modified"] data["last_modified"] = result["last_modified"]
data["last_error"] = "" data["last_error"] = ""
_errors[key] = 0 ps["errors"][key] = 0
try: try:
feed_title, items = _parse_feed(result["body"]) feed_title, items = _parse_feed(result["body"])
except Exception as exc: except Exception as exc:
data["last_error"] = f"Parse error: {exc}" data["last_error"] = f"Parse error: {exc}"
_errors[key] = _errors.get(key, 0) + 1 ps["errors"][key] = ps["errors"].get(key, 0) + 1
_feeds[key] = data ps["feeds"][key] = data
_save(bot, key, data) _save(bot, key, data)
return return
@@ -292,7 +297,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
seen_list = seen_list[-_MAX_SEEN:] seen_list = seen_list[-_MAX_SEEN:]
data["seen"] = seen_list data["seen"] = seen_list
_feeds[key] = data ps["feeds"][key] = data
_save(bot, key, data) _save(bot, key, data)
@@ -300,12 +305,13 @@ async def _poll_loop(bot, key: str) -> None:
"""Infinite poll loop for one feed.""" """Infinite poll loop for one feed."""
try: try:
while True: while True:
data = _feeds.get(key) or _load(bot, key) ps = _ps(bot)
data = ps["feeds"].get(key) or _load(bot, key)
if data is None: if data is None:
return return
interval = data.get("interval", _DEFAULT_INTERVAL) interval = data.get("interval", _DEFAULT_INTERVAL)
# Back off on consecutive errors # Back off on consecutive errors
errs = _errors.get(key, 0) errs = ps["errors"].get(key, 0)
if errs >= 5: if errs >= 5:
interval = min(interval * 2, _MAX_INTERVAL) interval = min(interval * 2, _MAX_INTERVAL)
await asyncio.sleep(interval) await asyncio.sleep(interval)
@@ -316,34 +322,37 @@ async def _poll_loop(bot, key: str) -> None:
def _start_poller(bot, key: str) -> None: def _start_poller(bot, key: str) -> None:
"""Create and track a poller task.""" """Create and track a poller task."""
existing = _pollers.get(key) ps = _ps(bot)
existing = ps["pollers"].get(key)
if existing and not existing.done(): if existing and not existing.done():
return return
task = asyncio.create_task(_poll_loop(bot, key)) task = asyncio.create_task(_poll_loop(bot, key))
_pollers[key] = task ps["pollers"][key] = task
def _stop_poller(key: str) -> None: def _stop_poller(bot, key: str) -> None:
"""Cancel and remove a poller task.""" """Cancel and remove a poller task."""
task = _pollers.pop(key, None) ps = _ps(bot)
task = ps["pollers"].pop(key, None)
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
_feeds.pop(key, None) ps["feeds"].pop(key, None)
_errors.pop(key, 0) ps["errors"].pop(key, 0)
# -- Restore on connect ----------------------------------------------------- # -- Restore on connect -----------------------------------------------------
def _restore(bot) -> None: def _restore(bot) -> None:
"""Rebuild pollers from persisted state.""" """Rebuild pollers from persisted state."""
ps = _ps(bot)
for key in bot.state.keys("rss"): for key in bot.state.keys("rss"):
existing = _pollers.get(key) existing = ps["pollers"].get(key)
if existing and not existing.done(): if existing and not existing.done():
continue continue
data = _load(bot, key) data = _load(bot, key)
if data is None: if data is None:
continue continue
_feeds[key] = data ps["feeds"][key] = data
_start_poller(bot, key) _start_poller(bot, key)
@@ -411,9 +420,10 @@ async def cmd_rss(bot, message):
if data is None: if data is None:
await bot.reply(message, f"No feed '{name}' in this channel") await bot.reply(message, f"No feed '{name}' in this channel")
return return
_feeds[key] = data ps = _ps(bot)
ps["feeds"][key] = data
await _poll_once(bot, key, announce=True) await _poll_once(bot, key, announce=True)
data = _feeds.get(key, data) data = ps["feeds"].get(key, data)
if data.get("last_error"): if data.get("last_error"):
await bot.reply(message, f"{name}: error -- {data['last_error']}") await bot.reply(message, f"{name}: error -- {data['last_error']}")
else: else:
@@ -494,7 +504,7 @@ async def cmd_rss(bot, message):
"title": feed_title, "title": feed_title,
} }
_save(bot, key, data) _save(bot, key, data)
_feeds[key] = data _ps(bot)["feeds"][key] = data
_start_poller(bot, key) _start_poller(bot, key)
display = feed_title or name display = feed_title or name
@@ -525,7 +535,7 @@ async def cmd_rss(bot, message):
await bot.reply(message, f"No feed '{name}' in this channel") await bot.reply(message, f"No feed '{name}' in this channel")
return return
_stop_poller(key) _stop_poller(bot, key)
_delete(bot, key) _delete(bot, key)
await bot.reply(message, f"Unsubscribed '{name}'") await bot.reply(message, f"Unsubscribed '{name}'")
return return

View File

@@ -6,6 +6,7 @@ import json
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from derp.http import urlopen as _urlopen
from derp.plugin import command from derp.plugin import command
# -- Constants --------------------------------------------------------------- # -- Constants ---------------------------------------------------------------
@@ -38,7 +39,7 @@ def _search(query: str) -> list[dict]:
url = f"{_SEARX_URL}?{params}" url = f"{_SEARX_URL}?{params}"
req = urllib.request.Request(url, method="GET") 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() raw = resp.read()
resp.close() resp.close()

View File

@@ -23,11 +23,15 @@ _FETCH_TIMEOUT = 10
_MAX_TITLE_LEN = 80 _MAX_TITLE_LEN = 80
_MAX_STREAMERS = 20 _MAX_STREAMERS = 20
# -- Module-level tracking --------------------------------------------------- # -- Per-bot runtime state ---------------------------------------------------
_pollers: dict[str, asyncio.Task] = {} def _ps(bot):
_streamers: dict[str, dict] = {} """Per-bot plugin runtime state."""
_errors: dict[str, int] = {} return bot._pstate.setdefault("twitch", {
"pollers": {},
"streamers": {},
"errors": {},
})
# -- Pure helpers ------------------------------------------------------------ # -- Pure helpers ------------------------------------------------------------
@@ -149,12 +153,13 @@ def _delete(bot, key: str) -> None:
async def _poll_once(bot, key: str, announce: bool = True) -> None: async def _poll_once(bot, key: str, announce: bool = True) -> None:
"""Single poll cycle for one Twitch streamer.""" """Single poll cycle for one Twitch streamer."""
data = _streamers.get(key) ps = _ps(bot)
data = ps["streamers"].get(key)
if data is None: if data is None:
data = _load(bot, key) data = _load(bot, key)
if data is None: if data is None:
return return
_streamers[key] = data ps["streamers"][key] = data
login = data["login"] login = data["login"]
@@ -166,13 +171,13 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
if result["error"]: if result["error"]:
data["last_error"] = result["error"] data["last_error"] = result["error"]
_errors[key] = _errors.get(key, 0) + 1 ps["errors"][key] = ps["errors"].get(key, 0) + 1
_streamers[key] = data ps["streamers"][key] = data
_save(bot, key, data) _save(bot, key, data)
return return
data["last_error"] = "" data["last_error"] = ""
_errors[key] = 0 ps["errors"][key] = 0
was_live = data.get("was_live", False) was_live = data.get("was_live", False)
old_stream_id = data.get("stream_id", "") old_stream_id = data.get("stream_id", "")
@@ -202,7 +207,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
else: else:
data["was_live"] = False data["was_live"] = False
_streamers[key] = data ps["streamers"][key] = data
_save(bot, key, data) _save(bot, key, data)
@@ -210,11 +215,12 @@ async def _poll_loop(bot, key: str) -> None:
"""Infinite poll loop for one Twitch streamer.""" """Infinite poll loop for one Twitch streamer."""
try: try:
while True: while True:
data = _streamers.get(key) or _load(bot, key) ps = _ps(bot)
data = ps["streamers"].get(key) or _load(bot, key)
if data is None: if data is None:
return return
interval = data.get("interval", _DEFAULT_INTERVAL) interval = data.get("interval", _DEFAULT_INTERVAL)
errs = _errors.get(key, 0) errs = ps["errors"].get(key, 0)
if errs >= 5: if errs >= 5:
interval = min(interval * 2, _MAX_INTERVAL) interval = min(interval * 2, _MAX_INTERVAL)
await asyncio.sleep(interval) await asyncio.sleep(interval)
@@ -225,34 +231,37 @@ async def _poll_loop(bot, key: str) -> None:
def _start_poller(bot, key: str) -> None: def _start_poller(bot, key: str) -> None:
"""Create and track a poller task.""" """Create and track a poller task."""
existing = _pollers.get(key) ps = _ps(bot)
existing = ps["pollers"].get(key)
if existing and not existing.done(): if existing and not existing.done():
return return
task = asyncio.create_task(_poll_loop(bot, key)) task = asyncio.create_task(_poll_loop(bot, key))
_pollers[key] = task ps["pollers"][key] = task
def _stop_poller(key: str) -> None: def _stop_poller(bot, key: str) -> None:
"""Cancel and remove a poller task.""" """Cancel and remove a poller task."""
task = _pollers.pop(key, None) ps = _ps(bot)
task = ps["pollers"].pop(key, None)
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
_streamers.pop(key, None) ps["streamers"].pop(key, None)
_errors.pop(key, 0) ps["errors"].pop(key, 0)
# -- Restore on connect ----------------------------------------------------- # -- Restore on connect -----------------------------------------------------
def _restore(bot) -> None: def _restore(bot) -> None:
"""Rebuild pollers from persisted state.""" """Rebuild pollers from persisted state."""
ps = _ps(bot)
for key in bot.state.keys("twitch"): for key in bot.state.keys("twitch"):
existing = _pollers.get(key) existing = ps["pollers"].get(key)
if existing and not existing.done(): if existing and not existing.done():
continue continue
data = _load(bot, key) data = _load(bot, key)
if data is None: if data is None:
continue continue
_streamers[key] = data ps["streamers"][key] = data
_start_poller(bot, key) _start_poller(bot, key)
@@ -329,9 +338,10 @@ async def cmd_twitch(bot, message):
if data is None: if data is None:
await bot.reply(message, f"No streamer '{name}' in this channel") await bot.reply(message, f"No streamer '{name}' in this channel")
return return
_streamers[key] = data ps = _ps(bot)
ps["streamers"][key] = data
await _poll_once(bot, key, announce=True) await _poll_once(bot, key, announce=True)
data = _streamers.get(key, data) data = ps["streamers"].get(key, data)
if data.get("last_error"): if data.get("last_error"):
await bot.reply(message, f"{name}: error -- {data['last_error']}") await bot.reply(message, f"{name}: error -- {data['last_error']}")
elif data.get("was_live"): elif data.get("was_live"):
@@ -417,7 +427,7 @@ async def cmd_twitch(bot, message):
"last_error": "", "last_error": "",
} }
_save(bot, key, data) _save(bot, key, data)
_streamers[key] = data _ps(bot)["streamers"][key] = data
_start_poller(bot, key) _start_poller(bot, key)
reply = f"Following '{name}' ({display_name})" reply = f"Following '{name}' ({display_name})"
@@ -446,7 +456,7 @@ async def cmd_twitch(bot, message):
await bot.reply(message, f"No streamer '{name}' in this channel") await bot.reply(message, f"No streamer '{name}' in this channel")
return return
_stop_poller(key) _stop_poller(bot, key)
_delete(bot, key) _delete(bot, key)
await bot.reply(message, f"Unfollowed '{name}'") await bot.reply(message, f"Unfollowed '{name}'")
return return

View File

@@ -38,9 +38,14 @@ _SKIP_EXTS = frozenset({
# Trailing punctuation to strip, but preserve balanced parens # Trailing punctuation to strip, but preserve balanced parens
_TRAIL_CHARS = set(".,;:!?)>]") _TRAIL_CHARS = set(".,;:!?)>]")
# -- Module-level state ------------------------------------------------------ # -- Per-bot state -----------------------------------------------------------
_seen: dict[str, float] = {}
def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("urltitle", {
"seen": {},
})
# -- HTML parser ------------------------------------------------------------- # -- HTML parser -------------------------------------------------------------
@@ -202,21 +207,22 @@ def _fetch_title(url: str) -> tuple[str, str]:
# -- Cooldown ---------------------------------------------------------------- # -- Cooldown ----------------------------------------------------------------
def _check_cooldown(url: str, cooldown: int) -> bool: def _check_cooldown(bot, url: str, cooldown: int) -> bool:
"""Return True if the URL is within the cooldown window.""" """Return True if the URL is within the cooldown window."""
seen = _ps(bot)["seen"]
now = time.monotonic() now = time.monotonic()
last = _seen.get(url) last = seen.get(url)
if last is not None and (now - last) < cooldown: if last is not None and (now - last) < cooldown:
return True return True
# Prune if cache is too large # Prune if cache is too large
if len(_seen) >= _CACHE_MAX: if len(seen) >= _CACHE_MAX:
cutoff = now - cooldown cutoff = now - cooldown
stale = [k for k, v in _seen.items() if v < cutoff] stale = [k for k, v in seen.items() if v < cutoff]
for k in stale: for k in stale:
del _seen[k] del seen[k]
_seen[url] = now seen[url] = now
return False return False
@@ -261,7 +267,7 @@ async def on_privmsg(bot, message):
for url in urls: for url in urls:
if _is_ignored_url(url, ignore_hosts): if _is_ignored_url(url, ignore_hosts):
continue continue
if _check_cooldown(url, cooldown): if _check_cooldown(bot, url, cooldown):
continue continue
title, desc = await loop.run_in_executor(None, _fetch_title, url) title, desc = await loop.run_in_executor(None, _fetch_title, url)

617
plugins/voice.py Normal file
View 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")

View File

@@ -14,9 +14,15 @@ from derp.plugin import command, event
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_MAX_BODY = 65536 # 64 KB _MAX_BODY = 65536 # 64 KB
_server: asyncio.Server | None = None
_request_count: int = 0
_started: float = 0.0 def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("webhook", {
"server": None,
"request_count": 0,
"started": 0.0,
})
def _verify_signature(secret: str, body: bytes, signature: str) -> bool: def _verify_signature(secret: str, body: bytes, signature: str) -> bool:
@@ -47,7 +53,7 @@ async def _handle_request(reader: asyncio.StreamReader,
writer: asyncio.StreamWriter, writer: asyncio.StreamWriter,
bot, secret: str) -> None: bot, secret: str) -> None:
"""Parse one HTTP request and dispatch to IRC.""" """Parse one HTTP request and dispatch to IRC."""
global _request_count ps = _ps(bot)
try: try:
# Read request line # Read request line
@@ -117,7 +123,7 @@ async def _handle_request(reader: asyncio.StreamReader,
else: else:
await bot.send(channel, text) await bot.send(channel, text)
_request_count += 1 ps["request_count"] += 1
writer.write(_http_response(200, "OK", "sent")) writer.write(_http_response(200, "OK", "sent"))
log.info("webhook: relayed to %s (%d bytes)", channel, len(text)) log.info("webhook: relayed to %s (%d bytes)", channel, len(text))
@@ -140,9 +146,9 @@ async def _handle_request(reader: asyncio.StreamReader,
@event("001") @event("001")
async def on_connect(bot, message): async def on_connect(bot, message):
"""Start the webhook HTTP server on connect (if enabled).""" """Start the webhook HTTP server on connect (if enabled)."""
global _server, _started, _request_count ps = _ps(bot)
if _server is not None: if ps["server"] is not None:
return # already running return # already running
cfg = bot.config.get("webhook", {}) cfg = bot.config.get("webhook", {})
@@ -157,9 +163,9 @@ async def on_connect(bot, message):
await _handle_request(reader, writer, bot, secret) await _handle_request(reader, writer, bot, secret)
try: try:
_server = await asyncio.start_server(handler, host, port) ps["server"] = await asyncio.start_server(handler, host, port)
_started = time.monotonic() ps["started"] = time.monotonic()
_request_count = 0 ps["request_count"] = 0
log.info("webhook: listening on %s:%d", host, port) log.info("webhook: listening on %s:%d", host, port)
except OSError as exc: except OSError as exc:
log.error("webhook: failed to bind %s:%d: %s", host, port, exc) log.error("webhook: failed to bind %s:%d: %s", host, port, exc)
@@ -168,18 +174,20 @@ async def on_connect(bot, message):
@command("webhook", help="Show webhook listener status", admin=True) @command("webhook", help="Show webhook listener status", admin=True)
async def cmd_webhook(bot, message): async def cmd_webhook(bot, message):
"""Display webhook server status.""" """Display webhook server status."""
if _server is None: ps = _ps(bot)
if ps["server"] is None:
await bot.reply(message, "Webhook: not running") await bot.reply(message, "Webhook: not running")
return return
socks = _server.sockets socks = ps["server"].sockets
if socks: if socks:
addr = socks[0].getsockname() addr = socks[0].getsockname()
address = f"{addr[0]}:{addr[1]}" address = f"{addr[0]}:{addr[1]}"
else: else:
address = "unknown" address = "unknown"
elapsed = int(time.monotonic() - _started) elapsed = int(time.monotonic() - ps["started"])
hours, rem = divmod(elapsed, 3600) hours, rem = divmod(elapsed, 3600)
minutes, secs = divmod(rem, 60) minutes, secs = divmod(rem, 60)
parts = [] parts = []
@@ -192,5 +200,5 @@ async def cmd_webhook(bot, message):
await bot.reply( await bot.reply(
message, message,
f"Webhook: {address} | {_request_count} requests | up {uptime}", f"Webhook: {address} | {ps['request_count']} requests | up {uptime}",
) )

View File

@@ -40,11 +40,15 @@ _BROWSER_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
_MAX_TITLE_LEN = 80 _MAX_TITLE_LEN = 80
_MAX_CHANNELS = 20 _MAX_CHANNELS = 20
# -- Module-level tracking --------------------------------------------------- # -- Per-bot runtime state ---------------------------------------------------
_pollers: dict[str, asyncio.Task] = {} def _ps(bot):
_channels: dict[str, dict] = {} """Per-bot plugin runtime state."""
_errors: dict[str, int] = {} return bot._pstate.setdefault("yt", {
"pollers": {},
"channels": {},
"errors": {},
})
# -- Pure helpers ------------------------------------------------------------ # -- Pure helpers ------------------------------------------------------------
@@ -317,12 +321,13 @@ def _delete(bot, key: str) -> None:
async def _poll_once(bot, key: str, announce: bool = True) -> None: async def _poll_once(bot, key: str, announce: bool = True) -> None:
"""Single poll cycle for one YouTube channel.""" """Single poll cycle for one YouTube channel."""
data = _channels.get(key) ps = _ps(bot)
data = ps["channels"].get(key)
if data is None: if data is None:
data = _load(bot, key) data = _load(bot, key)
if data is None: if data is None:
return return
_channels[key] = data ps["channels"][key] = data
url = data["feed_url"] url = data["feed_url"]
etag = data.get("etag", "") etag = data.get("etag", "")
@@ -338,16 +343,16 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
if result["error"]: if result["error"]:
data["last_error"] = result["error"] data["last_error"] = result["error"]
_errors[key] = _errors.get(key, 0) + 1 ps["errors"][key] = ps["errors"].get(key, 0) + 1
_channels[key] = data ps["channels"][key] = data
_save(bot, key, data) _save(bot, key, data)
return return
# HTTP 304 -- not modified # HTTP 304 -- not modified
if result["status"] == 304: if result["status"] == 304:
data["last_error"] = "" data["last_error"] = ""
_errors[key] = 0 ps["errors"][key] = 0
_channels[key] = data ps["channels"][key] = data
_save(bot, key, data) _save(bot, key, data)
return return
@@ -355,14 +360,14 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
data["etag"] = result["etag"] data["etag"] = result["etag"]
data["last_modified"] = result["last_modified"] data["last_modified"] = result["last_modified"]
data["last_error"] = "" data["last_error"] = ""
_errors[key] = 0 ps["errors"][key] = 0
try: try:
_, items = _parse_feed(result["body"]) _, items = _parse_feed(result["body"])
except Exception as exc: except Exception as exc:
data["last_error"] = f"Parse error: {exc}" data["last_error"] = f"Parse error: {exc}"
_errors[key] = _errors.get(key, 0) + 1 ps["errors"][key] = ps["errors"].get(key, 0) + 1
_channels[key] = data ps["channels"][key] = data
_save(bot, key, data) _save(bot, key, data)
return return
@@ -429,7 +434,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
seen_list = seen_list[-_MAX_SEEN:] seen_list = seen_list[-_MAX_SEEN:]
data["seen"] = seen_list data["seen"] = seen_list
_channels[key] = data ps["channels"][key] = data
_save(bot, key, data) _save(bot, key, data)
@@ -437,12 +442,13 @@ async def _poll_loop(bot, key: str) -> None:
"""Infinite poll loop for one YouTube channel.""" """Infinite poll loop for one YouTube channel."""
try: try:
while True: while True:
data = _channels.get(key) or _load(bot, key) ps = _ps(bot)
data = ps["channels"].get(key) or _load(bot, key)
if data is None: if data is None:
return return
interval = data.get("interval", _DEFAULT_INTERVAL) interval = data.get("interval", _DEFAULT_INTERVAL)
# Back off on consecutive errors # Back off on consecutive errors
errs = _errors.get(key, 0) errs = ps["errors"].get(key, 0)
if errs >= 5: if errs >= 5:
interval = min(interval * 2, _MAX_INTERVAL) interval = min(interval * 2, _MAX_INTERVAL)
await asyncio.sleep(interval) await asyncio.sleep(interval)
@@ -453,34 +459,37 @@ async def _poll_loop(bot, key: str) -> None:
def _start_poller(bot, key: str) -> None: def _start_poller(bot, key: str) -> None:
"""Create and track a poller task.""" """Create and track a poller task."""
existing = _pollers.get(key) ps = _ps(bot)
existing = ps["pollers"].get(key)
if existing and not existing.done(): if existing and not existing.done():
return return
task = asyncio.create_task(_poll_loop(bot, key)) task = asyncio.create_task(_poll_loop(bot, key))
_pollers[key] = task ps["pollers"][key] = task
def _stop_poller(key: str) -> None: def _stop_poller(bot, key: str) -> None:
"""Cancel and remove a poller task.""" """Cancel and remove a poller task."""
task = _pollers.pop(key, None) ps = _ps(bot)
task = ps["pollers"].pop(key, None)
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
_channels.pop(key, None) ps["channels"].pop(key, None)
_errors.pop(key, 0) ps["errors"].pop(key, 0)
# -- Restore on connect ----------------------------------------------------- # -- Restore on connect -----------------------------------------------------
def _restore(bot) -> None: def _restore(bot) -> None:
"""Rebuild pollers from persisted state.""" """Rebuild pollers from persisted state."""
ps = _ps(bot)
for key in bot.state.keys("yt"): for key in bot.state.keys("yt"):
existing = _pollers.get(key) existing = ps["pollers"].get(key)
if existing and not existing.done(): if existing and not existing.done():
continue continue
data = _load(bot, key) data = _load(bot, key)
if data is None: if data is None:
continue continue
_channels[key] = data ps["channels"][key] = data
_start_poller(bot, key) _start_poller(bot, key)
@@ -548,9 +557,10 @@ async def cmd_yt(bot, message):
if data is None: if data is None:
await bot.reply(message, f"No channel '{name}' in this channel") await bot.reply(message, f"No channel '{name}' in this channel")
return return
_channels[key] = data ps = _ps(bot)
ps["channels"][key] = data
await _poll_once(bot, key, announce=True) await _poll_once(bot, key, announce=True)
data = _channels.get(key, data) data = ps["channels"].get(key, data)
if data.get("last_error"): if data.get("last_error"):
await bot.reply(message, f"{name}: error -- {data['last_error']}") await bot.reply(message, f"{name}: error -- {data['last_error']}")
else: else:
@@ -652,7 +662,7 @@ async def cmd_yt(bot, message):
"title": channel_title, "title": channel_title,
} }
_save(bot, key, data) _save(bot, key, data)
_channels[key] = data _ps(bot)["channels"][key] = data
_start_poller(bot, key) _start_poller(bot, key)
display = channel_title or name display = channel_title or name
@@ -683,7 +693,7 @@ async def cmd_yt(bot, message):
await bot.reply(message, f"No channel '{name}' in this channel") await bot.reply(message, f"No channel '{name}' in this channel")
return return
_stop_poller(key) _stop_poller(bot, key)
_delete(bot, key) _delete(bot, key)
await bot.reply(message, f"Unfollowed '{name}'") await bot.reply(message, f"Unfollowed '{name}'")
return return

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "derp" name = "derp"
version = "0.1.0" version = "2.0.0"
description = "Asyncio IRC bot with plugin system" description = "Asyncio IRC bot with plugin system"
requires-python = ">=3.11" requires-python = ">=3.11"
license = "MIT" license = "MIT"
@@ -22,6 +22,7 @@ where = ["src"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff] [tool.ruff]
line-length = 99 line-length = 99

4
requirements-dev.txt Normal file
View File

@@ -0,0 +1,4 @@
-e .
pymumble>=1.6
pytest>=7.0
ruff>=0.4

View File

@@ -1,3 +1,4 @@
maxminddb>=2.0 maxminddb>=2.0
pymumble>=1.6
PySocks>=1.7.1 PySocks>=1.7.1
urllib3[socks]>=2.0 urllib3[socks]>=2.0

View File

@@ -1,3 +1,3 @@
"""derp - asyncio IRC bot with plugin system.""" """derp - asyncio IRC bot with plugin system."""
__version__ = "0.1.0" __version__ = "2.0.0"

View File

@@ -77,14 +77,17 @@ class _TokenBucket:
class Bot: class Bot:
"""IRC bot: ties connection, config, and plugins together.""" """IRC bot: ties connection, config, and plugins together."""
def __init__(self, config: dict, registry: PluginRegistry) -> None: def __init__(self, name: str, config: dict, registry: PluginRegistry) -> None:
self.name = name
self.config = config self.config = config
self.registry = registry self.registry = registry
self._pstate: dict = {} # per-bot plugin runtime state
self.conn = IRCConnection( self.conn = IRCConnection(
host=config["server"]["host"], host=config["server"]["host"],
port=config["server"]["port"], port=config["server"]["port"],
tls=config["server"]["tls"], tls=config["server"]["tls"],
tls_verify=config["server"].get("tls_verify", True), tls_verify=config["server"].get("tls_verify", True),
proxy=config["server"].get("proxy", False),
) )
self.nick: str = config["server"]["nick"] self.nick: str = config["server"]["nick"]
self.prefix: str = config["bot"]["prefix"] self.prefix: str = config["bot"]["prefix"]
@@ -93,12 +96,13 @@ class Bot:
self._tasks: set[asyncio.Task] = set() self._tasks: set[asyncio.Task] = set()
self._reconnect_delay: float = 5.0 self._reconnect_delay: float = 5.0
self._admins: list[str] = config.get("bot", {}).get("admins", []) 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._operators: list[str] = config.get("bot", {}).get("operators", [])
self._trusted: list[str] = config.get("bot", {}).get("trusted", []) self._trusted: list[str] = config.get("bot", {}).get("trusted", [])
self._opers: set[str] = set() # hostmasks of known IRC operators self._opers: set[str] = set() # hostmasks of known IRC operators
self._caps: set[str] = set() # negotiated IRCv3 caps self._caps: set[str] = set() # negotiated IRCv3 caps
self._who_pending: dict[str, asyncio.Task] = {} # debounced WHO per channel self._who_pending: dict[str, asyncio.Task] = {} # debounced WHO per channel
self.state = StateStore() self.state = StateStore(f"data/state-{name}.db")
# Rate limiter: default 2 msg/sec, burst of 5 # Rate limiter: default 2 msg/sec, burst of 5
rate_cfg = config.get("bot", {}) rate_cfg = config.get("bot", {})
self._bucket = _TokenBucket( self._bucket = _TokenBucket(
@@ -249,7 +253,10 @@ class Bot:
async def _loop(self) -> None: async def _loop(self) -> None:
"""Read and dispatch messages until disconnect.""" """Read and dispatch messages until disconnect."""
while self._running: 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: if line is None:
log.warning("server closed connection") log.warning("server closed connection")
return return
@@ -374,6 +381,9 @@ class Bot:
for pattern in self._admins: for pattern in self._admins:
if fnmatch.fnmatch(msg.prefix, pattern): if fnmatch.fnmatch(msg.prefix, pattern):
return "admin" return "admin"
for pattern in self._sorcerers:
if fnmatch.fnmatch(msg.prefix, pattern):
return "sorcerer"
for pattern in self._operators: for pattern in self._operators:
if fnmatch.fnmatch(msg.prefix, pattern): if fnmatch.fnmatch(msg.prefix, pattern):
return "oper" return "oper"
@@ -398,6 +408,12 @@ class Bot:
parts = text[len(self.prefix):].split(None, 1) parts = text[len(self.prefix):].split(None, 1)
cmd_name = parts[0].lower() if parts else "" cmd_name = parts[0].lower() if parts else ""
handler = self._resolve_command(cmd_name) 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: if handler is None:
return return
if handler is _AMBIGUOUS: if handler is _AMBIGUOUS:

View File

@@ -9,7 +9,8 @@ import sys
from derp import __version__ from derp import __version__
from derp.bot import Bot from derp.bot import Bot
from derp.config import resolve_config from derp.config import build_server_configs, resolve_config
from derp.irc import format_msg
from derp.log import JsonFormatter from derp.log import JsonFormatter
from derp.plugin import PluginRegistry from derp.plugin import PluginRegistry
@@ -37,8 +38,8 @@ def build_parser() -> argparse.ArgumentParser:
"--cprofile", "--cprofile",
metavar="PATH", metavar="PATH",
nargs="?", nargs="?",
const="derp.prof", const="data/derp.prof",
help="enable cProfile; dump stats to PATH [derp.prof]", help="enable cProfile; dump stats to PATH [data/derp.prof]",
) )
p.add_argument( p.add_argument(
"--tracemalloc", "--tracemalloc",
@@ -56,14 +57,14 @@ def build_parser() -> argparse.ArgumentParser:
return p return p
def _run(bot: Bot) -> None: def _run(bots: list) -> None:
"""Run the bot event loop with graceful SIGTERM handling.""" """Run all bots concurrently with graceful SIGTERM handling."""
import signal import signal
async def _start_with_signal(): async def _start_with_signal():
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGTERM, _shutdown, bot) loop.add_signal_handler(signal.SIGTERM, _shutdown, bots)
await bot.start() await asyncio.gather(*(bot.start() for bot in bots))
try: try:
asyncio.run(_start_with_signal()) asyncio.run(_start_with_signal())
@@ -71,11 +72,25 @@ def _run(bot: Bot) -> None:
logging.getLogger("derp").info("interrupted, shutting down") logging.getLogger("derp").info("interrupted, shutting down")
def _shutdown(bot: Bot) -> None: def _shutdown(bots: list) -> None:
"""Signal handler: stop the bot loop so cProfile can flush.""" """Signal handler: stop all bot loops and tear down connections."""
logging.getLogger("derp").info("SIGTERM received, shutting down") logging.getLogger("derp").info("SIGTERM received, shutting down")
bot._running = False loop = asyncio.get_running_loop()
asyncio.get_running_loop().create_task(bot.conn.close()) for bot in bots:
bot._running = False
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: def _dump_tracemalloc(log: logging.Logger, path: str, limit: int = 25) -> None:
@@ -121,9 +136,67 @@ def main(argv: list[str] | None = None) -> int:
log = logging.getLogger("derp") log = logging.getLogger("derp")
log.info("derp %s starting", __version__) log.info("derp %s starting", __version__)
server_configs = build_server_configs(config)
registry = PluginRegistry() registry = PluginRegistry()
bot = Bot(config, registry)
bot.load_plugins() bots: list = []
for name, srv_config in server_configs.items():
bot = Bot(name, srv_config, registry)
bots.append(bot)
# Load plugins once (shared registry)
bots[0].load_plugins()
# Teams adapter (optional)
if config.get("teams", {}).get("enabled"):
from derp.teams import TeamsBot
teams_bot = TeamsBot("teams", config, registry)
bots.append(teams_bot)
# Telegram adapter (optional)
if config.get("telegram", {}).get("enabled"):
from derp.telegram import TelegramBot
tg_bot = TelegramBot("telegram", config, registry)
bots.append(tg_bot)
# Mumble adapter (optional)
if config.get("mumble", {}).get("enabled"):
from derp.mumble import MumbleBot
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)
if args.tracemalloc: if args.tracemalloc:
import tracemalloc import tracemalloc
@@ -133,12 +206,34 @@ def main(argv: list[str] | None = None) -> int:
if args.cprofile: if args.cprofile:
import cProfile import cProfile
import threading
log.info("cProfile enabled, output: %s", args.cprofile) prof = cProfile.Profile()
cProfile.runctx("_run(bot)", globals(), {"bot": bot, "_run": _run}, args.cprofile) prof_path = args.cprofile
log.info("profile saved to %s", 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: else:
_run(bot) _run(bots)
if args.tracemalloc: if args.tracemalloc:
_dump_tracemalloc(log, "data/derp.malloc") _dump_tracemalloc(log, "data/derp.malloc")

View File

@@ -10,6 +10,7 @@ DEFAULTS: dict = {
"host": "irc.libera.chat", "host": "irc.libera.chat",
"port": 6697, "port": 6697,
"tls": True, "tls": True,
"proxy": False,
"nick": "derp", "nick": "derp",
"user": "derp", "user": "derp",
"realname": "derp IRC bot", "realname": "derp IRC bot",
@@ -39,6 +40,39 @@ DEFAULTS: dict = {
"port": 8080, "port": 8080,
"secret": "", "secret": "",
}, },
"teams": {
"enabled": False,
"proxy": True,
"bot_name": "derp",
"bind": "127.0.0.1",
"port": 8081,
"webhook_secret": "",
"incoming_webhook_url": "",
"admins": [],
"operators": [],
"trusted": [],
},
"telegram": {
"enabled": False,
"proxy": True,
"bot_token": "",
"poll_timeout": 30,
"admins": [],
"operators": [],
"trusted": [],
},
"mumble": {
"enabled": False,
"proxy": True,
"host": "127.0.0.1",
"port": 64738,
"username": "derp",
"password": "",
"tls_verify": False,
"admins": [],
"operators": [],
"trusted": [],
},
"logging": { "logging": {
"level": "info", "level": "info",
"format": "text", "format": "text",
@@ -75,3 +109,83 @@ def resolve_config(path: str | None) -> dict:
if p and p.is_file(): if p and p.is_file():
return load(p) return load(p)
return DEFAULTS.copy() return DEFAULTS.copy()
def _server_name(host: str) -> str:
"""Derive a short server name from a hostname.
``irc.libera.chat`` -> ``libera``, ``chat.freenode.net`` -> ``freenode``.
Falls back to the full host if no suitable label is found.
"""
parts = host.split(".")
for part in parts:
if part not in ("irc", "chat", ""):
return part
return host
_SERVER_KEYS = set(DEFAULTS["server"])
_BOT_KEYS = set(DEFAULTS["bot"])
def build_server_configs(raw: dict) -> dict[str, dict]:
"""Build per-server config dicts from a merged config.
Supports two layouts:
**Legacy** (``[server]`` section, no ``[servers]``):
Returns a single-entry dict with the server name derived from the
hostname. Existing config files work unchanged.
**Multi-server** (``[servers.<name>]`` sections):
Each ``[servers.<name>]`` block may contain both server-level keys
(host, port, tls, nick, ...) and bot-level overrides (prefix,
channels, admins, ...). Unset keys inherit from the top-level
``[bot]`` and ``[server]`` defaults.
Returns ``{name: config_dict}`` where each *config_dict* has the
canonical shape ``{"server": {...}, "bot": {...}, "channels": {...},
"webhook": {...}, "logging": {...}}``.
"""
servers_section = raw.get("servers")
# -- Legacy single-server layout --
if not servers_section or not isinstance(servers_section, dict):
name = _server_name(raw.get("server", {}).get("host", "default"))
return {name: raw}
# -- Multi-server layout --
# Shared top-level sections
shared_bot = raw.get("bot", {})
shared_server = raw.get("server", {})
shared_channels = raw.get("channels", {})
shared_webhook = raw.get("webhook", {})
shared_logging = raw.get("logging", {})
result: dict[str, dict] = {}
for name, block in servers_section.items():
if not isinstance(block, dict):
continue
# Separate server keys from bot-override keys
srv: dict = {}
bot_overrides: dict = {}
extra: dict = {}
for key, val in block.items():
if key in _SERVER_KEYS:
srv[key] = val
elif key in _BOT_KEYS:
bot_overrides[key] = val
else:
extra[key] = val
cfg = {
"server": _merge(DEFAULTS["server"], _merge(shared_server, srv)),
"bot": _merge(DEFAULTS["bot"], _merge(shared_bot, bot_overrides)),
"channels": _merge(shared_channels, extra.get("channels", {})),
"webhook": _merge(DEFAULTS["webhook"], shared_webhook),
"logging": _merge(DEFAULTS["logging"], shared_logging),
}
result[name] = cfg
return result

View File

@@ -1,4 +1,4 @@
"""Proxy-aware HTTP/TCP helpers -- routes outbound traffic through SOCKS5.""" """HTTP/TCP helpers -- optional SOCKS5 proxy routing for outbound traffic."""
import asyncio import asyncio
import logging import logging
@@ -40,8 +40,8 @@ def _get_pool() -> SOCKSProxyManager:
if _pool is None: if _pool is None:
_pool = SOCKSProxyManager( _pool = SOCKSProxyManager(
f"socks5h://{_PROXY_ADDR}:{_PROXY_PORT}/", f"socks5h://{_PROXY_ADDR}:{_PROXY_PORT}/",
num_pools=20, num_pools=30,
maxsize=4, maxsize=8,
retries=_POOL_RETRIES, retries=_POOL_RETRIES,
) )
return _pool return _pool
@@ -85,15 +85,56 @@ class _ProxyHandler(SocksiPyHandler, urllib.request.HTTPSHandler):
# -- Public HTTP interface --------------------------------------------------- # -- Public HTTP interface ---------------------------------------------------
def urlopen(req, *, timeout=None, context=None, retries=None):
"""Proxy-aware drop-in for urllib.request.urlopen.
Uses connection pooling via urllib3 for default requests. 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. 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. 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. Retries on transient SSL/connection errors with exponential backoff.
""" """
max_retries = retries if retries is not None else _MAX_RETRIES max_retries = retries if retries is not None else _MAX_RETRIES
# Direct (no proxy) path
if not proxy:
return _urlopen_direct(req, timeout=timeout, context=context, retries=max_retries)
# Custom SSL context -> fall back to opener (rare: username.py only) # Custom SSL context -> fall back to opener (rare: username.py only)
if context is not None: if context is not None:
return _urlopen_legacy(req, timeout=timeout, context=context, retries=max_retries) return _urlopen_legacy(req, timeout=timeout, context=context, retries=max_retries)
@@ -118,17 +159,14 @@ def urlopen(req, *, timeout=None, context=None, retries=None):
headers=headers, headers=headers,
body=body, body=body,
timeout=to, timeout=to,
preload_content=False, preload_content=True,
) )
if resp.status >= 400: 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( raise urllib.error.HTTPError(
url, resp.status, resp.reason or "", url, resp.status, resp.reason or "",
resp.headers, None, resp.headers, None,
) )
return resp return _PooledResponse(resp)
except urllib.error.HTTPError: except urllib.error.HTTPError:
raise raise
except _RETRY_ERRORS as exc: except _RETRY_ERRORS as exc:
@@ -140,6 +178,26 @@ def urlopen(req, *, timeout=None, context=None, retries=None):
time.sleep(delay) time.sleep(delay)
def _urlopen_direct(req, *, timeout=None, context=None, retries=None):
"""Open URL directly without SOCKS5 proxy."""
max_retries = retries if retries is not None else _MAX_RETRIES
kwargs = {}
if timeout is not None:
kwargs["timeout"] = timeout
if context is not None:
kwargs["context"] = context
for attempt in range(max_retries):
try:
return urllib.request.urlopen(req, **kwargs)
except _RETRY_ERRORS as exc:
if attempt + 1 >= max_retries:
raise
delay = 2 ** attempt
_log.debug("urlopen_direct retry %d/%d after %s: %s",
attempt + 1, max_retries, type(exc).__name__, exc)
time.sleep(delay)
def _urlopen_legacy(req, *, timeout=None, context=None, retries=None): def _urlopen_legacy(req, *, timeout=None, context=None, retries=None):
"""Open URL through legacy opener (custom SSL context).""" """Open URL through legacy opener (custom SSL context)."""
max_retries = retries if retries is not None else _MAX_RETRIES max_retries = retries if retries is not None else _MAX_RETRIES
@@ -159,27 +217,32 @@ def _urlopen_legacy(req, *, timeout=None, context=None, retries=None):
time.sleep(delay) time.sleep(delay)
def build_opener(*handlers, context=None): def build_opener(*handlers, context=None, proxy=True):
"""Proxy-aware drop-in for urllib.request.build_opener.""" """Build a URL opener, optionally with SOCKS5 proxy."""
if not proxy:
return urllib.request.build_opener(*handlers)
if not handlers and context is None: if not handlers and context is None:
return _get_opener() return _get_opener()
proxy = _ProxyHandler(context=context) proxy_handler = _ProxyHandler(context=context)
return urllib.request.build_opener(proxy, *handlers) return urllib.request.build_opener(proxy_handler, *handlers)
# -- Raw TCP helpers (unchanged) --------------------------------------------- # -- Raw TCP helpers (unchanged) ---------------------------------------------
def create_connection(address, *, timeout=None): def create_connection(address, *, timeout=None, proxy=True):
"""SOCKS5-proxied drop-in for socket.create_connection. """Drop-in for socket.create_connection, optionally through SOCKS5.
Returns a connected socksocket (usable as context manager). Returns a connected socket (usable as context manager).
Retries on transient connection errors with exponential backoff. Retries on transient connection errors with exponential backoff.
""" """
host, port = address host, port = address
for attempt in range(_MAX_RETRIES): for attempt in range(_MAX_RETRIES):
try: try:
sock = socks.socksocket() if proxy:
sock.set_proxy(SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True) sock = socks.socksocket()
sock.set_proxy(SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True)
else:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
if timeout is not None: if timeout is not None:
sock.settimeout(timeout) sock.settimeout(timeout)
sock.connect((host, port)) sock.connect((host, port))
@@ -193,12 +256,27 @@ def create_connection(address, *, timeout=None):
time.sleep(delay) time.sleep(delay)
async def open_connection(host, port, *, timeout=None): async def open_connection(host, port, *, timeout=None, proxy=True):
"""SOCKS5-proxied drop-in for asyncio.open_connection. """Async TCP connection, optionally through SOCKS5.
SOCKS5 handshake runs in a thread executor; returns (reader, writer). When proxied, SOCKS5 handshake runs in a thread executor.
Returns (reader, writer).
Retries on transient connection errors with exponential backoff. Retries on transient connection errors with exponential backoff.
""" """
if not proxy:
# Direct asyncio connection
for attempt in range(_MAX_RETRIES):
try:
return await asyncio.open_connection(host, port)
except _RETRY_ERRORS as exc:
if attempt + 1 >= _MAX_RETRIES:
raise
delay = 2 ** attempt
_log.debug("open_connection retry %d/%d after %s: %s",
attempt + 1, _MAX_RETRIES, type(exc).__name__, exc)
await asyncio.sleep(delay)
return # unreachable but satisfies type checker
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
def _connect(): def _connect():

View File

@@ -137,11 +137,12 @@ class IRCConnection:
"""Async TCP/TLS connection to an IRC server.""" """Async TCP/TLS connection to an IRC server."""
def __init__(self, host: str, port: int, tls: bool = True, def __init__(self, host: str, port: int, tls: bool = True,
tls_verify: bool = True) -> None: tls_verify: bool = True, proxy: bool = False) -> None:
self.host = host self.host = host
self.port = port self.port = port
self.tls = tls self.tls = tls
self.tls_verify = tls_verify self.tls_verify = tls_verify
self.proxy = proxy
self._reader: asyncio.StreamReader | None = None self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None self._writer: asyncio.StreamWriter | None = None
@@ -154,10 +155,26 @@ class IRCConnection:
ssl_ctx.check_hostname = False ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE ssl_ctx.verify_mode = ssl.CERT_NONE
log.info("connecting to %s:%d (tls=%s)", self.host, self.port, self.tls) log.info("connecting to %s:%d (tls=%s, proxy=%s)",
self._reader, self._writer = await asyncio.open_connection( self.host, self.port, self.tls, self.proxy)
self.host, self.port, ssl=ssl_ctx if self.proxy:
) from derp import http
reader, writer = await http.open_connection(
self.host, self.port,
)
if self.tls:
hostname = self.host if self.tls_verify else None
self._reader, self._writer = await asyncio.open_connection(
sock=writer.transport.get_extra_info("socket"),
ssl=ssl_ctx,
server_hostname=hostname,
)
else:
self._reader, self._writer = reader, writer
else:
self._reader, self._writer = await asyncio.open_connection(
self.host, self.port, ssl=ssl_ctx,
)
log.info("connected") log.info("connected")
async def send(self, line: str) -> None: async def send(self, line: str) -> None:

1040
src/derp/mumble.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ from typing import Any, Callable
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
TIERS: tuple[str, ...] = ("user", "trusted", "oper", "admin") TIERS: tuple[str, ...] = ("user", "trusted", "oper", "sorcerer", "admin")
@dataclass(slots=True) @dataclass(slots=True)
@@ -27,7 +27,13 @@ class Handler:
tier: str = "user" 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. """Decorator to register an async function as a bot command.
Usage:: Usage::
@@ -40,8 +46,8 @@ def command(name: str, help: str = "", admin: bool = False, tier: str = "") -> C
async def cmd_reload(bot, message): async def cmd_reload(bot, message):
... ...
@command("trusted_cmd", help="Trusted-only", tier="trusted") @command("skip", help="Skip track", aliases=["next"])
async def cmd_trusted(bot, message): 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_help = help # type: ignore[attr-defined]
func._derp_admin = admin # 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_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 func
return decorator return decorator
@@ -107,14 +114,25 @@ class PluginRegistry:
count = 0 count = 0
for _name, obj in inspect.getmembers(module, inspect.isfunction): for _name, obj in inspect.getmembers(module, inspect.isfunction):
if hasattr(obj, "_derp_command"): if hasattr(obj, "_derp_command"):
cmd_tier = getattr(obj, "_derp_tier", "user")
cmd_admin = getattr(obj, "_derp_admin", False)
self.register_command( self.register_command(
obj._derp_command, obj, obj._derp_command, obj,
help=getattr(obj, "_derp_help", ""), help=getattr(obj, "_derp_help", ""),
plugin=plugin_name, plugin=plugin_name,
admin=getattr(obj, "_derp_admin", False), admin=cmd_admin,
tier=getattr(obj, "_derp_tier", "user"), tier=cmd_tier,
) )
count += 1 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"): if hasattr(obj, "_derp_event"):
self.register_event(obj._derp_event, obj, plugin=plugin_name) self.register_event(obj._derp_event, obj, plugin=plugin_name)
count += 1 count += 1

532
src/derp/teams.py Normal file
View File

@@ -0,0 +1,532 @@
"""Microsoft Teams adapter: outgoing webhook receiver + incoming webhook sender."""
from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import json
import logging
import re
import time
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from derp import http
from derp.bot import _TokenBucket
from derp.plugin import TIERS, PluginRegistry
from derp.state import StateStore
log = logging.getLogger(__name__)
_MAX_BODY = 65536 # 64 KB
_AMBIGUOUS = object() # sentinel for ambiguous prefix matches
@dataclass(slots=True)
class TeamsMessage:
"""Parsed Teams Activity message, duck-typed with IRC Message.
Plugins that use only ``msg.nick``, ``msg.text``, ``msg.target``,
``msg.is_channel``, ``msg.prefix``, ``msg.command``, ``msg.params``,
and ``msg.tags`` work without modification.
"""
raw: dict
nick: str | None
prefix: str | None # AAD object ID (for ACL matching)
text: str | None
target: str | None # conversation/channel ID
is_channel: bool = True # outgoing webhooks are always channels
command: str = "PRIVMSG" # compatibility shim
params: list[str] = field(default_factory=list)
tags: dict[str, str] = field(default_factory=dict)
_replies: list[str] = field(default_factory=list, repr=False)
# -- Helpers -----------------------------------------------------------------
def _verify_hmac(secret: str, body: bytes, auth_header: str) -> bool:
"""Verify Teams outgoing webhook HMAC-SHA256 signature.
The secret is base64-encoded. The Authorization header format is
``HMAC <base64(hmac-sha256(b64decode(secret), body))>``.
"""
if not secret:
return True # no secret configured = open access
if not auth_header.startswith("HMAC "):
return False
try:
key = base64.b64decode(secret)
except Exception:
log.error("teams: invalid base64 webhook secret")
return False
expected = base64.b64encode(
hmac.new(key, body, hashlib.sha256).digest(),
).decode("ascii")
return hmac.compare_digest(expected, auth_header[5:])
def _strip_mention(text: str, bot_name: str) -> str:
"""Strip ``<at>BotName</at>`` prefix from message text."""
return re.sub(r"<at>[^<]*</at>\s*", "", text).strip()
def _parse_activity(body: bytes) -> dict | None:
"""Parse Teams Activity JSON. Returns None on failure."""
try:
data = json.loads(body)
except (json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(data, dict):
return None
return data
def _build_teams_message(activity: dict, bot_name: str) -> TeamsMessage:
"""Build a TeamsMessage from a Teams Activity dict."""
sender = activity.get("from", {})
conv = activity.get("conversation", {})
nick = sender.get("name")
prefix = sender.get("aadObjectId")
raw_text = activity.get("text", "")
text = _strip_mention(raw_text, bot_name)
target = conv.get("id")
return TeamsMessage(
raw=activity,
nick=nick,
prefix=prefix,
text=text,
target=target,
params=[target or "", text] if target else [text],
)
def _http_response(status: int, reason: str, body: str = "",
content_type: str = "text/plain; charset=utf-8") -> bytes:
"""Build a minimal HTTP/1.1 response."""
body_bytes = body.encode("utf-8") if body else b""
lines = [
f"HTTP/1.1 {status} {reason}",
f"Content-Type: {content_type}",
f"Content-Length: {len(body_bytes)}",
"Connection: close",
"",
"",
]
return "\r\n".join(lines).encode("utf-8") + body_bytes
def _json_response(status: int, reason: str, data: dict) -> bytes:
"""Build an HTTP/1.1 JSON response."""
body = json.dumps(data)
return _http_response(status, reason, body, "application/json")
# -- TeamsBot ----------------------------------------------------------------
class TeamsBot:
"""Microsoft Teams bot adapter via outgoing/incoming webhooks.
Exposes the same public API as :class:`derp.bot.Bot` so that
protocol-agnostic plugins work without modification.
"""
def __init__(self, name: str, config: dict, registry: PluginRegistry) -> None:
self.name = name
self.config = config
self.registry = registry
self._pstate: dict = {}
teams_cfg = config.get("teams", {})
self._proxy: bool = teams_cfg.get("proxy", True)
self.nick: str = teams_cfg.get("bot_name", "derp")
self.prefix: str = config.get("bot", {}).get("prefix", "!")
self._running = False
self._started: float = time.monotonic()
self._tasks: set[asyncio.Task] = set()
self._admins: list[str] = teams_cfg.get("admins", [])
self._operators: list[str] = teams_cfg.get("operators", [])
self._trusted: list[str] = teams_cfg.get("trusted", [])
self.state = StateStore(f"data/state-{name}.db")
self._server: asyncio.Server | None = None
self._webhook_secret: str = teams_cfg.get("webhook_secret", "")
self._incoming_url: str = teams_cfg.get("incoming_webhook_url", "")
self._bind: str = teams_cfg.get("bind", "127.0.0.1")
self._port: int = teams_cfg.get("port", 8081)
rate_cfg = config.get("bot", {})
self._bucket = _TokenBucket(
rate=rate_cfg.get("rate_limit", 2.0),
burst=rate_cfg.get("rate_burst", 5),
)
# -- Lifecycle -----------------------------------------------------------
async def start(self) -> None:
"""Start the HTTP server for receiving outgoing webhooks."""
self._running = True
try:
self._server = await asyncio.start_server(
self._handle_connection, self._bind, self._port,
)
except OSError as exc:
log.error("teams: failed to bind %s:%d: %s",
self._bind, self._port, exc)
return
log.info("teams: listening on %s:%d", self._bind, self._port)
try:
while self._running:
await asyncio.sleep(1)
finally:
self._server.close()
await self._server.wait_closed()
log.info("teams: stopped")
# -- HTTP server ---------------------------------------------------------
async def _handle_connection(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter,
) -> None:
"""Handle a single HTTP connection from Teams."""
try:
# Read request line
request_line = await asyncio.wait_for(
reader.readline(), timeout=10.0)
if not request_line:
return
parts = request_line.decode("utf-8", errors="replace").strip().split()
if len(parts) < 2:
writer.write(_http_response(400, "Bad Request",
"malformed request"))
return
method, path = parts[0], parts[1]
# Read headers
headers: dict[str, str] = {}
while True:
line = await asyncio.wait_for(reader.readline(), timeout=10.0)
if not line or line == b"\r\n" or line == b"\n":
break
decoded = line.decode("utf-8", errors="replace").strip()
if ":" in decoded:
key, val = decoded.split(":", 1)
headers[key.strip().lower()] = val.strip()
# Method check
if method != "POST":
writer.write(_http_response(405, "Method Not Allowed",
"POST only"))
return
# Path check
if path != "/api/messages":
writer.write(_http_response(404, "Not Found"))
return
# Read body
content_length = int(headers.get("content-length", "0"))
if content_length > _MAX_BODY:
writer.write(_http_response(413, "Payload Too Large",
f"max {_MAX_BODY} bytes"))
return
body = await asyncio.wait_for(
reader.readexactly(content_length), timeout=10.0)
# Verify HMAC signature
auth = headers.get("authorization", "")
if not _verify_hmac(self._webhook_secret, body, auth):
writer.write(_http_response(401, "Unauthorized",
"bad signature"))
return
# Parse Activity JSON
activity = _parse_activity(body)
if activity is None:
writer.write(_http_response(400, "Bad Request",
"invalid JSON"))
return
# Only handle message activities
if activity.get("type") != "message":
writer.write(_json_response(200, "OK",
{"type": "message", "text": ""}))
return
# Build message and dispatch
msg = _build_teams_message(activity, self.nick)
await self._dispatch_command(msg)
# Collect replies
reply_text = "\n".join(msg._replies) if msg._replies else ""
writer.write(_json_response(200, "OK", {
"type": "message",
"text": reply_text,
}))
except (asyncio.TimeoutError, asyncio.IncompleteReadError,
ConnectionError):
log.debug("teams: client disconnected")
except Exception:
log.exception("teams: error handling request")
try:
writer.write(_http_response(500, "Internal Server Error"))
except Exception:
pass
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
# -- Command dispatch ----------------------------------------------------
async def _dispatch_command(self, msg: TeamsMessage) -> None:
"""Parse and dispatch a command from a Teams message."""
text = msg.text
if not text or not text.startswith(self.prefix):
return
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:
return
if handler is _AMBIGUOUS:
matches = [k for k in self.registry.commands
if k.startswith(cmd_name)]
names = ", ".join(self.prefix + m for m in sorted(matches))
msg._replies.append(
f"Ambiguous command '{self.prefix}{cmd_name}': {names}")
return
if not self._plugin_allowed(handler.plugin, msg.target):
return
required = handler.tier
if required != "user":
sender = self._get_tier(msg)
if TIERS.index(sender) < TIERS.index(required):
msg._replies.append(
f"Permission denied: {self.prefix}{cmd_name} "
f"requires {required}")
return
try:
await handler.callback(self, msg)
except Exception:
log.exception("teams: error in command handler '%s'", cmd_name)
def _resolve_command(self, name: str):
"""Resolve command name with unambiguous prefix matching.
Returns the Handler on exact or unique prefix match, the sentinel
``_AMBIGUOUS`` if multiple commands match, or None if nothing matches.
"""
handler = self.registry.commands.get(name)
if handler is not None:
return handler
matches = [v for k, v in self.registry.commands.items()
if k.startswith(name)]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
return _AMBIGUOUS
return None
def _plugin_allowed(self, plugin_name: str, channel: str | None) -> bool:
"""Channel filtering is IRC-only; all plugins are allowed on Teams."""
return True
# -- Permission tiers ----------------------------------------------------
def _get_tier(self, msg: TeamsMessage) -> str:
"""Determine permission tier from AAD object ID.
Unlike IRC (fnmatch hostmask patterns), Teams matches exact
AAD object IDs from the ``teams.admins``, ``teams.operators``,
and ``teams.trusted`` config lists.
"""
if not msg.prefix:
return "user"
for aad_id in self._admins:
if msg.prefix == aad_id:
return "admin"
for aad_id in self._operators:
if msg.prefix == aad_id:
return "oper"
for aad_id in self._trusted:
if msg.prefix == aad_id:
return "trusted"
return "user"
def _is_admin(self, msg: TeamsMessage) -> bool:
"""Check if the message sender is a bot admin."""
return self._get_tier(msg) == "admin"
# -- Public API for plugins ----------------------------------------------
async def send(self, target: str, text: str) -> None:
"""Send a message via incoming webhook (proactive messages).
Requires ``teams.incoming_webhook_url`` to be configured.
Does nothing if no URL is set.
"""
if not self._incoming_url:
log.debug("teams: send() skipped, no incoming_webhook_url")
return
await self._bucket.acquire()
payload = json.dumps({"text": text}).encode("utf-8")
req = urllib.request.Request(
self._incoming_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(
None, lambda: http.urlopen(req, proxy=self._proxy),
)
except Exception:
log.exception("teams: failed to send via incoming webhook")
async def reply(self, msg, text: str) -> None:
"""Reply by appending to the message reply buffer.
Collected replies are returned as the HTTP response body.
"""
msg._replies.append(text)
async def long_reply(
self, msg, lines: list[str], *,
label: str = "",
) -> None:
"""Reply with a list of lines; paste overflow to FlaskPaste.
Same overflow logic as :meth:`derp.bot.Bot.long_reply` but
appends to the reply buffer instead of sending via IRC.
"""
threshold = self.config.get("bot", {}).get("paste_threshold", 4)
if not lines:
return
if len(lines) <= threshold:
for line in lines:
msg._replies.append(line)
return
# Attempt paste overflow
fp = self.registry._modules.get("flaskpaste")
paste_url = None
if fp:
full_text = "\n".join(lines)
loop = asyncio.get_running_loop()
paste_url = await loop.run_in_executor(
None, fp.create_paste, self, full_text,
)
if paste_url:
preview_count = min(2, threshold - 1)
for line in lines[:preview_count]:
msg._replies.append(line)
remaining = len(lines) - preview_count
suffix = f" ({label})" if label else ""
msg._replies.append(
f"... {remaining} more lines{suffix}: {paste_url}")
else:
for line in lines:
msg._replies.append(line)
async def action(self, target: str, text: str) -> None:
"""Send an action as italic text via incoming webhook."""
await self.send(target, f"_{text}_")
async def shorten_url(self, url: str) -> str:
"""Shorten a URL via FlaskPaste. Returns original on failure."""
fp = self.registry._modules.get("flaskpaste")
if not fp:
return url
loop = asyncio.get_running_loop()
try:
return await loop.run_in_executor(None, fp.shorten_url, self, url)
except Exception:
return url
# -- IRC no-ops ----------------------------------------------------------
async def join(self, channel: str) -> None:
"""No-op: IRC-only concept."""
log.debug("teams: join() is a no-op")
async def part(self, channel: str, reason: str = "") -> None:
"""No-op: IRC-only concept."""
log.debug("teams: part() is a no-op")
async def quit(self, reason: str = "bye") -> None:
"""Stop the Teams adapter."""
self._running = False
async def kick(self, channel: str, nick: str, reason: str = "") -> None:
"""No-op: IRC-only concept."""
log.debug("teams: kick() is a no-op")
async def mode(self, target: str, mode_str: str, *args: str) -> None:
"""No-op: IRC-only concept."""
log.debug("teams: mode() is a no-op")
async def set_topic(self, channel: str, topic: str) -> None:
"""No-op: IRC-only concept."""
log.debug("teams: set_topic() is a no-op")
# -- Plugin management (delegated to registry) ---------------------------
def load_plugins(self, plugins_dir: str | Path | None = None) -> None:
"""Load plugins from the configured directory."""
if plugins_dir is None:
plugins_dir = self.config.get("bot", {}).get(
"plugins_dir", "plugins")
path = Path(plugins_dir)
self.registry.load_directory(path)
@property
def plugins_dir(self) -> Path:
"""Resolved path to the plugins directory."""
return Path(self.config.get("bot", {}).get("plugins_dir", "plugins"))
def load_plugin(self, name: str) -> tuple[bool, str]:
"""Hot-load a new plugin by name from the plugins directory."""
if name in self.registry._modules:
return False, f"plugin already loaded: {name}"
path = self.plugins_dir / f"{name}.py"
if not path.is_file():
return False, f"{name}.py not found"
count = self.registry.load_plugin(path)
if count < 0:
return False, f"failed to load {name}"
return True, f"{count} handlers"
def reload_plugin(self, name: str) -> tuple[bool, str]:
"""Reload a plugin, picking up any file changes."""
return self.registry.reload_plugin(name)
def unload_plugin(self, name: str) -> tuple[bool, str]:
"""Unload a plugin, removing all its handlers."""
if self.registry.unload_plugin(name):
return True, ""
if name == "core":
return False, "cannot unload core"
return False, f"plugin not loaded: {name}"
def _spawn(self, coro, *, name: str | None = None) -> asyncio.Task:
"""Spawn a background task and track it for cleanup."""
task = asyncio.create_task(coro, name=name)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return task

490
src/derp/telegram.py Normal file
View File

@@ -0,0 +1,490 @@
"""Telegram adapter: long-polling via getUpdates, all HTTP through SOCKS5."""
from __future__ import annotations
import asyncio
import json
import logging
import time
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from derp import http
from derp.bot import _TokenBucket
from derp.plugin import TIERS, PluginRegistry
from derp.state import StateStore
log = logging.getLogger(__name__)
_API_BASE = "https://api.telegram.org/bot"
_MAX_MSG_LEN = 4096
_AMBIGUOUS = object() # sentinel for ambiguous prefix matches
@dataclass(slots=True)
class TelegramMessage:
"""Parsed Telegram update, duck-typed with IRC Message.
Plugins that use only ``msg.nick``, ``msg.text``, ``msg.target``,
``msg.is_channel``, ``msg.prefix``, ``msg.command``, ``msg.params``,
and ``msg.tags`` work without modification.
"""
raw: dict # original Telegram Update
nick: str | None # first_name (or username fallback)
prefix: str | None # user_id as string (for ACL)
text: str | None # message text
target: str | None # chat_id as string
is_channel: bool = True # True for groups, False for DMs
command: str = "PRIVMSG" # compat shim
params: list[str] = field(default_factory=list)
tags: dict[str, str] = field(default_factory=dict)
# -- Helpers -----------------------------------------------------------------
def _strip_bot_suffix(text: str, bot_username: str) -> str:
"""Strip ``@botusername`` suffix from command text.
``!help@mybot`` -> ``!help``
"""
if not bot_username:
return text
suffix = f"@{bot_username}"
if " " in text:
first, rest = text.split(" ", 1)
if first.lower().endswith(suffix.lower()):
return first[: -len(suffix)] + " " + rest
return text
if text.lower().endswith(suffix.lower()):
return text[: -len(suffix)]
return text
def _build_telegram_message(
update: dict, bot_username: str,
) -> TelegramMessage | None:
"""Build a TelegramMessage from a Telegram Update dict.
Returns None if the update has no usable message.
"""
msg = update.get("message") or update.get("edited_message")
if not msg or not isinstance(msg, dict):
return None
sender = msg.get("from", {})
chat = msg.get("chat", {})
nick = sender.get("first_name") or sender.get("username")
user_id = sender.get("id")
prefix = str(user_id) if user_id is not None else None
raw_text = msg.get("text", "")
text = _strip_bot_suffix(raw_text, bot_username) if raw_text else raw_text
chat_id = chat.get("id")
target = str(chat_id) if chat_id is not None else None
chat_type = chat.get("type", "private")
is_channel = chat_type in ("group", "supergroup", "channel")
return TelegramMessage(
raw=update,
nick=nick,
prefix=prefix,
text=text,
target=target,
is_channel=is_channel,
params=[target or "", text] if target else [text],
)
def _split_message(text: str, max_len: int = _MAX_MSG_LEN) -> list[str]:
"""Split text at line boundaries to fit within max_len."""
if len(text.encode("utf-8")) <= max_len:
return [text]
chunks: list[str] = []
current: list[str] = []
current_len = 0
for line in text.split("\n"):
line_len = len(line.encode("utf-8")) + 1 # +1 for newline
if current and current_len + line_len > max_len:
chunks.append("\n".join(current))
current = []
current_len = 0
current.append(line)
current_len += line_len
if current:
chunks.append("\n".join(current))
return chunks
# -- TelegramBot -------------------------------------------------------------
class TelegramBot:
"""Telegram bot adapter via long-polling (getUpdates).
Exposes the same public API as :class:`derp.bot.Bot` so that
protocol-agnostic plugins work without modification.
HTTP is routed through ``derp.http.urlopen`` (SOCKS5 optional
via ``telegram.proxy`` config).
"""
def __init__(self, name: str, config: dict, registry: PluginRegistry) -> None:
self.name = name
self.config = config
self.registry = registry
self._pstate: dict = {}
tg_cfg = config.get("telegram", {})
self._token: str = tg_cfg.get("bot_token", "")
self._poll_timeout: int = tg_cfg.get("poll_timeout", 30)
self._proxy: bool = tg_cfg.get("proxy", True)
self.nick: str = "" # set by getMe
self._bot_username: str = "" # set by getMe
self.prefix: str = (
tg_cfg.get("prefix")
or config.get("bot", {}).get("prefix", "!")
)
self._running = False
self._started: float = time.monotonic()
self._tasks: set[asyncio.Task] = set()
self._admins: list[str] = [str(x) for x in tg_cfg.get("admins", [])]
self._operators: list[str] = [str(x) for x in tg_cfg.get("operators", [])]
self._trusted: list[str] = [str(x) for x in tg_cfg.get("trusted", [])]
self.state = StateStore(f"data/state-{name}.db")
self._offset: int = 0
rate_cfg = config.get("bot", {})
self._bucket = _TokenBucket(
rate=rate_cfg.get("rate_limit", 2.0),
burst=rate_cfg.get("rate_burst", 5),
)
# -- Telegram API --------------------------------------------------------
def _api_url(self, method: str) -> str:
"""Build Telegram Bot API URL."""
return f"{_API_BASE}{self._token}/{method}"
def _api_call(self, method: str, payload: dict | None = None) -> dict:
"""Synchronous Telegram API call through SOCKS5 proxy.
Meant to run in a thread executor.
"""
url = self._api_url(method)
if payload:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url, data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
else:
req = urllib.request.Request(url, method="GET")
timeout = self._poll_timeout + 5 if method == "getUpdates" else 30
resp = http.urlopen(req, timeout=timeout, proxy=self._proxy)
body = resp.read() if hasattr(resp, "read") else resp.data
return json.loads(body)
# -- Lifecycle -----------------------------------------------------------
async def start(self) -> None:
"""Verify token, then enter long-poll loop."""
self._running = True
loop = asyncio.get_running_loop()
# Verify token via getMe
try:
result = await loop.run_in_executor(None, self._api_call, "getMe")
if not result.get("ok"):
log.error("telegram: getMe failed: %s", result)
return
me = result.get("result", {})
self.nick = me.get("first_name", "bot")
self._bot_username = me.get("username", "")
log.info("telegram: authenticated as @%s", self._bot_username)
except Exception:
log.exception("telegram: failed to authenticate")
return
# Long-poll loop
while self._running:
try:
updates = await loop.run_in_executor(
None, self._poll_updates,
)
for update in updates:
msg = _build_telegram_message(update, self._bot_username)
if msg is not None:
await self._dispatch_command(msg)
except Exception:
if self._running:
log.exception("telegram: poll error, backing off 5s")
await asyncio.sleep(5)
log.info("telegram: stopped")
def _poll_updates(self) -> list[dict]:
"""Fetch updates from Telegram (blocking, run in executor)."""
payload = {
"offset": self._offset,
"timeout": self._poll_timeout,
}
result = self._api_call("getUpdates", payload)
if not result.get("ok"):
log.warning("telegram: getUpdates failed: %s", result)
return []
updates = result.get("result", [])
if updates:
self._offset = updates[-1]["update_id"] + 1
return updates
# -- Command dispatch ----------------------------------------------------
async def _dispatch_command(self, msg: TelegramMessage) -> None:
"""Parse and dispatch a command from a Telegram message."""
text = msg.text
if not text or not text.startswith(self.prefix):
return
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:
return
if handler is _AMBIGUOUS:
matches = [k for k in self.registry.commands
if k.startswith(cmd_name)]
names = ", ".join(self.prefix + m for m in sorted(matches))
await self.reply(
msg,
f"Ambiguous command '{self.prefix}{cmd_name}': {names}",
)
return
if not self._plugin_allowed(handler.plugin, msg.target):
return
required = handler.tier
if required != "user":
sender = self._get_tier(msg)
if TIERS.index(sender) < TIERS.index(required):
await self.reply(
msg,
f"Permission denied: {self.prefix}{cmd_name} "
f"requires {required}",
)
return
try:
await handler.callback(self, msg)
except Exception:
log.exception("telegram: error in command handler '%s'", cmd_name)
def _resolve_command(self, name: str):
"""Resolve command name with unambiguous prefix matching.
Returns the Handler on exact or unique prefix match, the sentinel
``_AMBIGUOUS`` if multiple commands match, or None if nothing matches.
"""
handler = self.registry.commands.get(name)
if handler is not None:
return handler
matches = [v for k, v in self.registry.commands.items()
if k.startswith(name)]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
return _AMBIGUOUS
return None
def _plugin_allowed(self, plugin_name: str, channel: str | None) -> bool:
"""Channel filtering is IRC-only; all plugins are allowed on Telegram."""
return True
# -- Permission tiers ----------------------------------------------------
def _get_tier(self, msg: TelegramMessage) -> str:
"""Determine permission tier from user_id.
Matches exact string comparison of user_id against config lists.
"""
if not msg.prefix:
return "user"
for uid in self._admins:
if msg.prefix == uid:
return "admin"
for uid in self._operators:
if msg.prefix == uid:
return "oper"
for uid in self._trusted:
if msg.prefix == uid:
return "trusted"
return "user"
def _is_admin(self, msg: TelegramMessage) -> bool:
"""Check if the message sender is a bot admin."""
return self._get_tier(msg) == "admin"
# -- Public API for plugins ----------------------------------------------
async def send(self, target: str, text: str) -> None:
"""Send a message via sendMessage API (proxied, rate-limited).
Long messages are split at line boundaries to fit Telegram's
4096-character limit.
"""
await self._bucket.acquire()
loop = asyncio.get_running_loop()
for chunk in _split_message(text):
payload = {
"chat_id": target,
"text": chunk,
}
try:
await loop.run_in_executor(
None, self._api_call, "sendMessage", payload,
)
except Exception:
log.exception("telegram: failed to send message")
async def reply(self, msg, text: str) -> None:
"""Reply to the source chat."""
if msg.target:
await self.send(msg.target, text)
async def long_reply(
self, msg, lines: list[str], *,
label: str = "",
) -> None:
"""Reply with a list of lines; paste overflow to FlaskPaste.
Same overflow logic as :meth:`derp.bot.Bot.long_reply`.
"""
threshold = self.config.get("bot", {}).get("paste_threshold", 4)
if not lines or not msg.target:
return
if len(lines) <= threshold:
for line in lines:
await self.send(msg.target, line)
return
# Attempt paste overflow
fp = self.registry._modules.get("flaskpaste")
paste_url = None
if fp:
full_text = "\n".join(lines)
loop = asyncio.get_running_loop()
paste_url = await loop.run_in_executor(
None, fp.create_paste, self, full_text,
)
if paste_url:
preview_count = min(2, threshold - 1)
for line in lines[:preview_count]:
await self.send(msg.target, line)
remaining = len(lines) - preview_count
suffix = f" ({label})" if label else ""
await self.send(
msg.target,
f"... {remaining} more lines{suffix}: {paste_url}",
)
else:
for line in lines:
await self.send(msg.target, line)
async def action(self, target: str, text: str) -> None:
"""Send an action as italic Markdown text."""
await self.send(target, f"_{text}_")
async def shorten_url(self, url: str) -> str:
"""Shorten a URL via FlaskPaste. Returns original on failure."""
fp = self.registry._modules.get("flaskpaste")
if not fp:
return url
loop = asyncio.get_running_loop()
try:
return await loop.run_in_executor(None, fp.shorten_url, self, url)
except Exception:
return url
# -- IRC no-ops ----------------------------------------------------------
async def join(self, channel: str) -> None:
"""No-op: IRC-only concept."""
log.debug("telegram: join() is a no-op")
async def part(self, channel: str, reason: str = "") -> None:
"""No-op: IRC-only concept."""
log.debug("telegram: part() is a no-op")
async def quit(self, reason: str = "bye") -> None:
"""Stop the Telegram adapter."""
self._running = False
async def kick(self, channel: str, nick: str, reason: str = "") -> None:
"""No-op: IRC-only concept."""
log.debug("telegram: kick() is a no-op")
async def mode(self, target: str, mode_str: str, *args: str) -> None:
"""No-op: IRC-only concept."""
log.debug("telegram: mode() is a no-op")
async def set_topic(self, channel: str, topic: str) -> None:
"""No-op: IRC-only concept."""
log.debug("telegram: set_topic() is a no-op")
# -- Plugin management (delegated to registry) ---------------------------
def load_plugins(self, plugins_dir: str | Path | None = None) -> None:
"""Load plugins from the configured directory."""
if plugins_dir is None:
plugins_dir = self.config.get("bot", {}).get(
"plugins_dir", "plugins")
path = Path(plugins_dir)
self.registry.load_directory(path)
@property
def plugins_dir(self) -> Path:
"""Resolved path to the plugins directory."""
return Path(self.config.get("bot", {}).get("plugins_dir", "plugins"))
def load_plugin(self, name: str) -> tuple[bool, str]:
"""Hot-load a new plugin by name from the plugins directory."""
if name in self.registry._modules:
return False, f"plugin already loaded: {name}"
path = self.plugins_dir / f"{name}.py"
if not path.is_file():
return False, f"{name}.py not found"
count = self.registry.load_plugin(path)
if count < 0:
return False, f"failed to load {name}"
return True, f"{count} handlers"
def reload_plugin(self, name: str) -> tuple[bool, str]:
"""Reload a plugin, picking up any file changes."""
return self.registry.reload_plugin(name)
def unload_plugin(self, name: str) -> tuple[bool, str]:
"""Unload a plugin, removing all its handlers."""
if self.registry.unload_plugin(name):
return True, ""
if name == "core":
return False, "cannot unload core"
return False, f"plugin not loaded: {name}"
def _spawn(self, coro, *, name: str | None = None) -> asyncio.Task:
"""Spawn a background task and track it for cleanup."""
task = asyncio.create_task(coro, name=name)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return task

View File

@@ -63,7 +63,7 @@ class _Harness:
}, },
} }
self.registry = PluginRegistry() self.registry = PluginRegistry()
self.bot = Bot(config, self.registry) self.bot = Bot("test", config, self.registry)
self.conn = _MockConnection() self.conn = _MockConnection()
self.bot.conn = self.conn # type: ignore[assignment] self.bot.conn = self.conn # type: ignore[assignment]
self.registry.load_plugin(Path("plugins/core.py")) self.registry.load_plugin(Path("plugins/core.py"))
@@ -107,7 +107,7 @@ def _msg(text: str, prefix: str = "nick!user@host") -> Message:
class TestTierConstants: class TestTierConstants:
def test_tier_order(self): def test_tier_order(self):
assert TIERS == ("user", "trusted", "oper", "admin") assert TIERS == ("user", "trusted", "oper", "sorcerer", "admin")
def test_index_comparison(self): def test_index_comparison(self):
assert TIERS.index("user") < TIERS.index("trusted") assert TIERS.index("user") < TIERS.index("trusted")

View File

@@ -21,11 +21,10 @@ from plugins.alert import ( # noqa: E402
_MAX_SEEN, _MAX_SEEN,
_compact_num, _compact_num,
_delete, _delete,
_errors,
_extract_videos, _extract_videos,
_load, _load,
_poll_once, _poll_once,
_pollers, _ps,
_restore, _restore,
_save, _save,
_save_result, _save_result,
@@ -35,7 +34,6 @@ from plugins.alert import ( # noqa: E402
_start_poller, _start_poller,
_state_key, _state_key,
_stop_poller, _stop_poller,
_subscriptions,
_truncate, _truncate,
_validate_name, _validate_name,
cmd_alert, cmd_alert,
@@ -169,6 +167,7 @@ class _FakeBot:
self.actions: list[tuple[str, str]] = [] self.actions: list[tuple[str, str]] = []
self.replied: list[str] = [] self.replied: list[str] = []
self.state = _FakeState() self.state = _FakeState()
self._pstate: dict = {}
self.registry = _FakeRegistry() self.registry = _FakeRegistry()
self._admin = admin self._admin = admin
@@ -205,14 +204,18 @@ def _pm(text: str, nick: str = "alice") -> Message:
) )
def _clear() -> None: def _clear(bot=None) -> None:
"""Reset module-level state between tests.""" """Reset per-bot plugin state between tests."""
for task in _pollers.values(): if bot is None:
return
ps = _ps(bot)
for task in ps["pollers"].values():
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
_pollers.clear() ps["pollers"].clear()
_subscriptions.clear() ps["subs"].clear()
_errors.clear() ps["errors"].clear()
ps["poll_count"].clear()
def _fake_yt(keyword): def _fake_yt(keyword):
@@ -417,7 +420,7 @@ class TestExtractVideos:
def close(self): def close(self):
pass pass
with patch("urllib.request.urlopen", return_value=FakeResp()): with patch.object(_mod, "_urlopen", return_value=FakeResp()):
results = _search_youtube("test") results = _search_youtube("test")
assert len(results) == 1 assert len(results) == 1
assert results[0]["id"] == "dup1" assert results[0]["id"] == "dup1"
@@ -435,7 +438,7 @@ class TestSearchYoutube:
def close(self): def close(self):
pass pass
with patch("urllib.request.urlopen", return_value=FakeResp()): with patch.object(_mod, "_urlopen", return_value=FakeResp()):
results = _search_youtube("test query") results = _search_youtube("test query")
assert len(results) == 2 assert len(results) == 2
assert results[0]["id"] == "abc123" assert results[0]["id"] == "abc123"
@@ -443,7 +446,7 @@ class TestSearchYoutube:
def test_http_error_propagates(self): def test_http_error_propagates(self):
import pytest import pytest
with patch("urllib.request.urlopen", side_effect=ConnectionError("fail")): with patch.object(_mod, "_urlopen", side_effect=ConnectionError("fail")):
with pytest.raises(ConnectionError): with pytest.raises(ConnectionError):
_search_youtube("test") _search_youtube("test")
@@ -595,8 +598,8 @@ class TestCmdAlertAdd:
assert len(data["seen"]["yt"]) == 2 assert len(data["seen"]["yt"]) == 2
assert len(data["seen"]["tw"]) == 2 assert len(data["seen"]["tw"]) == 2
assert len(data["seen"]["sx"]) == 2 assert len(data["seen"]["sx"]) == 2
assert "#test:mc-speed" in _pollers assert "#test:mc-speed" in _ps(bot)["pollers"]
_stop_poller("#test:mc-speed") _stop_poller(bot, "#test:mc-speed")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -644,7 +647,7 @@ class TestCmdAlertAdd:
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
await cmd_alert(bot, _msg("!alert add dupe other keyword")) await cmd_alert(bot, _msg("!alert add dupe other keyword"))
assert "already exists" in bot.replied[0] assert "already exists" in bot.replied[0]
_stop_poller("#test:dupe") _stop_poller(bot, "#test:dupe")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -681,7 +684,7 @@ class TestCmdAlertAdd:
assert len(data["seen"]["yt"]) == 2 assert len(data["seen"]["yt"]) == 2
assert len(data["seen"].get("tw", [])) == 0 assert len(data["seen"].get("tw", [])) == 0
assert len(data["seen"]["sx"]) == 2 assert len(data["seen"]["sx"]) == 2
_stop_poller("#test:partial") _stop_poller(bot, "#test:partial")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -704,7 +707,7 @@ class TestCmdAlertDel:
await cmd_alert(bot, _msg("!alert del todel")) await cmd_alert(bot, _msg("!alert del todel"))
assert "Removed 'todel'" in bot.replied[0] assert "Removed 'todel'" in bot.replied[0]
assert _load(bot, "#test:todel") is None assert _load(bot, "#test:todel") is None
assert "#test:todel" not in _pollers assert "#test:todel" not in _ps(bot)["pollers"]
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -896,7 +899,7 @@ class TestPollOnce:
} }
key = "#test:poll" key = "#test:poll"
_save(bot, key, data) _save(bot, key, data)
_subscriptions[key] = data _ps(bot)["subs"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
@@ -933,7 +936,7 @@ class TestPollOnce:
} }
key = "#test:dedup" key = "#test:dedup"
_save(bot, key, data) _save(bot, key, data)
_subscriptions[key] = data _ps(bot)["subs"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
@@ -953,7 +956,7 @@ class TestPollOnce:
} }
key = "#test:partial" key = "#test:partial"
_save(bot, key, data) _save(bot, key, data)
_subscriptions[key] = data _ps(bot)["subs"][key] = data
backends = {"yt": _fake_yt_error, "tw": _fake_tw, "sx": _fake_sx} backends = {"yt": _fake_yt_error, "tw": _fake_tw, "sx": _fake_sx}
async def inner(): async def inner():
@@ -965,7 +968,7 @@ class TestPollOnce:
assert len(tw_msgs) == 2 assert len(tw_msgs) == 2
assert len(sx_msgs) == 2 assert len(sx_msgs) == 2
# Error counter should be incremented for yt backend # Error counter should be incremented for yt backend
assert _errors[key]["yt"] == 1 assert _ps(bot)["errors"][key]["yt"] == 1
updated = _load(bot, key) updated = _load(bot, key)
assert "yt" in updated.get("last_errors", {}) assert "yt" in updated.get("last_errors", {})
@@ -981,7 +984,7 @@ class TestPollOnce:
} }
key = "#test:quiet" key = "#test:quiet"
_save(bot, key, data) _save(bot, key, data)
_subscriptions[key] = data _ps(bot)["subs"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
@@ -1012,7 +1015,7 @@ class TestPollOnce:
} }
key = "#test:cap" key = "#test:cap"
_save(bot, key, data) _save(bot, key, data)
_subscriptions[key] = data _ps(bot)["subs"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", {"yt": fake_many, "tw": _fake_tw}): with patch.object(_mod, "_BACKENDS", {"yt": fake_many, "tw": _fake_tw}):
@@ -1034,13 +1037,13 @@ class TestPollOnce:
} }
key = "#test:allerr" key = "#test:allerr"
_save(bot, key, data) _save(bot, key, data)
_subscriptions[key] = data _ps(bot)["subs"][key] = data
backends = {"yt": _fake_yt_error, "tw": _fake_tw_error, "sx": _fake_sx_error} backends = {"yt": _fake_yt_error, "tw": _fake_tw_error, "sx": _fake_sx_error}
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", backends): with patch.object(_mod, "_BACKENDS", backends):
await _poll_once(bot, key, announce=True) await _poll_once(bot, key, announce=True)
assert all(v == 1 for v in _errors[key].values()) assert all(v == 1 for v in _ps(bot)["errors"][key].values())
assert len(bot.sent) == 0 assert len(bot.sent) == 0
asyncio.run(inner()) asyncio.run(inner())
@@ -1058,13 +1061,13 @@ class TestPollOnce:
} }
key = "#test:clrerr" key = "#test:clrerr"
_save(bot, key, data) _save(bot, key, data)
_subscriptions[key] = data _ps(bot)["subs"][key] = data
_errors[key] = {"yt": 3, "tw": 3, "sx": 3} _ps(bot)["errors"][key] = {"yt": 3, "tw": 3, "sx": 3}
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
await _poll_once(bot, key, announce=True) await _poll_once(bot, key, announce=True)
assert all(v == 0 for v in _errors[key].values()) assert all(v == 0 for v in _ps(bot)["errors"][key].values())
updated = _load(bot, key) updated = _load(bot, key)
assert updated.get("last_errors", {}) == {} assert updated.get("last_errors", {}) == {}
@@ -1088,10 +1091,11 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:restored" in _pollers ps = _ps(bot)
task = _pollers["#test:restored"] assert "#test:restored" in ps["pollers"]
task = ps["pollers"]["#test:restored"]
assert not task.done() assert not task.done()
_stop_poller("#test:restored") _stop_poller(bot, "#test:restored")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -1107,10 +1111,11 @@ class TestRestore:
_save(bot, "#test:active", data) _save(bot, "#test:active", data)
async def inner(): async def inner():
ps = _ps(bot)
dummy = asyncio.create_task(asyncio.sleep(9999)) dummy = asyncio.create_task(asyncio.sleep(9999))
_pollers["#test:active"] = dummy ps["pollers"]["#test:active"] = dummy
_restore(bot) _restore(bot)
assert _pollers["#test:active"] is dummy assert ps["pollers"]["#test:active"] is dummy
dummy.cancel() dummy.cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -1127,14 +1132,15 @@ class TestRestore:
_save(bot, "#test:done", data) _save(bot, "#test:done", data)
async def inner(): async def inner():
ps = _ps(bot)
done_task = asyncio.create_task(asyncio.sleep(0)) done_task = asyncio.create_task(asyncio.sleep(0))
await done_task await done_task
_pollers["#test:done"] = done_task ps["pollers"]["#test:done"] = done_task
_restore(bot) _restore(bot)
new_task = _pollers["#test:done"] new_task = ps["pollers"]["#test:done"]
assert new_task is not done_task assert new_task is not done_task
assert not new_task.done() assert not new_task.done()
_stop_poller("#test:done") _stop_poller(bot, "#test:done")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -1146,7 +1152,7 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:bad" not in _pollers assert "#test:bad" not in _ps(bot)["pollers"]
asyncio.run(inner()) asyncio.run(inner())
@@ -1163,8 +1169,8 @@ class TestRestore:
async def inner(): async def inner():
msg = _msg("", target="botname") msg = _msg("", target="botname")
await on_connect(bot, msg) await on_connect(bot, msg)
assert "#test:conn" in _pollers assert "#test:conn" in _ps(bot)["pollers"]
_stop_poller("#test:conn") _stop_poller(bot, "#test:conn")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -1185,16 +1191,17 @@ class TestPollerManagement:
} }
key = "#test:mgmt" key = "#test:mgmt"
_save(bot, key, data) _save(bot, key, data)
_subscriptions[key] = data _ps(bot)["subs"][key] = data
async def inner(): async def inner():
ps = _ps(bot)
_start_poller(bot, key) _start_poller(bot, key)
assert key in _pollers assert key in ps["pollers"]
assert not _pollers[key].done() assert not ps["pollers"][key].done()
_stop_poller(key) _stop_poller(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
assert key not in _pollers assert key not in ps["pollers"]
assert key not in _subscriptions assert key not in ps["subs"]
asyncio.run(inner()) asyncio.run(inner())
@@ -1208,21 +1215,22 @@ class TestPollerManagement:
} }
key = "#test:idem" key = "#test:idem"
_save(bot, key, data) _save(bot, key, data)
_subscriptions[key] = data _ps(bot)["subs"][key] = data
async def inner(): async def inner():
ps = _ps(bot)
_start_poller(bot, key) _start_poller(bot, key)
first = _pollers[key] first = ps["pollers"][key]
_start_poller(bot, key) _start_poller(bot, key)
assert _pollers[key] is first assert ps["pollers"][key] is first
_stop_poller(key) _stop_poller(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_stop_nonexistent(self): def test_stop_nonexistent(self):
_clear() bot = _FakeBot()
_stop_poller("#test:nonexistent") _stop_poller(bot, "#test:nonexistent")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1255,7 +1263,7 @@ class TestSearchSearx:
def close(self): def close(self):
pass pass
with patch("urllib.request.urlopen", return_value=FakeResp()): with patch.object(_mod, "_urlopen", return_value=FakeResp()):
results = _search_searx("test query") results = _search_searx("test query")
# Same response served for all categories; deduped by URL # Same response served for all categories; deduped by URL
assert len(results) == 3 assert len(results) == 3
@@ -1273,13 +1281,13 @@ class TestSearchSearx:
def close(self): def close(self):
pass pass
with patch("urllib.request.urlopen", return_value=FakeResp()): with patch.object(_mod, "_urlopen", return_value=FakeResp()):
results = _search_searx("nothing") results = _search_searx("nothing")
assert results == [] assert results == []
def test_http_error_returns_empty(self): def test_http_error_returns_empty(self):
"""SearXNG catches per-category errors; all failing returns empty.""" """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") results = _search_searx("test")
assert results == [] assert results == []
@@ -1299,7 +1307,7 @@ class TestExtraInHistory:
} }
_save(bot, "#test:hist", data) _save(bot, "#test:hist", data)
# Insert a result with extra metadata # Insert a result with extra metadata
_save_result("#test", "hist", "hn", { _save_result(bot, "#test", "hist", "hn", {
"id": "h1", "title": "Cool HN Post", "url": "https://hn.example.com/1", "id": "h1", "title": "Cool HN Post", "url": "https://hn.example.com/1",
"date": "2026-01-15", "extra": "42pt 10c", "date": "2026-01-15", "extra": "42pt 10c",
}) })
@@ -1321,7 +1329,7 @@ class TestExtraInHistory:
"interval": 300, "seen": {}, "last_poll": "", "last_error": "", "interval": 300, "seen": {}, "last_poll": "", "last_error": "",
} }
_save(bot, "#test:hist2", data) _save(bot, "#test:hist2", data)
_save_result("#test", "hist2", "yt", { _save_result(bot, "#test", "hist2", "yt", {
"id": "y1", "title": "Plain Video", "url": "https://yt.example.com/1", "id": "y1", "title": "Plain Video", "url": "https://yt.example.com/1",
"date": "", "extra": "", "date": "", "extra": "",
}) })
@@ -1348,7 +1356,7 @@ class TestExtraInInfo:
"interval": 300, "seen": {}, "last_poll": "", "last_error": "", "interval": 300, "seen": {}, "last_poll": "", "last_error": "",
} }
_save(bot, "#test:inf", data) _save(bot, "#test:inf", data)
short_id = _save_result("#test", "inf", "gh", { short_id = _save_result(bot, "#test", "inf", "gh", {
"id": "g1", "title": "cool/repo: A cool project", "id": "g1", "title": "cool/repo: A cool project",
"url": "https://github.com/cool/repo", "url": "https://github.com/cool/repo",
"date": "2026-01-10", "extra": "42* 5fk", "date": "2026-01-10", "extra": "42* 5fk",
@@ -1370,7 +1378,7 @@ class TestExtraInInfo:
"interval": 300, "seen": {}, "last_poll": "", "last_error": "", "interval": 300, "seen": {}, "last_poll": "", "last_error": "",
} }
_save(bot, "#test:inf2", data) _save(bot, "#test:inf2", data)
short_id = _save_result("#test", "inf2", "yt", { short_id = _save_result(bot, "#test", "inf2", "yt", {
"id": "y2", "title": "Some Video", "id": "y2", "title": "Some Video",
"url": "https://youtube.com/watch?v=y2", "url": "https://youtube.com/watch?v=y2",
"date": "", "extra": "", "date": "", "extra": "",

212
tests/test_alias.py Normal file
View 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)

View File

@@ -5,7 +5,14 @@ from pathlib import Path
import pytest import pytest
from derp.config import DEFAULTS, _merge, load, resolve_config from derp.config import (
DEFAULTS,
_merge,
_server_name,
build_server_configs,
load,
resolve_config,
)
class TestMerge: class TestMerge:
@@ -110,3 +117,182 @@ class TestResolveConfig:
original = copy.deepcopy(DEFAULTS) original = copy.deepcopy(DEFAULTS)
resolve_config(None) resolve_config(None)
assert DEFAULTS == original assert DEFAULTS == original
# ---------------------------------------------------------------------------
# _server_name
# ---------------------------------------------------------------------------
class TestServerName:
"""Test hostname-to-short-name derivation."""
def test_libera(self):
assert _server_name("irc.libera.chat") == "libera"
def test_oftc(self):
assert _server_name("irc.oftc.net") == "oftc"
def test_freenode(self):
assert _server_name("chat.freenode.net") == "freenode"
def test_plain_hostname(self):
assert _server_name("myserver") == "myserver"
def test_empty_fallback(self):
assert _server_name("") == ""
def test_only_common_parts(self):
assert _server_name("irc.chat.irc") == "irc.chat.irc"
# ---------------------------------------------------------------------------
# build_server_configs
# ---------------------------------------------------------------------------
class TestBuildServerConfigs:
"""Test multi-server config builder."""
def test_legacy_single_server(self):
"""Legacy [server] config returns a single-entry dict."""
raw = _merge(DEFAULTS, {
"server": {"host": "irc.libera.chat", "nick": "testbot"},
})
result = build_server_configs(raw)
assert list(result.keys()) == ["libera"]
assert result["libera"]["server"]["nick"] == "testbot"
def test_legacy_preserves_full_config(self):
"""Legacy mode passes through the entire config dict."""
raw = _merge(DEFAULTS, {
"server": {"host": "irc.oftc.net"},
"bot": {"prefix": "."},
})
result = build_server_configs(raw)
cfg = result["oftc"]
assert cfg["bot"]["prefix"] == "."
assert cfg["server"]["host"] == "irc.oftc.net"
def test_multi_server_creates_entries(self):
"""Multiple [servers.*] blocks produce multiple entries."""
raw = {
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
"servers": {
"libera": {"host": "irc.libera.chat", "port": 6697,
"nick": "derp", "channels": ["#test"]},
"oftc": {"host": "irc.oftc.net", "port": 6697,
"nick": "derpbot", "channels": ["#derp"]},
},
}
result = build_server_configs(raw)
assert set(result.keys()) == {"libera", "oftc"}
def test_multi_server_key_separation(self):
"""Server keys and bot keys are separated correctly."""
raw = {
"servers": {
"test": {
"host": "irc.test.net", "port": 6667, "tls": False,
"nick": "bot",
"prefix": ".", "channels": ["#a"],
"admins": ["*!*@admin"],
},
},
}
result = build_server_configs(raw)
cfg = result["test"]
# Server keys
assert cfg["server"]["host"] == "irc.test.net"
assert cfg["server"]["port"] == 6667
assert cfg["server"]["nick"] == "bot"
# Bot keys (overrides)
assert cfg["bot"]["prefix"] == "."
assert cfg["bot"]["channels"] == ["#a"]
assert cfg["bot"]["admins"] == ["*!*@admin"]
def test_multi_server_inherits_shared_bot(self):
"""Per-server configs inherit shared [bot] defaults."""
raw = {
"bot": {"prefix": ".", "admins": ["*!*@global"]},
"servers": {
"s1": {"host": "irc.s1.net", "nick": "bot1"},
},
}
result = build_server_configs(raw)
cfg = result["s1"]
assert cfg["bot"]["prefix"] == "."
assert cfg["bot"]["admins"] == ["*!*@global"]
def test_multi_server_overrides_shared_bot(self):
"""Per-server bot keys override shared [bot] values."""
raw = {
"bot": {"prefix": "!", "admins": ["*!*@global"]},
"servers": {
"s1": {"host": "irc.s1.net", "nick": "bot1",
"prefix": ".", "admins": ["*!*@local"]},
},
}
result = build_server_configs(raw)
cfg = result["s1"]
assert cfg["bot"]["prefix"] == "."
assert cfg["bot"]["admins"] == ["*!*@local"]
def test_multi_server_defaults_applied(self):
"""Missing keys fall back to DEFAULTS."""
raw = {
"servers": {
"minimal": {"host": "irc.min.net", "nick": "m"},
},
}
result = build_server_configs(raw)
cfg = result["minimal"]
assert cfg["server"]["tls"] is True # from DEFAULTS
assert cfg["bot"]["prefix"] == "!" # from DEFAULTS
assert cfg["bot"]["rate_limit"] == 2.0
def test_multi_server_shared_sections(self):
"""Shared webhook/logging sections propagate to all servers."""
raw = {
"webhook": {"enabled": True, "port": 9090},
"logging": {"format": "json"},
"servers": {
"a": {"host": "irc.a.net", "nick": "a"},
"b": {"host": "irc.b.net", "nick": "b"},
},
}
result = build_server_configs(raw)
for name in ("a", "b"):
assert result[name]["webhook"]["enabled"] is True
assert result[name]["webhook"]["port"] == 9090
assert result[name]["logging"]["format"] == "json"
def test_empty_servers_section_falls_back(self):
"""Empty [servers] section treated as legacy single-server."""
raw = _merge(DEFAULTS, {"servers": {}})
result = build_server_configs(raw)
assert len(result) == 1
def test_no_servers_key_is_legacy(self):
"""Config without [servers] is legacy single-server mode."""
raw = copy.deepcopy(DEFAULTS)
result = build_server_configs(raw)
assert len(result) == 1
name = list(result.keys())[0]
assert result[name] is raw
class TestProxyDefaults:
"""Verify proxy defaults in each adapter section."""
def test_server_proxy_default_false(self):
assert DEFAULTS["server"]["proxy"] is False
def test_teams_proxy_default_true(self):
assert DEFAULTS["teams"]["proxy"] is True
def test_telegram_proxy_default_true(self):
assert DEFAULTS["telegram"]["proxy"] is True
def test_mumble_proxy_default_true(self):
assert DEFAULTS["mumble"]["proxy"] is True

288
tests/test_core.py Normal file
View 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}"

View File

@@ -20,16 +20,15 @@ from plugins.cron import ( # noqa: E402
_MAX_JOBS, _MAX_JOBS,
_delete, _delete,
_format_duration, _format_duration,
_jobs,
_load, _load,
_make_id, _make_id,
_parse_duration, _parse_duration,
_ps,
_restore, _restore,
_save, _save,
_start_job, _start_job,
_state_key, _state_key,
_stop_job, _stop_job,
_tasks,
cmd_cron, cmd_cron,
on_connect, on_connect,
) )
@@ -67,6 +66,7 @@ class _FakeBot:
self.replied: list[str] = [] self.replied: list[str] = []
self.dispatched: list[Message] = [] self.dispatched: list[Message] = []
self.state = _FakeState() self.state = _FakeState()
self._pstate: dict = {}
self._admin = admin self._admin = admin
self.prefix = "!" self.prefix = "!"
@@ -99,13 +99,16 @@ def _pm(text: str, nick: str = "admin") -> Message:
) )
def _clear() -> None: def _clear(bot=None) -> None:
"""Reset module-level state between tests.""" """Reset per-bot plugin state between tests."""
for task in _tasks.values(): if bot is None:
return
ps = _ps(bot)
for task in ps["tasks"].values():
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
_tasks.clear() ps["tasks"].clear()
_jobs.clear() ps["jobs"].clear()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -226,7 +229,6 @@ class TestStateHelpers:
class TestCmdCronAdd: class TestCmdCronAdd:
def test_add_success(self): def test_add_success(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -245,50 +247,43 @@ class TestCmdCronAdd:
assert data["interval"] == 300 assert data["interval"] == 300
assert data["channel"] == "#ops" assert data["channel"] == "#ops"
# Verify task started # Verify task started
assert len(_tasks) == 1 assert len(_ps(bot)["tasks"]) == 1
_clear() _clear(bot)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_add_requires_channel(self): def test_add_requires_channel(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _pm("!cron add 5m #ops !ping"))) asyncio.run(cmd_cron(bot, _pm("!cron add 5m #ops !ping")))
assert "Use this command in a channel" in bot.replied[0] assert "Use this command in a channel" in bot.replied[0]
def test_add_missing_args(self): def test_add_missing_args(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _msg("!cron add 5m"))) asyncio.run(cmd_cron(bot, _msg("!cron add 5m")))
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
def test_add_invalid_interval(self): def test_add_invalid_interval(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _msg("!cron add abc #ops !ping"))) asyncio.run(cmd_cron(bot, _msg("!cron add abc #ops !ping")))
assert "Invalid interval" in bot.replied[0] assert "Invalid interval" in bot.replied[0]
def test_add_interval_too_short(self): def test_add_interval_too_short(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _msg("!cron add 30s #ops !ping"))) asyncio.run(cmd_cron(bot, _msg("!cron add 30s #ops !ping")))
assert "Minimum interval" in bot.replied[0] assert "Minimum interval" in bot.replied[0]
def test_add_interval_too_long(self): def test_add_interval_too_long(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _msg("!cron add 30d #ops !ping"))) asyncio.run(cmd_cron(bot, _msg("!cron add 30d #ops !ping")))
assert "Maximum interval" in bot.replied[0] assert "Maximum interval" in bot.replied[0]
def test_add_bad_target(self): def test_add_bad_target(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _msg("!cron add 5m alice !ping"))) asyncio.run(cmd_cron(bot, _msg("!cron add 5m alice !ping")))
assert "Target must be a channel" in bot.replied[0] assert "Target must be a channel" in bot.replied[0]
def test_add_job_limit(self): def test_add_job_limit(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
for i in range(_MAX_JOBS): for i in range(_MAX_JOBS):
_save(bot, f"#ops:job{i}", {"id": f"job{i}", "channel": "#ops"}) _save(bot, f"#ops:job{i}", {"id": f"job{i}", "channel": "#ops"})
@@ -297,7 +292,6 @@ class TestCmdCronAdd:
assert "limit reached" in bot.replied[0] assert "limit reached" in bot.replied[0]
def test_add_admin_required(self): def test_add_admin_required(self):
_clear()
bot = _FakeBot(admin=False) bot = _FakeBot(admin=False)
asyncio.run(cmd_cron(bot, _msg("!cron add 5m #ops !ping"))) asyncio.run(cmd_cron(bot, _msg("!cron add 5m #ops !ping")))
# The @command(admin=True) decorator handles this via bot._dispatch_command, # The @command(admin=True) decorator handles this via bot._dispatch_command,
@@ -313,7 +307,6 @@ class TestCmdCronAdd:
class TestCmdCronDel: class TestCmdCronDel:
def test_del_success(self): def test_del_success(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -327,25 +320,22 @@ class TestCmdCronDel:
assert "Removed" in bot.replied[0] assert "Removed" in bot.replied[0]
assert cron_id in bot.replied[0] assert cron_id in bot.replied[0]
assert len(bot.state.keys("cron")) == 0 assert len(bot.state.keys("cron")) == 0
_clear() _clear(bot)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_del_nonexistent(self): def test_del_nonexistent(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _msg("!cron del nosuch"))) asyncio.run(cmd_cron(bot, _msg("!cron del nosuch")))
assert "No cron job" in bot.replied[0] assert "No cron job" in bot.replied[0]
def test_del_missing_id(self): def test_del_missing_id(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _msg("!cron del"))) asyncio.run(cmd_cron(bot, _msg("!cron del")))
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
def test_del_with_hash_prefix(self): def test_del_with_hash_prefix(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -356,7 +346,7 @@ class TestCmdCronDel:
bot.replied.clear() bot.replied.clear()
await cmd_cron(bot, _msg(f"!cron del #{cron_id}")) await cmd_cron(bot, _msg(f"!cron del #{cron_id}"))
assert "Removed" in bot.replied[0] assert "Removed" in bot.replied[0]
_clear() _clear(bot)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -368,13 +358,11 @@ class TestCmdCronDel:
class TestCmdCronList: class TestCmdCronList:
def test_list_empty(self): def test_list_empty(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _msg("!cron list"))) asyncio.run(cmd_cron(bot, _msg("!cron list")))
assert "No cron jobs" in bot.replied[0] assert "No cron jobs" in bot.replied[0]
def test_list_populated(self): def test_list_populated(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
_save(bot, "#test:abc123", { _save(bot, "#test:abc123", {
"id": "abc123", "channel": "#test", "id": "abc123", "channel": "#test",
@@ -386,13 +374,11 @@ class TestCmdCronList:
assert "!rss check news" in bot.replied[0] assert "!rss check news" in bot.replied[0]
def test_list_requires_channel(self): def test_list_requires_channel(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _pm("!cron list"))) asyncio.run(cmd_cron(bot, _pm("!cron list")))
assert "Use this command in a channel" in bot.replied[0] assert "Use this command in a channel" in bot.replied[0]
def test_list_channel_isolation(self): def test_list_channel_isolation(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
_save(bot, "#test:mine", { _save(bot, "#test:mine", {
"id": "mine", "channel": "#test", "id": "mine", "channel": "#test",
@@ -413,13 +399,11 @@ class TestCmdCronList:
class TestCmdCronUsage: class TestCmdCronUsage:
def test_no_args(self): def test_no_args(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _msg("!cron"))) asyncio.run(cmd_cron(bot, _msg("!cron")))
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
def test_unknown_subcommand(self): def test_unknown_subcommand(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_cron(bot, _msg("!cron foobar"))) asyncio.run(cmd_cron(bot, _msg("!cron foobar")))
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
@@ -431,7 +415,6 @@ class TestCmdCronUsage:
class TestRestore: class TestRestore:
def test_restore_spawns_tasks(self): def test_restore_spawns_tasks(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"id": "abc123", "channel": "#test", "id": "abc123", "channel": "#test",
@@ -443,15 +426,15 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:abc123" in _tasks ps = _ps(bot)
assert not _tasks["#test:abc123"].done() assert "#test:abc123" in ps["tasks"]
_clear() assert not ps["tasks"]["#test:abc123"].done()
_clear(bot)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_restore_skips_active(self): def test_restore_skips_active(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"id": "active", "channel": "#test", "id": "active", "channel": "#test",
@@ -462,17 +445,17 @@ class TestRestore:
_save(bot, "#test:active", data) _save(bot, "#test:active", data)
async def inner(): async def inner():
ps = _ps(bot)
dummy = asyncio.create_task(asyncio.sleep(9999)) dummy = asyncio.create_task(asyncio.sleep(9999))
_tasks["#test:active"] = dummy ps["tasks"]["#test:active"] = dummy
_restore(bot) _restore(bot)
assert _tasks["#test:active"] is dummy assert ps["tasks"]["#test:active"] is dummy
dummy.cancel() dummy.cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_restore_replaces_done_task(self): def test_restore_replaces_done_task(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"id": "done", "channel": "#test", "id": "done", "channel": "#test",
@@ -483,31 +466,30 @@ class TestRestore:
_save(bot, "#test:done", data) _save(bot, "#test:done", data)
async def inner(): async def inner():
ps = _ps(bot)
done_task = asyncio.create_task(asyncio.sleep(0)) done_task = asyncio.create_task(asyncio.sleep(0))
await done_task await done_task
_tasks["#test:done"] = done_task ps["tasks"]["#test:done"] = done_task
_restore(bot) _restore(bot)
new_task = _tasks["#test:done"] new_task = ps["tasks"]["#test:done"]
assert new_task is not done_task assert new_task is not done_task
assert not new_task.done() assert not new_task.done()
_clear() _clear(bot)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_restore_skips_bad_json(self): def test_restore_skips_bad_json(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
bot.state.set("cron", "#test:bad", "not json{{{") bot.state.set("cron", "#test:bad", "not json{{{")
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:bad" not in _tasks assert "#test:bad" not in _ps(bot)["tasks"]
asyncio.run(inner()) asyncio.run(inner())
def test_on_connect_calls_restore(self): def test_on_connect_calls_restore(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"id": "conn", "channel": "#test", "id": "conn", "channel": "#test",
@@ -520,8 +502,8 @@ class TestRestore:
async def inner(): async def inner():
msg = _msg("", target="botname") msg = _msg("", target="botname")
await on_connect(bot, msg) await on_connect(bot, msg)
assert "#test:conn" in _tasks assert "#test:conn" in _ps(bot)["tasks"]
_clear() _clear(bot)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -534,7 +516,6 @@ class TestRestore:
class TestCronLoop: class TestCronLoop:
def test_dispatches_command(self): def test_dispatches_command(self):
"""Cron loop dispatches a synthetic message after interval.""" """Cron loop dispatches a synthetic message after interval."""
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -545,10 +526,10 @@ class TestCronLoop:
"added_by": "admin", "added_by": "admin",
} }
key = "#test:loop1" key = "#test:loop1"
_jobs[key] = data _ps(bot)["jobs"][key] = data
_start_job(bot, key) _start_job(bot, key)
await asyncio.sleep(0.15) await asyncio.sleep(0.15)
_stop_job(key) _stop_job(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
# Should have dispatched at least once # Should have dispatched at least once
assert len(bot.dispatched) >= 1 assert len(bot.dispatched) >= 1
@@ -560,8 +541,7 @@ class TestCronLoop:
asyncio.run(inner()) asyncio.run(inner())
def test_loop_stops_on_job_removal(self): def test_loop_stops_on_job_removal(self):
"""Cron loop exits when job is removed from _jobs.""" """Cron loop exits when job is removed from jobs dict."""
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -572,12 +552,13 @@ class TestCronLoop:
"added_by": "admin", "added_by": "admin",
} }
key = "#test:loop2" key = "#test:loop2"
_jobs[key] = data ps = _ps(bot)
ps["jobs"][key] = data
_start_job(bot, key) _start_job(bot, key)
await asyncio.sleep(0.02) await asyncio.sleep(0.02)
_jobs.pop(key, None) ps["jobs"].pop(key, None)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
task = _tasks.get(key) task = ps["tasks"].get(key)
if task: if task:
assert task.done() assert task.done()
@@ -590,7 +571,6 @@ class TestCronLoop:
class TestJobManagement: class TestJobManagement:
def test_start_and_stop(self): def test_start_and_stop(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"id": "mgmt", "channel": "#test", "id": "mgmt", "channel": "#test",
@@ -599,21 +579,21 @@ class TestJobManagement:
"added_by": "admin", "added_by": "admin",
} }
key = "#test:mgmt" key = "#test:mgmt"
_jobs[key] = data ps = _ps(bot)
ps["jobs"][key] = data
async def inner(): async def inner():
_start_job(bot, key) _start_job(bot, key)
assert key in _tasks assert key in ps["tasks"]
assert not _tasks[key].done() assert not ps["tasks"][key].done()
_stop_job(key) _stop_job(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
assert key not in _tasks assert key not in ps["tasks"]
assert key not in _jobs assert key not in ps["jobs"]
asyncio.run(inner()) asyncio.run(inner())
def test_start_idempotent(self): def test_start_idempotent(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"id": "idem", "channel": "#test", "id": "idem", "channel": "#test",
@@ -622,18 +602,19 @@ class TestJobManagement:
"added_by": "admin", "added_by": "admin",
} }
key = "#test:idem" key = "#test:idem"
_jobs[key] = data ps = _ps(bot)
ps["jobs"][key] = data
async def inner(): async def inner():
_start_job(bot, key) _start_job(bot, key)
first = _tasks[key] first = ps["tasks"][key]
_start_job(bot, key) _start_job(bot, key)
assert _tasks[key] is first assert ps["tasks"][key] is first
_stop_job(key) _stop_job(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_stop_nonexistent(self): def test_stop_nonexistent(self):
_clear() bot = _FakeBot()
_stop_job("#test:nonexistent") _stop_job(bot, "#test:nonexistent")

View File

@@ -1,5 +1,6 @@
"""Tests for the SOCKS5 proxy HTTP/TCP module.""" """Tests for the HTTP/TCP module with optional SOCKS5 proxy."""
import socket
import ssl import ssl
import urllib.error import urllib.error
import urllib.request import urllib.request
@@ -202,11 +203,15 @@ class TestUrlopen:
pool = MagicMock() pool = MagicMock()
resp = MagicMock() resp = MagicMock()
resp.status = 200 resp.status = 200
resp.data = b"ok"
resp.reason = "OK"
resp.headers = {}
pool.request.return_value = resp pool.request.return_value = resp
mock_pool_fn.return_value = pool mock_pool_fn.return_value = pool
result = urlopen("https://example.com/") result = urlopen("https://example.com/")
assert result is resp assert result.status == 200
assert result.read() == b"ok"
@patch.object(derp.http, "_get_pool") @patch.object(derp.http, "_get_pool")
def test_context_falls_back_to_opener(self, mock_pool_fn): def test_context_falls_back_to_opener(self, mock_pool_fn):
@@ -267,3 +272,106 @@ class TestCreateConnection:
mock_cls.return_value = sock mock_cls.return_value = sock
result = create_connection(("example.com", 443)) result = create_connection(("example.com", 443))
assert result is sock assert result is sock
# -- proxy=False paths -------------------------------------------------------
class TestUrlopenDirect:
"""Tests for urlopen(proxy=False) -- stdlib direct path."""
@patch("derp.http.urllib.request.urlopen")
def test_uses_stdlib_urlopen(self, mock_urlopen):
resp = MagicMock()
mock_urlopen.return_value = resp
result = urlopen("https://example.com/", proxy=False)
mock_urlopen.assert_called_once()
assert result is resp
@patch("derp.http.urllib.request.urlopen")
def test_passes_timeout(self, mock_urlopen):
resp = MagicMock()
mock_urlopen.return_value = resp
urlopen("https://example.com/", timeout=15, proxy=False)
_, kwargs = mock_urlopen.call_args
assert kwargs["timeout"] == 15
@patch("derp.http.urllib.request.urlopen")
def test_passes_context(self, mock_urlopen):
resp = MagicMock()
mock_urlopen.return_value = resp
ctx = ssl.create_default_context()
urlopen("https://example.com/", context=ctx, proxy=False)
_, kwargs = mock_urlopen.call_args
assert kwargs["context"] is ctx
@patch.object(derp.http, "_get_pool")
@patch("derp.http.urllib.request.urlopen")
def test_skips_socks_pool(self, mock_urlopen, mock_pool_fn):
resp = MagicMock()
mock_urlopen.return_value = resp
urlopen("https://example.com/", proxy=False)
mock_pool_fn.assert_not_called()
class TestBuildOpenerDirect:
"""Tests for build_opener(proxy=False) -- no SOCKS5 handler."""
def test_no_proxy_handler(self):
opener = build_opener(proxy=False)
proxy_handlers = [h for h in opener.handlers
if isinstance(h, _ProxyHandler)]
assert len(proxy_handlers) == 0
def test_with_extra_handler(self):
class Custom(urllib.request.HTTPRedirectHandler):
pass
opener = build_opener(Custom, proxy=False)
custom = [h for h in opener.handlers if isinstance(h, Custom)]
assert len(custom) == 1
proxy_handlers = [h for h in opener.handlers
if isinstance(h, _ProxyHandler)]
assert len(proxy_handlers) == 0
class TestCreateConnectionDirect:
"""Tests for create_connection(proxy=False) -- stdlib socket."""
@patch("derp.http.socket.socket")
def test_uses_stdlib_socket(self, mock_sock_cls):
sock = MagicMock()
mock_sock_cls.return_value = sock
result = create_connection(("example.com", 443), proxy=False)
mock_sock_cls.assert_called_once_with(
socket.AF_INET, socket.SOCK_STREAM,
)
assert result is sock
@patch("derp.http.socket.socket")
def test_connects_to_target(self, mock_sock_cls):
sock = MagicMock()
mock_sock_cls.return_value = sock
create_connection(("example.com", 443), proxy=False)
sock.connect.assert_called_once_with(("example.com", 443))
@patch("derp.http.socket.socket")
def test_sets_timeout(self, mock_sock_cls):
sock = MagicMock()
mock_sock_cls.return_value = sock
create_connection(("example.com", 443), timeout=10, proxy=False)
sock.settimeout.assert_called_once_with(10)
@patch("derp.http.socket.socket")
def test_no_socks_proxy_set(self, mock_sock_cls):
sock = MagicMock()
mock_sock_cls.return_value = sock
create_connection(("example.com", 443), proxy=False)
sock.set_proxy.assert_not_called()
@patch("derp.http.socks.socksocket")
@patch("derp.http.socket.socket")
def test_no_socksocket_created(self, mock_sock_cls, mock_socks_cls):
sock = MagicMock()
mock_sock_cls.return_value = sock
create_connection(("example.com", 443), proxy=False)
mock_socks_cls.assert_not_called()

View File

@@ -95,7 +95,7 @@ class _Harness:
config["channels"] = channel_config config["channels"] = channel_config
self.registry = PluginRegistry() self.registry = PluginRegistry()
self.bot = Bot(config, self.registry) self.bot = Bot("test", config, self.registry)
self.conn = _MockConnection() self.conn = _MockConnection()
self.bot.conn = self.conn # type: ignore[assignment] self.bot.conn = self.conn # type: ignore[assignment]
self.bot.state = StateStore(":memory:") self.bot.state = StateStore(":memory:")

View File

@@ -1,6 +1,12 @@
"""Tests for IRC message parsing and formatting.""" """Tests for IRC message parsing and formatting."""
from derp.irc import _parse_tags, _unescape_tag_value, format_msg, parse from derp.irc import (
IRCConnection,
_parse_tags,
_unescape_tag_value,
format_msg,
parse,
)
class TestParse: class TestParse:
@@ -142,3 +148,19 @@ class TestFormat:
# No space in tail, not starting with colon, head exists -> no colon # No space in tail, not starting with colon, head exists -> no colon
result = format_msg("MODE", "#ch", "+o", "nick") result = format_msg("MODE", "#ch", "+o", "nick")
assert result == "MODE #ch +o nick" assert result == "MODE #ch +o nick"
class TestIRCConnectionProxy:
"""IRCConnection proxy flag tests."""
def test_proxy_default_false(self):
conn = IRCConnection("irc.example.com", 6697)
assert conn.proxy is False
def test_proxy_enabled(self):
conn = IRCConnection("irc.example.com", 6697, proxy=True)
assert conn.proxy is True
def test_proxy_disabled(self):
conn = IRCConnection("irc.example.com", 6697, proxy=False)
assert conn.proxy is False

916
tests/test_lastfm.py Normal file
View 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
View 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

739
tests/test_mumble.py Normal file
View File

@@ -0,0 +1,739 @@
"""Tests for the Mumble adapter."""
import asyncio
import struct
from unittest.mock import AsyncMock, MagicMock, patch
from derp.mumble import (
MumbleBot,
MumbleMessage,
_escape_html,
_scale_pcm,
_scale_pcm_ramp,
_shell_quote,
_strip_html,
)
from derp.plugin import PluginRegistry
# -- Helpers -----------------------------------------------------------------
def _make_bot(admins=None, operators=None, trusted=None, prefix=None):
"""Create a MumbleBot with test config."""
config = {
"mumble": {
"enabled": True,
"host": "127.0.0.1",
"port": 64738,
"username": "derp",
"password": "",
"admins": admins or [],
"operators": operators or [],
"trusted": trusted or [],
},
"bot": {
"prefix": prefix or "!",
"paste_threshold": 4,
"plugins_dir": "plugins",
"rate_limit": 2.0,
"rate_burst": 5,
},
}
registry = PluginRegistry()
bot = MumbleBot("mu-test", config, registry)
return bot
def _mu_msg(text="!ping", nick="Alice", prefix="Alice",
target="0", is_channel=True):
"""Create a MumbleMessage for command testing."""
return MumbleMessage(
raw={}, nick=nick, prefix=prefix, text=text, target=target,
is_channel=is_channel,
params=[target, text],
)
# -- Test helpers for registering commands -----------------------------------
async def _echo_handler(bot, msg):
"""Simple command handler that echoes text."""
args = msg.text.split(None, 1)
reply = args[1] if len(args) > 1 else "no args"
await bot.reply(msg, reply)
async def _admin_handler(bot, msg):
"""Admin-only command handler."""
await bot.reply(msg, "admin action done")
# ---------------------------------------------------------------------------
# TestMumbleMessage
# ---------------------------------------------------------------------------
class TestMumbleMessage:
def test_defaults(self):
msg = MumbleMessage(raw={}, nick=None, prefix=None, text=None,
target=None)
assert msg.is_channel is True
assert msg.command == "PRIVMSG"
assert msg.params == []
assert msg.tags == {}
def test_custom_values(self):
msg = MumbleMessage(
raw={"field": 1}, nick="Alice", prefix="Alice",
text="hello", target="0", is_channel=True,
command="PRIVMSG", params=["0", "hello"],
tags={"key": "val"},
)
assert msg.nick == "Alice"
assert msg.prefix == "Alice"
assert msg.text == "hello"
assert msg.target == "0"
assert msg.tags == {"key": "val"}
def test_duck_type_compat(self):
"""MumbleMessage has the same attribute names as IRC Message."""
msg = _mu_msg()
attrs = ["raw", "nick", "prefix", "text", "target",
"is_channel", "command", "params", "tags"]
for attr in attrs:
assert hasattr(msg, attr), f"missing attribute: {attr}"
def test_dm_message(self):
msg = _mu_msg(target="dm", is_channel=False)
assert msg.is_channel is False
assert msg.target == "dm"
def test_prefix_is_username(self):
msg = _mu_msg(prefix="admin_user")
assert msg.prefix == "admin_user"
# ---------------------------------------------------------------------------
# TestHtmlHelpers
# ---------------------------------------------------------------------------
class TestHtmlHelpers:
def test_strip_html_simple(self):
assert _strip_html("<b>bold</b>") == "bold"
def test_strip_html_entities(self):
assert _strip_html("&amp; &lt; &gt; &quot;") == '& < > "'
def test_strip_html_nested(self):
assert _strip_html("<p><b>hello</b> <i>world</i></p>") == "hello world"
def test_strip_html_plain(self):
assert _strip_html("no tags here") == "no tags here"
def test_escape_html(self):
assert _escape_html("<script>alert('xss')") == "&lt;script&gt;alert('xss')"
def test_escape_html_ampersand(self):
assert _escape_html("a & b") == "a &amp; b"
# ---------------------------------------------------------------------------
# TestMumbleBotReply
# ---------------------------------------------------------------------------
class TestMumbleBotReply:
def test_send_calls_send_html(self):
bot = _make_bot()
sent: list[tuple[str, str]] = []
async def _fake_send_html(target, html_text):
sent.append((target, html_text))
with patch.object(bot, "_send_html", side_effect=_fake_send_html):
asyncio.run(bot.send("5", "hello"))
assert sent == [("5", "hello")]
def test_send_escapes_html(self):
bot = _make_bot()
sent: list[tuple[str, str]] = []
async def _fake_send_html(target, html_text):
sent.append((target, html_text))
with patch.object(bot, "_send_html", side_effect=_fake_send_html):
asyncio.run(bot.send("0", "<script>alert('xss')"))
assert "&lt;script&gt;" in sent[0][1]
def test_reply_sends_to_target(self):
bot = _make_bot()
msg = _mu_msg(target="5")
sent: list[tuple[str, str]] = []
async def _fake_send(target, text):
sent.append((target, text))
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot.reply(msg, "pong"))
assert sent == [("5", "pong")]
def test_reply_dm_fallback(self):
bot = _make_bot()
msg = _mu_msg(target="dm", is_channel=False)
sent: list[tuple[str, str]] = []
async def _fake_send(target, text):
sent.append((target, text))
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot.reply(msg, "dm reply"))
assert sent == [("0", "dm reply")]
def test_long_reply_under_threshold(self):
bot = _make_bot()
msg = _mu_msg()
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot.long_reply(msg, ["a", "b", "c"]))
assert sent == ["a", "b", "c"]
def test_long_reply_over_threshold_no_paste(self):
bot = _make_bot()
msg = _mu_msg()
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot.long_reply(msg, ["a", "b", "c", "d", "e"]))
assert sent == ["a", "b", "c", "d", "e"]
def test_long_reply_empty(self):
bot = _make_bot()
msg = _mu_msg()
with patch.object(bot, "send") as mock_send:
asyncio.run(bot.long_reply(msg, []))
mock_send.assert_not_called()
def test_action_format(self):
bot = _make_bot()
sent: list[tuple[str, str]] = []
async def _fake_send_html(target, html_text):
sent.append((target, html_text))
with patch.object(bot, "_send_html", side_effect=_fake_send_html):
asyncio.run(bot.action("0", "does a thing"))
assert sent == [("0", "<i>does a thing</i>")]
def test_action_escapes_content(self):
bot = _make_bot()
sent: list[tuple[str, str]] = []
async def _fake_send_html(target, html_text):
sent.append((target, html_text))
with patch.object(bot, "_send_html", side_effect=_fake_send_html):
asyncio.run(bot.action("0", "<script>"))
assert sent == [("0", "<i>&lt;script&gt;</i>")]
# ---------------------------------------------------------------------------
# TestMumbleBotDispatch
# ---------------------------------------------------------------------------
class TestMumbleBotDispatch:
def test_dispatch_known_command(self):
bot = _make_bot()
bot.registry.register_command(
"echo", _echo_handler, help="echo", plugin="test")
msg = _mu_msg(text="!echo world")
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot._dispatch_command(msg))
assert sent == ["world"]
def test_dispatch_unknown_command(self):
bot = _make_bot()
msg = _mu_msg(text="!nonexistent")
with patch.object(bot, "send") as mock_send:
asyncio.run(bot._dispatch_command(msg))
mock_send.assert_not_called()
def test_dispatch_no_prefix(self):
bot = _make_bot()
msg = _mu_msg(text="just a message")
with patch.object(bot, "send") as mock_send:
asyncio.run(bot._dispatch_command(msg))
mock_send.assert_not_called()
def test_dispatch_empty_text(self):
bot = _make_bot()
msg = _mu_msg(text="")
with patch.object(bot, "send") as mock_send:
asyncio.run(bot._dispatch_command(msg))
mock_send.assert_not_called()
def test_dispatch_none_text(self):
bot = _make_bot()
msg = _mu_msg()
msg.text = None
with patch.object(bot, "send") as mock_send:
asyncio.run(bot._dispatch_command(msg))
mock_send.assert_not_called()
def test_dispatch_ambiguous(self):
bot = _make_bot()
bot.registry.register_command("ping", _echo_handler, plugin="test")
bot.registry.register_command("plugins", _echo_handler, plugin="test")
msg = _mu_msg(text="!p")
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot._dispatch_command(msg))
assert len(sent) == 1
assert "Ambiguous" in sent[0]
def test_dispatch_tier_denied(self):
bot = _make_bot()
bot.registry.register_command(
"secret", _admin_handler, plugin="test", tier="admin")
msg = _mu_msg(text="!secret", prefix="nobody")
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot._dispatch_command(msg))
assert len(sent) == 1
assert "Permission denied" in sent[0]
def test_dispatch_tier_allowed(self):
bot = _make_bot(admins=["Alice"])
bot.registry.register_command(
"secret", _admin_handler, plugin="test", tier="admin")
msg = _mu_msg(text="!secret", prefix="Alice")
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot._dispatch_command(msg))
assert sent == ["admin action done"]
def test_dispatch_prefix_match(self):
bot = _make_bot()
bot.registry.register_command("echo", _echo_handler, plugin="test")
msg = _mu_msg(text="!ec hello")
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot._dispatch_command(msg))
assert sent == ["hello"]
# ---------------------------------------------------------------------------
# TestMumbleBotTier
# ---------------------------------------------------------------------------
class TestMumbleBotTier:
def test_admin_tier(self):
bot = _make_bot(admins=["AdminUser"])
msg = _mu_msg(prefix="AdminUser")
assert bot._get_tier(msg) == "admin"
def test_oper_tier(self):
bot = _make_bot(operators=["OperUser"])
msg = _mu_msg(prefix="OperUser")
assert bot._get_tier(msg) == "oper"
def test_trusted_tier(self):
bot = _make_bot(trusted=["TrustedUser"])
msg = _mu_msg(prefix="TrustedUser")
assert bot._get_tier(msg) == "trusted"
def test_user_tier_default(self):
bot = _make_bot()
msg = _mu_msg(prefix="RandomUser")
assert bot._get_tier(msg) == "user"
def test_no_prefix(self):
bot = _make_bot(admins=["Admin"])
msg = _mu_msg()
msg.prefix = None
assert bot._get_tier(msg) == "user"
def test_is_admin_true(self):
bot = _make_bot(admins=["Admin"])
msg = _mu_msg(prefix="Admin")
assert bot._is_admin(msg) is True
def test_is_admin_false(self):
bot = _make_bot()
msg = _mu_msg(prefix="Nobody")
assert bot._is_admin(msg) is False
def test_priority_order(self):
"""Admin takes priority over oper and trusted."""
bot = _make_bot(admins=["User"], operators=["User"], trusted=["User"])
msg = _mu_msg(prefix="User")
assert bot._get_tier(msg) == "admin"
# ---------------------------------------------------------------------------
# TestMumbleBotNoOps
# ---------------------------------------------------------------------------
class TestMumbleBotNoOps:
def test_join_noop(self):
bot = _make_bot()
asyncio.run(bot.join("#channel"))
def test_part_noop(self):
bot = _make_bot()
asyncio.run(bot.part("#channel", "reason"))
def test_kick_noop(self):
bot = _make_bot()
asyncio.run(bot.kick("#channel", "nick", "reason"))
def test_mode_noop(self):
bot = _make_bot()
asyncio.run(bot.mode("#channel", "+o", "nick"))
def test_set_topic_noop(self):
bot = _make_bot()
asyncio.run(bot.set_topic("#channel", "new topic"))
def test_quit_stops(self):
bot = _make_bot()
bot._running = True
asyncio.run(bot.quit())
assert bot._running is False
# ---------------------------------------------------------------------------
# TestPluginManagement
# ---------------------------------------------------------------------------
class TestPluginManagement:
def test_load_plugin_not_found(self):
bot = _make_bot()
ok, msg = bot.load_plugin("nonexistent_xyz")
assert ok is False
assert "not found" in msg
def test_load_plugin_already_loaded(self):
bot = _make_bot()
bot.registry._modules["test"] = object()
ok, msg = bot.load_plugin("test")
assert ok is False
assert "already loaded" in msg
def test_unload_core_refused(self):
bot = _make_bot()
ok, msg = bot.unload_plugin("core")
assert ok is False
assert "cannot unload core" in msg
def test_unload_not_loaded(self):
bot = _make_bot()
ok, msg = bot.unload_plugin("nonexistent")
assert ok is False
assert "not loaded" in msg
def test_reload_delegates(self):
bot = _make_bot()
ok, msg = bot.reload_plugin("nonexistent")
assert ok is False
assert "not loaded" in msg
# ---------------------------------------------------------------------------
# TestMumbleBotConfig
# ---------------------------------------------------------------------------
class TestMumbleBotConfig:
def test_prefix_from_mumble_section(self):
config = {
"mumble": {
"enabled": True,
"host": "127.0.0.1",
"port": 64738,
"username": "derp",
"password": "",
"prefix": "/",
"admins": [],
"operators": [],
"trusted": [],
},
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
}
bot = MumbleBot("test", config, PluginRegistry())
assert bot.prefix == "/"
def test_prefix_falls_back_to_bot(self):
config = {
"mumble": {
"enabled": True,
"host": "127.0.0.1",
"port": 64738,
"username": "derp",
"password": "",
"admins": [],
"operators": [],
"trusted": [],
},
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
}
bot = MumbleBot("test", config, PluginRegistry())
assert bot.prefix == "!"
def test_admins_coerced_to_str(self):
bot = _make_bot(admins=[111, 222])
assert bot._admins == ["111", "222"]
def test_default_port(self):
bot = _make_bot()
assert bot._port == 64738
def test_nick_from_username(self):
bot = _make_bot()
assert bot.nick == "derp"
# ---------------------------------------------------------------------------
# TestPcmScaling
# ---------------------------------------------------------------------------
class TestPcmScaling:
def test_unity_volume(self):
pcm = struct.pack("<hh", 1000, -1000)
result = _scale_pcm(pcm, 1.0)
assert result == pcm
def test_half_volume(self):
pcm = struct.pack("<h", 1000)
result = _scale_pcm(pcm, 0.5)
samples = struct.unpack("<h", result)
assert samples[0] == 500
def test_clamp_positive(self):
pcm = struct.pack("<h", 32767)
result = _scale_pcm(pcm, 2.0)
samples = struct.unpack("<h", result)
assert samples[0] == 32767
def test_clamp_negative(self):
pcm = struct.pack("<h", -32768)
result = _scale_pcm(pcm, 2.0)
samples = struct.unpack("<h", result)
assert samples[0] == -32768
def test_zero_volume(self):
pcm = struct.pack("<hh", 32767, -32768)
result = _scale_pcm(pcm, 0.0)
samples = struct.unpack("<hh", result)
assert samples == (0, 0)
def test_preserves_length(self):
pcm = b"\x00" * 1920
result = _scale_pcm(pcm, 0.5)
assert len(result) == 1920
# ---------------------------------------------------------------------------
# TestShellQuote
# ---------------------------------------------------------------------------
class TestShellQuote:
def test_simple(self):
assert _shell_quote("hello") == "'hello'"
def test_single_quote(self):
assert _shell_quote("it's") == "'it'\\''s'"
def test_url(self):
url = "https://youtube.com/watch?v=abc&t=10"
quoted = _shell_quote(url)
assert quoted.startswith("'")
assert quoted.endswith("'")
# ---------------------------------------------------------------------------
# TestPcmRamping
# ---------------------------------------------------------------------------
class TestPcmRamping:
def test_flat_when_equal(self):
"""When vol_start == vol_end, behaves like _scale_pcm."""
pcm = struct.pack("<hh", 1000, -1000)
result = _scale_pcm_ramp(pcm, 0.5, 0.5)
expected = _scale_pcm(pcm, 0.5)
assert result == expected
def test_linear_interpolation(self):
"""Volume ramps linearly from start to end across samples."""
pcm = struct.pack("<hhhh", 10000, 10000, 10000, 10000)
result = _scale_pcm_ramp(pcm, 0.0, 1.0)
samples = struct.unpack("<hhhh", result)
# At i=0: vol=0.0, i=1: vol=0.25, i=2: vol=0.5, i=3: vol=0.75
assert samples[0] == 0
assert samples[1] == 2500
assert samples[2] == 5000
assert samples[3] == 7500
def test_clamp_positive(self):
"""Ramping up with loud samples clamps to 32767."""
pcm = struct.pack("<h", 32767)
result = _scale_pcm_ramp(pcm, 2.0, 2.0)
samples = struct.unpack("<h", result)
assert samples[0] == 32767
def test_clamp_negative(self):
"""Ramping up with negative samples clamps to -32768."""
pcm = struct.pack("<h", -32768)
result = _scale_pcm_ramp(pcm, 2.0, 2.0)
samples = struct.unpack("<h", result)
assert samples[0] == -32768
def test_preserves_length(self):
"""Output length equals input length."""
pcm = b"\x00" * 1920
result = _scale_pcm_ramp(pcm, 0.0, 1.0)
assert len(result) == 1920
def test_empty_data(self):
"""Empty input returns empty output."""
result = _scale_pcm_ramp(b"", 0.0, 1.0)
assert result == b""
def test_reverse_direction(self):
"""Volume ramps down from start to end."""
pcm = struct.pack("<hhhh", 10000, 10000, 10000, 10000)
result = _scale_pcm_ramp(pcm, 1.0, 0.0)
samples = struct.unpack("<hhhh", result)
# At i=0: vol=1.0, i=1: vol=0.75, i=2: vol=0.5, i=3: vol=0.25
assert samples[0] == 10000
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
View 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]

2606
tests/test_music.py Normal file

File diff suppressed because it is too large Load Diff

310
tests/test_musicbrainz.py Normal file
View 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

View File

@@ -29,7 +29,7 @@ def _make_bot(*, paste_threshold: int = 4, flaskpaste_mod=None) -> Bot:
registry = PluginRegistry() registry = PluginRegistry()
if flaskpaste_mod is not None: if flaskpaste_mod is not None:
registry._modules["flaskpaste"] = flaskpaste_mod registry._modules["flaskpaste"] = flaskpaste_mod
bot = Bot(config, registry) bot = Bot("test", config, registry)
bot._sent: list[tuple[str, str]] = [] # type: ignore[attr-defined] bot._sent: list[tuple[str, str]] = [] # type: ignore[attr-defined]
async def _capturing_send(target: str, text: str) -> None: async def _capturing_send(target: str, text: str) -> None:

View File

@@ -22,13 +22,11 @@ from plugins.pastemoni import ( # noqa: E402
_MAX_SEEN, _MAX_SEEN,
_ArchiveParser, _ArchiveParser,
_delete, _delete,
_errors,
_fetch_gists, _fetch_gists,
_fetch_pastebin, _fetch_pastebin,
_load, _load,
_monitors,
_poll_once, _poll_once,
_pollers, _ps,
_restore, _restore,
_save, _save,
_snippet_around, _snippet_around,
@@ -132,6 +130,7 @@ class _FakeBot:
self.replied: list[str] = [] self.replied: list[str] = []
self.state = _FakeState() self.state = _FakeState()
self.registry = _FakeRegistry() self.registry = _FakeRegistry()
self._pstate: dict = {}
self._admin = admin self._admin = admin
async def send(self, target: str, text: str) -> None: async def send(self, target: str, text: str) -> None:
@@ -163,14 +162,17 @@ def _pm(text: str, nick: str = "alice") -> Message:
) )
def _clear() -> None: def _clear(bot=None) -> None:
"""Reset module-level state between tests.""" """Reset per-bot plugin state between tests."""
for task in _pollers.values(): if bot is None:
return
ps = _ps(bot)
for task in ps["pollers"].values():
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
_pollers.clear() ps["pollers"].clear()
_monitors.clear() ps["monitors"].clear()
_errors.clear() ps["errors"].clear()
def _fake_pb(keyword): def _fake_pb(keyword):
@@ -428,7 +430,6 @@ class TestStateHelpers:
class TestPollOnce: class TestPollOnce:
def test_new_items_announced(self): def test_new_items_announced(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "poll", "channel": "#test", "keyword": "test", "name": "poll", "channel": "#test",
@@ -437,7 +438,7 @@ class TestPollOnce:
} }
key = "#test:poll" key = "#test:poll"
_save(bot, key, data) _save(bot, key, data)
_monitors[key] = data _ps(bot)["monitors"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
@@ -451,7 +452,6 @@ class TestPollOnce:
asyncio.run(inner()) asyncio.run(inner())
def test_seen_items_deduped(self): def test_seen_items_deduped(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "dedup", "channel": "#test", "keyword": "test", "name": "dedup", "channel": "#test",
@@ -461,7 +461,7 @@ class TestPollOnce:
} }
key = "#test:dedup" key = "#test:dedup"
_save(bot, key, data) _save(bot, key, data)
_monitors[key] = data _ps(bot)["monitors"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
@@ -472,7 +472,6 @@ class TestPollOnce:
def test_error_increments_counter(self): def test_error_increments_counter(self):
"""All backends failing increments the error counter.""" """All backends failing increments the error counter."""
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "errs", "channel": "#test", "keyword": "test", "name": "errs", "channel": "#test",
@@ -481,20 +480,19 @@ class TestPollOnce:
} }
key = "#test:errs" key = "#test:errs"
_save(bot, key, data) _save(bot, key, data)
_monitors[key] = data _ps(bot)["monitors"][key] = data
all_fail = {"pb": _fake_pb_error, "gh": _fake_gh_error} all_fail = {"pb": _fake_pb_error, "gh": _fake_gh_error}
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", all_fail): with patch.object(_mod, "_BACKENDS", all_fail):
await _poll_once(bot, key, announce=True) await _poll_once(bot, key, announce=True)
assert _errors[key] == 1 assert _ps(bot)["errors"][key] == 1
assert len(bot.sent) == 0 assert len(bot.sent) == 0
asyncio.run(inner()) asyncio.run(inner())
def test_partial_failure_resets_counter(self): def test_partial_failure_resets_counter(self):
"""One backend succeeding resets the error counter.""" """One backend succeeding resets the error counter."""
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "partial", "channel": "#test", "keyword": "test", "name": "partial", "channel": "#test",
@@ -503,14 +501,14 @@ class TestPollOnce:
} }
key = "#test:partial" key = "#test:partial"
_save(bot, key, data) _save(bot, key, data)
_monitors[key] = data _ps(bot)["monitors"][key] = data
_errors[key] = 3 _ps(bot)["errors"][key] = 3
partial_fail = {"pb": _fake_pb_error, "gh": _fake_gh} partial_fail = {"pb": _fake_pb_error, "gh": _fake_gh}
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", partial_fail): with patch.object(_mod, "_BACKENDS", partial_fail):
await _poll_once(bot, key, announce=True) await _poll_once(bot, key, announce=True)
assert _errors[key] == 0 assert _ps(bot)["errors"][key] == 0
gh_msgs = [s for t, s in bot.sent if t == "#test" and "[gh]" in s] gh_msgs = [s for t, s in bot.sent if t == "#test" and "[gh]" in s]
assert len(gh_msgs) == 1 assert len(gh_msgs) == 1
@@ -518,7 +516,6 @@ class TestPollOnce:
def test_max_announce_cap(self): def test_max_announce_cap(self):
"""Only MAX_ANNOUNCE items announced per backend.""" """Only MAX_ANNOUNCE items announced per backend."""
_clear()
bot = _FakeBot() bot = _FakeBot()
def _fake_many(keyword): def _fake_many(keyword):
@@ -535,7 +532,7 @@ class TestPollOnce:
} }
key = "#test:cap" key = "#test:cap"
_save(bot, key, data) _save(bot, key, data)
_monitors[key] = data _ps(bot)["monitors"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", {"pb": _fake_many}): with patch.object(_mod, "_BACKENDS", {"pb": _fake_many}):
@@ -548,7 +545,6 @@ class TestPollOnce:
asyncio.run(inner()) asyncio.run(inner())
def test_no_announce_flag(self): def test_no_announce_flag(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "quiet", "channel": "#test", "keyword": "test", "name": "quiet", "channel": "#test",
@@ -557,7 +553,7 @@ class TestPollOnce:
} }
key = "#test:quiet" key = "#test:quiet"
_save(bot, key, data) _save(bot, key, data)
_monitors[key] = data _ps(bot)["monitors"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
@@ -571,7 +567,6 @@ class TestPollOnce:
def test_seen_cap(self): def test_seen_cap(self):
"""Seen list capped at MAX_SEEN per backend.""" """Seen list capped at MAX_SEEN per backend."""
_clear()
bot = _FakeBot() bot = _FakeBot()
def _fake_many(keyword): def _fake_many(keyword):
@@ -587,7 +582,7 @@ class TestPollOnce:
} }
key = "#test:seencap" key = "#test:seencap"
_save(bot, key, data) _save(bot, key, data)
_monitors[key] = data _ps(bot)["monitors"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_BACKENDS", {"pb": _fake_many}): with patch.object(_mod, "_BACKENDS", {"pb": _fake_many}):
@@ -605,7 +600,6 @@ class TestPollOnce:
class TestCmdAdd: class TestCmdAdd:
def test_add_success(self): def test_add_success(self):
_clear()
bot = _FakeBot(admin=True) bot = _FakeBot(admin=True)
async def inner(): async def inner():
@@ -621,38 +615,33 @@ class TestCmdAdd:
assert data["channel"] == "#test" assert data["channel"] == "#test"
assert len(data["seen"]["pb"]) == 2 assert len(data["seen"]["pb"]) == 2
assert len(data["seen"]["gh"]) == 1 assert len(data["seen"]["gh"]) == 1
assert "#test:leak-watch" in _pollers assert "#test:leak-watch" in _ps(bot)["pollers"]
_stop_poller("#test:leak-watch") _stop_poller(bot, "#test:leak-watch")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_add_requires_admin(self): def test_add_requires_admin(self):
_clear()
bot = _FakeBot(admin=False) bot = _FakeBot(admin=False)
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add test keyword"))) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add test keyword")))
assert "Permission denied" in bot.replied[0] assert "Permission denied" in bot.replied[0]
def test_add_requires_channel(self): def test_add_requires_channel(self):
_clear()
bot = _FakeBot(admin=True) bot = _FakeBot(admin=True)
asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni add test keyword"))) asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni add test keyword")))
assert "Use this command in a channel" in bot.replied[0] assert "Use this command in a channel" in bot.replied[0]
def test_add_invalid_name(self): def test_add_invalid_name(self):
_clear()
bot = _FakeBot(admin=True) bot = _FakeBot(admin=True)
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add BAD! keyword"))) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add BAD! keyword")))
assert "Invalid name" in bot.replied[0] assert "Invalid name" in bot.replied[0]
def test_add_missing_keyword(self): def test_add_missing_keyword(self):
_clear()
bot = _FakeBot(admin=True) bot = _FakeBot(admin=True)
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add myname"))) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add myname")))
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
def test_add_duplicate(self): def test_add_duplicate(self):
_clear()
bot = _FakeBot(admin=True) bot = _FakeBot(admin=True)
async def inner(): async def inner():
@@ -663,13 +652,12 @@ class TestCmdAdd:
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
await cmd_pastemoni(bot, _msg("!pastemoni add dupe other")) await cmd_pastemoni(bot, _msg("!pastemoni add dupe other"))
assert "already exists" in bot.replied[0] assert "already exists" in bot.replied[0]
_stop_poller("#test:dupe") _stop_poller(bot, "#test:dupe")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_add_limit(self): def test_add_limit(self):
_clear()
bot = _FakeBot(admin=True) bot = _FakeBot(admin=True)
for i in range(20): for i in range(20):
_save(bot, f"#test:mon{i}", {"name": f"mon{i}", "channel": "#test"}) _save(bot, f"#test:mon{i}", {"name": f"mon{i}", "channel": "#test"})
@@ -684,7 +672,6 @@ class TestCmdAdd:
class TestCmdDel: class TestCmdDel:
def test_del_success(self): def test_del_success(self):
_clear()
bot = _FakeBot(admin=True) bot = _FakeBot(admin=True)
async def inner(): async def inner():
@@ -695,25 +682,22 @@ class TestCmdDel:
await cmd_pastemoni(bot, _msg("!pastemoni del todel")) await cmd_pastemoni(bot, _msg("!pastemoni del todel"))
assert "Removed 'todel'" in bot.replied[0] assert "Removed 'todel'" in bot.replied[0]
assert _load(bot, "#test:todel") is None assert _load(bot, "#test:todel") is None
assert "#test:todel" not in _pollers assert "#test:todel" not in _ps(bot)["pollers"]
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_del_requires_admin(self): def test_del_requires_admin(self):
_clear()
bot = _FakeBot(admin=False) bot = _FakeBot(admin=False)
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del test"))) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del test")))
assert "Permission denied" in bot.replied[0] assert "Permission denied" in bot.replied[0]
def test_del_nonexistent(self): def test_del_nonexistent(self):
_clear()
bot = _FakeBot(admin=True) bot = _FakeBot(admin=True)
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del nosuch"))) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del nosuch")))
assert "No monitor" in bot.replied[0] assert "No monitor" in bot.replied[0]
def test_del_no_name(self): def test_del_no_name(self):
_clear()
bot = _FakeBot(admin=True) bot = _FakeBot(admin=True)
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del"))) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del")))
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
@@ -721,13 +705,11 @@ class TestCmdDel:
class TestCmdList: class TestCmdList:
def test_list_empty(self): def test_list_empty(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni list"))) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni list")))
assert "No monitors" in bot.replied[0] assert "No monitors" in bot.replied[0]
def test_list_populated(self): def test_list_populated(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
_save(bot, "#test:leaks", { _save(bot, "#test:leaks", {
"name": "leaks", "channel": "#test", "keyword": "api_key", "name": "leaks", "channel": "#test", "keyword": "api_key",
@@ -743,7 +725,6 @@ class TestCmdList:
assert "creds" in bot.replied[0] assert "creds" in bot.replied[0]
def test_list_shows_errors(self): def test_list_shows_errors(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
_save(bot, "#test:broken", { _save(bot, "#test:broken", {
"name": "broken", "channel": "#test", "keyword": "test", "name": "broken", "channel": "#test", "keyword": "test",
@@ -754,13 +735,11 @@ class TestCmdList:
assert "1 errors" in bot.replied[0] assert "1 errors" in bot.replied[0]
def test_list_requires_channel(self): def test_list_requires_channel(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni list"))) asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni list")))
assert "Use this command in a channel" in bot.replied[0] assert "Use this command in a channel" in bot.replied[0]
def test_list_channel_isolation(self): def test_list_channel_isolation(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
_save(bot, "#test:mine", { _save(bot, "#test:mine", {
"name": "mine", "channel": "#test", "keyword": "test", "name": "mine", "channel": "#test", "keyword": "test",
@@ -777,7 +756,6 @@ class TestCmdList:
class TestCmdCheck: class TestCmdCheck:
def test_check_success(self): def test_check_success(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "chk", "channel": "#test", "keyword": "test", "name": "chk", "channel": "#test",
@@ -794,19 +772,16 @@ class TestCmdCheck:
asyncio.run(inner()) asyncio.run(inner())
def test_check_nonexistent(self): def test_check_nonexistent(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni check nope"))) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni check nope")))
assert "No monitor" in bot.replied[0] assert "No monitor" in bot.replied[0]
def test_check_requires_channel(self): def test_check_requires_channel(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni check test"))) asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni check test")))
assert "Use this command in a channel" in bot.replied[0] assert "Use this command in a channel" in bot.replied[0]
def test_check_shows_error(self): def test_check_shows_error(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "errchk", "channel": "#test", "keyword": "test", "name": "errchk", "channel": "#test",
@@ -824,7 +799,6 @@ class TestCmdCheck:
asyncio.run(inner()) asyncio.run(inner())
def test_check_announces_new_items(self): def test_check_announces_new_items(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "news", "channel": "#test", "keyword": "test", "name": "news", "channel": "#test",
@@ -845,7 +819,6 @@ class TestCmdCheck:
asyncio.run(inner()) asyncio.run(inner())
def test_check_no_name(self): def test_check_no_name(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni check"))) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni check")))
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
@@ -853,13 +826,11 @@ class TestCmdCheck:
class TestCmdUsage: class TestCmdUsage:
def test_no_args(self): def test_no_args(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni"))) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni")))
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
def test_unknown_subcommand(self): def test_unknown_subcommand(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni foobar"))) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni foobar")))
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
@@ -871,7 +842,6 @@ class TestCmdUsage:
class TestRestore: class TestRestore:
def test_pollers_rebuilt_from_state(self): def test_pollers_rebuilt_from_state(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "restored", "channel": "#test", "keyword": "test", "name": "restored", "channel": "#test",
@@ -882,15 +852,14 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:restored" in _pollers assert "#test:restored" in _ps(bot)["pollers"]
assert not _pollers["#test:restored"].done() assert not _ps(bot)["pollers"]["#test:restored"].done()
_stop_poller("#test:restored") _stop_poller(bot, "#test:restored")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_restore_skips_active(self): def test_restore_skips_active(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "active", "channel": "#test", "keyword": "test", "name": "active", "channel": "#test",
@@ -901,16 +870,15 @@ class TestRestore:
async def inner(): async def inner():
dummy = asyncio.create_task(asyncio.sleep(9999)) dummy = asyncio.create_task(asyncio.sleep(9999))
_pollers["#test:active"] = dummy _ps(bot)["pollers"]["#test:active"] = dummy
_restore(bot) _restore(bot)
assert _pollers["#test:active"] is dummy assert _ps(bot)["pollers"]["#test:active"] is dummy
dummy.cancel() dummy.cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_restore_replaces_done_task(self): def test_restore_replaces_done_task(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "done", "channel": "#test", "keyword": "test", "name": "done", "channel": "#test",
@@ -922,29 +890,27 @@ class TestRestore:
async def inner(): async def inner():
done_task = asyncio.create_task(asyncio.sleep(0)) done_task = asyncio.create_task(asyncio.sleep(0))
await done_task await done_task
_pollers["#test:done"] = done_task _ps(bot)["pollers"]["#test:done"] = done_task
_restore(bot) _restore(bot)
new_task = _pollers["#test:done"] new_task = _ps(bot)["pollers"]["#test:done"]
assert new_task is not done_task assert new_task is not done_task
assert not new_task.done() assert not new_task.done()
_stop_poller("#test:done") _stop_poller(bot, "#test:done")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_restore_skips_bad_json(self): def test_restore_skips_bad_json(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
bot.state.set("pastemoni", "#test:bad", "not json{{{") bot.state.set("pastemoni", "#test:bad", "not json{{{")
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:bad" not in _pollers assert "#test:bad" not in _ps(bot)["pollers"]
asyncio.run(inner()) asyncio.run(inner())
def test_on_connect_calls_restore(self): def test_on_connect_calls_restore(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "conn", "channel": "#test", "keyword": "test", "name": "conn", "channel": "#test",
@@ -956,8 +922,8 @@ class TestRestore:
async def inner(): async def inner():
msg = _msg("", target="botname") msg = _msg("", target="botname")
await on_connect(bot, msg) await on_connect(bot, msg)
assert "#test:conn" in _pollers assert "#test:conn" in _ps(bot)["pollers"]
_stop_poller("#test:conn") _stop_poller(bot, "#test:conn")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -969,7 +935,6 @@ class TestRestore:
class TestPollerManagement: class TestPollerManagement:
def test_start_and_stop(self): def test_start_and_stop(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "mgmt", "channel": "#test", "keyword": "test", "name": "mgmt", "channel": "#test",
@@ -978,21 +943,20 @@ class TestPollerManagement:
} }
key = "#test:mgmt" key = "#test:mgmt"
_save(bot, key, data) _save(bot, key, data)
_monitors[key] = data _ps(bot)["monitors"][key] = data
async def inner(): async def inner():
_start_poller(bot, key) _start_poller(bot, key)
assert key in _pollers assert key in _ps(bot)["pollers"]
assert not _pollers[key].done() assert not _ps(bot)["pollers"][key].done()
_stop_poller(key) _stop_poller(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
assert key not in _pollers assert key not in _ps(bot)["pollers"]
assert key not in _monitors assert key not in _ps(bot)["monitors"]
asyncio.run(inner()) asyncio.run(inner())
def test_start_idempotent(self): def test_start_idempotent(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
data = { data = {
"keyword": "test", "name": "idem", "channel": "#test", "keyword": "test", "name": "idem", "channel": "#test",
@@ -1001,18 +965,18 @@ class TestPollerManagement:
} }
key = "#test:idem" key = "#test:idem"
_save(bot, key, data) _save(bot, key, data)
_monitors[key] = data _ps(bot)["monitors"][key] = data
async def inner(): async def inner():
_start_poller(bot, key) _start_poller(bot, key)
first = _pollers[key] first = _ps(bot)["pollers"][key]
_start_poller(bot, key) _start_poller(bot, key)
assert _pollers[key] is first assert _ps(bot)["pollers"][key] is first
_stop_poller(key) _stop_poller(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_stop_nonexistent(self): def test_stop_nonexistent(self):
_clear() bot = _FakeBot()
_stop_poller("#test:nonexistent") _stop_poller(bot, "#test:nonexistent")

View File

@@ -36,6 +36,20 @@ class TestDecorators:
assert handler._derp_event == "PRIVMSG" 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): def test_command_decorator_admin(self):
@command("secret", help="admin only", admin=True) @command("secret", help="admin only", admin=True)
async def handler(bot, msg): async def handler(bot, msg):
@@ -208,6 +222,46 @@ class TestRegistry:
assert registry.commands["secret"].admin is True assert registry.commands["secret"].admin is True
assert registry.commands["public"].admin is False 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): def test_load_plugin_stores_path(self, tmp_path: Path):
plugin_file = tmp_path / "pathed.py" plugin_file = tmp_path / "pathed.py"
plugin_file.write_text(textwrap.dedent("""\ plugin_file.write_text(textwrap.dedent("""\
@@ -384,7 +438,7 @@ class TestPrefixMatch:
async def _noop(bot, msg): async def _noop(bot, msg):
pass pass
registry.register_command(name, _noop, plugin="test") registry.register_command(name, _noop, plugin="test")
return Bot(config, registry) return Bot("test", config, registry)
def test_exact_match(self): def test_exact_match(self):
bot = self._make_bot(["ping", "pong", "plugins"]) bot = self._make_bot(["ping", "pong", "plugins"])
@@ -438,7 +492,7 @@ class TestIsAdmin:
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins", "bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins",
"admins": admins or []}, "admins": admins or []},
} }
bot = Bot(config, PluginRegistry()) bot = Bot("test", config, PluginRegistry())
if opers: if opers:
bot._opers = opers bot._opers = opers
return bot return bot
@@ -565,7 +619,7 @@ def _make_test_bot() -> Bot:
"nick": "test", "user": "test", "realname": "test"}, "nick": "test", "user": "test", "realname": "test"},
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"}, "bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
} }
bot = Bot(config, PluginRegistry()) bot = Bot("test", config, PluginRegistry())
bot.conn = _FakeConnection() # type: ignore[assignment] bot.conn = _FakeConnection() # type: ignore[assignment]
return bot return bot
@@ -637,7 +691,7 @@ class TestChannelFilter:
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"}, "bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
"channels": channels_cfg or {}, "channels": channels_cfg or {},
} }
return Bot(config, PluginRegistry()) return Bot("test", config, PluginRegistry())
def test_core_always_allowed(self): def test_core_always_allowed(self):
bot = self._make_bot({"#locked": {"plugins": ["core"]}}) bot = self._make_bot({"#locked": {"plugins": ["core"]}})
@@ -677,6 +731,71 @@ class TestChannelFilter:
assert bot._plugin_allowed("encode", "&local") is False 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: class TestSplitUtf8:
"""Test UTF-8 safe message splitting.""" """Test UTF-8 safe message splitting."""

View File

@@ -19,8 +19,6 @@ sys.modules[_spec.name] = _mod
_spec.loader.exec_module(_mod) _spec.loader.exec_module(_mod)
from plugins.remind import ( # noqa: E402 from plugins.remind import ( # noqa: E402
_by_user,
_calendar,
_cleanup, _cleanup,
_delete_saved, _delete_saved,
_format_duration, _format_duration,
@@ -30,9 +28,9 @@ from plugins.remind import ( # noqa: E402
_parse_date, _parse_date,
_parse_duration, _parse_duration,
_parse_time, _parse_time,
_ps,
_remind_once, _remind_once,
_remind_repeat, _remind_repeat,
_reminders,
_restore, _restore,
_save, _save,
_schedule_at, _schedule_at,
@@ -74,6 +72,7 @@ class _FakeBot:
self.replied: list[str] = [] self.replied: list[str] = []
self.config: dict = {"bot": {"timezone": tz}} self.config: dict = {"bot": {"timezone": tz}}
self.state = _FakeState() self.state = _FakeState()
self._pstate: dict = {}
async def send(self, target: str, text: str) -> None: async def send(self, target: str, text: str) -> None:
self.sent.append((target, text)) self.sent.append((target, text))
@@ -98,15 +97,18 @@ def _pm(text: str, nick: str = "alice") -> Message:
) )
def _clear() -> None: def _clear(bot=None) -> None:
"""Reset global module state between tests.""" """Reset per-bot plugin state between tests."""
for entry in _reminders.values(): if bot is None:
return
ps = _ps(bot)
for entry in ps["reminders"].values():
task = entry[0] task = entry[0]
if task is not None and not task.done(): if task is not None and not task.done():
task.cancel() task.cancel()
_reminders.clear() ps["reminders"].clear()
_by_user.clear() ps["by_user"].clear()
_calendar.clear() ps["calendar"].clear()
async def _run_cmd(bot, msg): async def _run_cmd(bot, msg):
@@ -120,7 +122,7 @@ async def _run_cmd_and_cleanup(bot, msg):
"""Run cmd_remind, yield, then cancel all spawned tasks.""" """Run cmd_remind, yield, then cancel all spawned tasks."""
await cmd_remind(bot, msg) await cmd_remind(bot, msg)
await asyncio.sleep(0) await asyncio.sleep(0)
for entry in list(_reminders.values()): for entry in list(_ps(bot)["reminders"].values()):
if entry[0] is not None and not entry[0].done(): if entry[0] is not None and not entry[0].done():
entry[0].cancel() entry[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -229,47 +231,51 @@ class TestMakeId:
class TestCleanup: class TestCleanup:
def test_removes_from_both_structures(self): def test_removes_from_both_structures(self):
_clear() bot = _FakeBot()
_reminders["abc123"] = (None, "#ch", "alice", "label", "12:00", False) ps = _ps(bot)
_by_user[("#ch", "alice")] = ["abc123"] ps["reminders"]["abc123"] = (None, "#ch", "alice", "label", "12:00", False)
ps["by_user"][("#ch", "alice")] = ["abc123"]
_cleanup("abc123", "#ch", "alice") _cleanup(bot, "abc123", "#ch", "alice")
assert "abc123" not in _reminders assert "abc123" not in ps["reminders"]
assert ("#ch", "alice") not in _by_user assert ("#ch", "alice") not in ps["by_user"]
def test_removes_single_entry_from_multi(self): def test_removes_single_entry_from_multi(self):
_clear() bot = _FakeBot()
_reminders["aaa"] = (None, "#ch", "alice", "", "12:00", False) ps = _ps(bot)
_reminders["bbb"] = (None, "#ch", "alice", "", "12:00", False) ps["reminders"]["aaa"] = (None, "#ch", "alice", "", "12:00", False)
_by_user[("#ch", "alice")] = ["aaa", "bbb"] ps["reminders"]["bbb"] = (None, "#ch", "alice", "", "12:00", False)
ps["by_user"][("#ch", "alice")] = ["aaa", "bbb"]
_cleanup("aaa", "#ch", "alice") _cleanup(bot, "aaa", "#ch", "alice")
assert "aaa" not in _reminders assert "aaa" not in ps["reminders"]
assert _by_user[("#ch", "alice")] == ["bbb"] assert ps["by_user"][("#ch", "alice")] == ["bbb"]
def test_missing_rid_no_error(self): def test_missing_rid_no_error(self):
_clear() bot = _FakeBot()
_cleanup("nonexistent", "#ch", "alice") _cleanup(bot, "nonexistent", "#ch", "alice")
def test_missing_user_key_no_error(self): def test_missing_user_key_no_error(self):
_clear() bot = _FakeBot()
_reminders["abc"] = (None, "#ch", "alice", "", "12:00", False) ps = _ps(bot)
ps["reminders"]["abc"] = (None, "#ch", "alice", "", "12:00", False)
_cleanup("abc", "#ch", "bob") # different nick, user key absent _cleanup(bot, "abc", "#ch", "bob") # different nick, user key absent
assert "abc" not in _reminders assert "abc" not in ps["reminders"]
def test_clears_calendar_set(self): def test_clears_calendar_set(self):
_clear() bot = _FakeBot()
_reminders["cal01"] = (None, "#ch", "alice", "", "12:00", False) ps = _ps(bot)
_by_user[("#ch", "alice")] = ["cal01"] ps["reminders"]["cal01"] = (None, "#ch", "alice", "", "12:00", False)
_calendar.add("cal01") ps["by_user"][("#ch", "alice")] = ["cal01"]
ps["calendar"].add("cal01")
_cleanup("cal01", "#ch", "alice") _cleanup(bot, "cal01", "#ch", "alice")
assert "cal01" not in _calendar assert "cal01" not in ps["calendar"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -278,16 +284,16 @@ class TestCleanup:
class TestRemindOnce: class TestRemindOnce:
def test_fires_metadata_and_label(self): def test_fires_metadata_and_label(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
ps = _ps(bot)
async def inner(): async def inner():
rid = "once01" rid = "once01"
task = asyncio.create_task( task = asyncio.create_task(
_remind_once(bot, rid, "#ch", "alice", "check oven", 0, "12:00:00 UTC"), _remind_once(bot, rid, "#ch", "alice", "check oven", 0, "12:00:00 UTC"),
) )
_reminders[rid] = (task, "#ch", "alice", "check oven", "12:00:00 UTC", False) ps["reminders"][rid] = (task, "#ch", "alice", "check oven", "12:00:00 UTC", False)
_by_user[("#ch", "alice")] = [rid] ps["by_user"][("#ch", "alice")] = [rid]
await task await task
asyncio.run(inner()) asyncio.run(inner())
@@ -295,42 +301,42 @@ class TestRemindOnce:
assert "alice: reminder #once01" in bot.sent[0][1] assert "alice: reminder #once01" in bot.sent[0][1]
assert "12:00:00 UTC" in bot.sent[0][1] assert "12:00:00 UTC" in bot.sent[0][1]
assert bot.sent[1] == ("#ch", "check oven") assert bot.sent[1] == ("#ch", "check oven")
assert "once01" not in _reminders assert "once01" not in _ps(bot)["reminders"]
def test_empty_label_sends_one_line(self): def test_empty_label_sends_one_line(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
ps = _ps(bot)
async def inner(): async def inner():
rid = "once02" rid = "once02"
task = asyncio.create_task( task = asyncio.create_task(
_remind_once(bot, rid, "#ch", "bob", "", 0, "12:00:00 UTC"), _remind_once(bot, rid, "#ch", "bob", "", 0, "12:00:00 UTC"),
) )
_reminders[rid] = (task, "#ch", "bob", "", "12:00:00 UTC", False) ps["reminders"][rid] = (task, "#ch", "bob", "", "12:00:00 UTC", False)
_by_user[("#ch", "bob")] = [rid] ps["by_user"][("#ch", "bob")] = [rid]
await task await task
asyncio.run(inner()) asyncio.run(inner())
assert len(bot.sent) == 1 assert len(bot.sent) == 1
def test_cancellation_cleans_up(self): def test_cancellation_cleans_up(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
ps = _ps(bot)
async def inner(): async def inner():
rid = "once03" rid = "once03"
task = asyncio.create_task( task = asyncio.create_task(
_remind_once(bot, rid, "#ch", "alice", "text", 9999, "12:00:00 UTC"), _remind_once(bot, rid, "#ch", "alice", "text", 9999, "12:00:00 UTC"),
) )
_reminders[rid] = (task, "#ch", "alice", "text", "12:00:00 UTC", False) ps["reminders"][rid] = (task, "#ch", "alice", "text", "12:00:00 UTC", False)
_by_user[("#ch", "alice")] = [rid] ps["by_user"][("#ch", "alice")] = [rid]
await asyncio.sleep(0) await asyncio.sleep(0)
task.cancel() task.cancel()
await asyncio.gather(task, return_exceptions=True) await asyncio.gather(task, return_exceptions=True)
asyncio.run(inner()) asyncio.run(inner())
assert len(bot.sent) == 0 assert len(bot.sent) == 0
assert "once03" not in _reminders assert "once03" not in _ps(bot)["reminders"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -339,16 +345,16 @@ class TestRemindOnce:
class TestRemindRepeat: class TestRemindRepeat:
def test_fires_at_least_once(self): def test_fires_at_least_once(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
ps = _ps(bot)
async def inner(): async def inner():
rid = "rpt01" rid = "rpt01"
task = asyncio.create_task( task = asyncio.create_task(
_remind_repeat(bot, rid, "#ch", "alice", "hydrate", 0, "12:00:00 UTC"), _remind_repeat(bot, rid, "#ch", "alice", "hydrate", 0, "12:00:00 UTC"),
) )
_reminders[rid] = (task, "#ch", "alice", "hydrate", "12:00:00 UTC", True) ps["reminders"][rid] = (task, "#ch", "alice", "hydrate", "12:00:00 UTC", True)
_by_user[("#ch", "alice")] = [rid] ps["by_user"][("#ch", "alice")] = [rid]
for _ in range(5): for _ in range(5):
await asyncio.sleep(0) await asyncio.sleep(0)
task.cancel() task.cancel()
@@ -357,26 +363,26 @@ class TestRemindRepeat:
asyncio.run(inner()) asyncio.run(inner())
assert len(bot.sent) >= 2 # at least one fire (metadata + label) assert len(bot.sent) >= 2 # at least one fire (metadata + label)
assert any("rpt01" in t for _, t in bot.sent) assert any("rpt01" in t for _, t in bot.sent)
assert "rpt01" not in _reminders assert "rpt01" not in _ps(bot)["reminders"]
def test_cancellation_cleans_up(self): def test_cancellation_cleans_up(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
ps = _ps(bot)
async def inner(): async def inner():
rid = "rpt02" rid = "rpt02"
task = asyncio.create_task( task = asyncio.create_task(
_remind_repeat(bot, rid, "#ch", "bob", "stretch", 9999, "12:00:00 UTC"), _remind_repeat(bot, rid, "#ch", "bob", "stretch", 9999, "12:00:00 UTC"),
) )
_reminders[rid] = (task, "#ch", "bob", "stretch", "12:00:00 UTC", True) ps["reminders"][rid] = (task, "#ch", "bob", "stretch", "12:00:00 UTC", True)
_by_user[("#ch", "bob")] = [rid] ps["by_user"][("#ch", "bob")] = [rid]
await asyncio.sleep(0) await asyncio.sleep(0)
task.cancel() task.cancel()
await asyncio.gather(task, return_exceptions=True) await asyncio.gather(task, return_exceptions=True)
asyncio.run(inner()) asyncio.run(inner())
assert len(bot.sent) == 0 assert len(bot.sent) == 0
assert "rpt02" not in _reminders assert "rpt02" not in _ps(bot)["reminders"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -385,25 +391,21 @@ class TestRemindRepeat:
class TestCmdRemindUsage: class TestCmdRemindUsage:
def test_no_args_shows_usage(self): def test_no_args_shows_usage(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind"))) asyncio.run(cmd_remind(bot, _msg("!remind")))
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
def test_invalid_duration(self): def test_invalid_duration(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind xyz some text"))) asyncio.run(cmd_remind(bot, _msg("!remind xyz some text")))
assert "Invalid duration" in bot.replied[0] assert "Invalid duration" in bot.replied[0]
def test_every_no_args(self): def test_every_no_args(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind every"))) asyncio.run(cmd_remind(bot, _msg("!remind every")))
assert "Invalid duration" in bot.replied[0] assert "Invalid duration" in bot.replied[0]
def test_every_invalid_duration(self): def test_every_invalid_duration(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind every abc"))) asyncio.run(cmd_remind(bot, _msg("!remind every abc")))
assert "Invalid duration" in bot.replied[0] assert "Invalid duration" in bot.replied[0]
@@ -415,7 +417,6 @@ class TestCmdRemindUsage:
class TestCmdRemindOneshot: class TestCmdRemindOneshot:
def test_creates_with_duration(self): def test_creates_with_duration(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -427,7 +428,6 @@ class TestCmdRemindOneshot:
assert "#" in bot.replied[0] assert "#" in bot.replied[0]
def test_no_label(self): def test_no_label(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -437,27 +437,26 @@ class TestCmdRemindOneshot:
assert "set (5m)" in bot.replied[0] assert "set (5m)" in bot.replied[0]
def test_stores_in_tracking(self): def test_stores_in_tracking(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
await _run_cmd(bot, _msg("!remind 9999s task")) await _run_cmd(bot, _msg("!remind 9999s task"))
assert len(_reminders) == 1 ps = _ps(bot)
entry = next(iter(_reminders.values())) assert len(ps["reminders"]) == 1
entry = next(iter(ps["reminders"].values()))
assert entry[1] == "#test" # target assert entry[1] == "#test" # target
assert entry[2] == "alice" # nick assert entry[2] == "alice" # nick
assert entry[3] == "task" # label assert entry[3] == "task" # label
assert entry[5] is False # not repeating assert entry[5] is False # not repeating
assert ("#test", "alice") in _by_user assert ("#test", "alice") in ps["by_user"]
# cleanup # cleanup
for e in _reminders.values(): for e in ps["reminders"].values():
e[0].cancel() e[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_days_duration(self): def test_days_duration(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -473,7 +472,6 @@ class TestCmdRemindOneshot:
class TestCmdRemindRepeat: class TestCmdRemindRepeat:
def test_creates_repeating(self): def test_creates_repeating(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -484,21 +482,20 @@ class TestCmdRemindRepeat:
assert "every 1h" in bot.replied[0] assert "every 1h" in bot.replied[0]
def test_repeating_stores_flag(self): def test_repeating_stores_flag(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
await _run_cmd(bot, _msg("!remind every 30m stretch")) await _run_cmd(bot, _msg("!remind every 30m stretch"))
entry = next(iter(_reminders.values())) ps = _ps(bot)
entry = next(iter(ps["reminders"].values()))
assert entry[5] is True # repeating flag assert entry[5] is True # repeating flag
for e in _reminders.values(): for e in ps["reminders"].values():
e[0].cancel() e[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_repeating_no_label(self): def test_repeating_no_label(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -514,20 +511,18 @@ class TestCmdRemindRepeat:
class TestCmdRemindList: class TestCmdRemindList:
def test_empty_list(self): def test_empty_list(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind list"))) asyncio.run(cmd_remind(bot, _msg("!remind list")))
assert "No active reminders" in bot.replied[0] assert "No active reminders" in bot.replied[0]
def test_shows_active(self): def test_shows_active(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
await _run_cmd(bot, _msg("!remind 9999s task")) await _run_cmd(bot, _msg("!remind 9999s task"))
bot.replied.clear() bot.replied.clear()
await cmd_remind(bot, _msg("!remind list")) await cmd_remind(bot, _msg("!remind list"))
for e in _reminders.values(): for e in _ps(bot)["reminders"].values():
e[0].cancel() e[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -536,14 +531,13 @@ class TestCmdRemindList:
assert "#" in bot.replied[0] assert "#" in bot.replied[0]
def test_shows_repeat_tag(self): def test_shows_repeat_tag(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
await _run_cmd(bot, _msg("!remind every 9999s task")) await _run_cmd(bot, _msg("!remind every 9999s task"))
bot.replied.clear() bot.replied.clear()
await cmd_remind(bot, _msg("!remind list")) await cmd_remind(bot, _msg("!remind list"))
for e in _reminders.values(): for e in _ps(bot)["reminders"].values():
e[0].cancel() e[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -561,7 +555,6 @@ class TestCmdRemindCancel:
return reply.split("#")[1].split(" ")[0] return reply.split("#")[1].split(" ")[0]
def test_cancel_valid(self): def test_cancel_valid(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -575,7 +568,6 @@ class TestCmdRemindCancel:
assert "Cancelled" in bot.replied[0] assert "Cancelled" in bot.replied[0]
def test_cancel_with_hash_prefix(self): def test_cancel_with_hash_prefix(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -589,7 +581,6 @@ class TestCmdRemindCancel:
assert "Cancelled" in bot.replied[0] assert "Cancelled" in bot.replied[0]
def test_cancel_wrong_user(self): def test_cancel_wrong_user(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -597,7 +588,7 @@ class TestCmdRemindCancel:
rid = self._extract_rid(bot.replied[0]) rid = self._extract_rid(bot.replied[0])
bot.replied.clear() bot.replied.clear()
await cmd_remind(bot, _msg(f"!remind cancel {rid}", nick="eve")) await cmd_remind(bot, _msg(f"!remind cancel {rid}", nick="eve"))
for e in _reminders.values(): for e in _ps(bot)["reminders"].values():
e[0].cancel() e[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -605,13 +596,11 @@ class TestCmdRemindCancel:
assert "No active reminder" in bot.replied[0] assert "No active reminder" in bot.replied[0]
def test_cancel_nonexistent(self): def test_cancel_nonexistent(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind cancel ffffff"))) asyncio.run(cmd_remind(bot, _msg("!remind cancel ffffff")))
assert "No active reminder" in bot.replied[0] assert "No active reminder" in bot.replied[0]
def test_cancel_no_id(self): def test_cancel_no_id(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind cancel"))) asyncio.run(cmd_remind(bot, _msg("!remind cancel")))
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
@@ -623,28 +612,28 @@ class TestCmdRemindCancel:
class TestCmdRemindTarget: class TestCmdRemindTarget:
def test_channel_target(self): def test_channel_target(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
await _run_cmd(bot, _msg("!remind 9999s task", target="#ops")) await _run_cmd(bot, _msg("!remind 9999s task", target="#ops"))
entry = next(iter(_reminders.values())) ps = _ps(bot)
entry = next(iter(ps["reminders"].values()))
assert entry[1] == "#ops" assert entry[1] == "#ops"
for e in _reminders.values(): for e in ps["reminders"].values():
e[0].cancel() e[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_pm_uses_nick(self): def test_pm_uses_nick(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
await _run_cmd(bot, _pm("!remind 9999s task")) await _run_cmd(bot, _pm("!remind 9999s task"))
entry = next(iter(_reminders.values())) ps = _ps(bot)
entry = next(iter(ps["reminders"].values()))
assert entry[1] == "alice" # nick, not "botname" assert entry[1] == "alice" # nick, not "botname"
for e in _reminders.values(): for e in ps["reminders"].values():
e[0].cancel() e[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -788,7 +777,6 @@ class TestCmdRemindAt:
return reply.split("#")[1].split(" ")[0] return reply.split("#")[1].split(" ")[0]
def test_valid_future_date(self): def test_valid_future_date(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d") future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
@@ -801,7 +789,6 @@ class TestCmdRemindAt:
assert "deploy release" not in bot.replied[0] # label not in confirmation assert "deploy release" not in bot.replied[0] # label not in confirmation
def test_past_date_rejected(self): def test_past_date_rejected(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -811,7 +798,6 @@ class TestCmdRemindAt:
assert "past" in bot.replied[0].lower() assert "past" in bot.replied[0].lower()
def test_default_time_noon(self): def test_default_time_noon(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d") future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
@@ -822,7 +808,6 @@ class TestCmdRemindAt:
assert "12:00" in bot.replied[0] assert "12:00" in bot.replied[0]
def test_with_explicit_time(self): def test_with_explicit_time(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d") future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
@@ -833,7 +818,6 @@ class TestCmdRemindAt:
assert "14:30" in bot.replied[0] assert "14:30" in bot.replied[0]
def test_stores_in_state(self): def test_stores_in_state(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d") future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
@@ -846,15 +830,15 @@ class TestCmdRemindAt:
assert data["type"] == "at" assert data["type"] == "at"
assert data["nick"] == "alice" assert data["nick"] == "alice"
assert data["label"] == "persist me" assert data["label"] == "persist me"
assert rid in _calendar ps = _ps(bot)
for e in _reminders.values(): assert rid in ps["calendar"]
for e in ps["reminders"].values():
e[0].cancel() e[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_invalid_date_format(self): def test_invalid_date_format(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -864,7 +848,6 @@ class TestCmdRemindAt:
assert "Invalid date" in bot.replied[0] assert "Invalid date" in bot.replied[0]
def test_no_args_shows_usage(self): def test_no_args_shows_usage(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -883,7 +866,6 @@ class TestCmdRemindYearly:
return reply.split("#")[1].split(" ")[0] return reply.split("#")[1].split(" ")[0]
def test_valid_creation(self): def test_valid_creation(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -894,7 +876,6 @@ class TestCmdRemindYearly:
assert "yearly 06-15" in bot.replied[0] assert "yearly 06-15" in bot.replied[0]
def test_invalid_date(self): def test_invalid_date(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -904,7 +885,6 @@ class TestCmdRemindYearly:
assert "Invalid date" in bot.replied[0] assert "Invalid date" in bot.replied[0]
def test_invalid_day(self): def test_invalid_day(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -914,7 +894,6 @@ class TestCmdRemindYearly:
assert "Invalid date" in bot.replied[0] assert "Invalid date" in bot.replied[0]
def test_stores_in_state(self): def test_stores_in_state(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -926,15 +905,15 @@ class TestCmdRemindYearly:
assert data["type"] == "yearly" assert data["type"] == "yearly"
assert data["month_day"] == "02-14" assert data["month_day"] == "02-14"
assert data["nick"] == "alice" assert data["nick"] == "alice"
assert rid in _calendar ps = _ps(bot)
for e in _reminders.values(): assert rid in ps["calendar"]
for e in ps["reminders"].values():
e[0].cancel() e[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_with_explicit_time(self): def test_with_explicit_time(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -944,7 +923,6 @@ class TestCmdRemindYearly:
assert "yearly 12-25" in bot.replied[0] assert "yearly 12-25" in bot.replied[0]
def test_no_args_shows_usage(self): def test_no_args_shows_usage(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -954,7 +932,6 @@ class TestCmdRemindYearly:
assert "Usage:" in bot.replied[0] assert "Usage:" in bot.replied[0]
def test_leap_day_allowed(self): def test_leap_day_allowed(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -997,7 +974,6 @@ class TestCalendarPersistence:
assert bot.state.get("remind", "abc123") is None assert bot.state.get("remind", "abc123") is None
def test_cancel_deletes_from_state(self): def test_cancel_deletes_from_state(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d") future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
@@ -1013,7 +989,6 @@ class TestCalendarPersistence:
asyncio.run(inner()) asyncio.run(inner())
def test_at_fire_deletes_from_state(self): def test_at_fire_deletes_from_state(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
async def inner(): async def inner():
@@ -1026,12 +1001,13 @@ class TestCalendarPersistence:
"created": "12:00:00 UTC", "created": "12:00:00 UTC",
} }
_save(bot, rid, data) _save(bot, rid, data)
_calendar.add(rid) ps = _ps(bot)
ps["calendar"].add(rid)
task = asyncio.create_task( task = asyncio.create_task(
_schedule_at(bot, rid, "#ch", "alice", "fire now", fire_dt, "12:00:00 UTC"), _schedule_at(bot, rid, "#ch", "alice", "fire now", fire_dt, "12:00:00 UTC"),
) )
_reminders[rid] = (task, "#ch", "alice", "fire now", "12:00:00 UTC", False) ps["reminders"][rid] = (task, "#ch", "alice", "fire now", "12:00:00 UTC", False)
_by_user[("#ch", "alice")] = [rid] ps["by_user"][("#ch", "alice")] = [rid]
await task await task
assert bot.state.get("remind", rid) is None assert bot.state.get("remind", rid) is None
@@ -1044,7 +1020,6 @@ class TestCalendarPersistence:
class TestRestore: class TestRestore:
def test_restores_at_from_state(self): def test_restores_at_from_state(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
fire_dt = datetime.now(timezone.utc) + timedelta(hours=1) fire_dt = datetime.now(timezone.utc) + timedelta(hours=1)
data = { data = {
@@ -1057,9 +1032,10 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "rest01" in _reminders ps = _ps(bot)
assert "rest01" in _calendar assert "rest01" in ps["reminders"]
entry = _reminders["rest01"] assert "rest01" in ps["calendar"]
entry = ps["reminders"]["rest01"]
assert not entry[0].done() assert not entry[0].done()
entry[0].cancel() entry[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -1067,7 +1043,6 @@ class TestRestore:
asyncio.run(inner()) asyncio.run(inner())
def test_restores_yearly_from_state(self): def test_restores_yearly_from_state(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
fire_dt = datetime.now(timezone.utc) + timedelta(days=180) fire_dt = datetime.now(timezone.utc) + timedelta(days=180)
data = { data = {
@@ -1080,9 +1055,10 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "rest02" in _reminders ps = _ps(bot)
assert "rest02" in _calendar assert "rest02" in ps["reminders"]
entry = _reminders["rest02"] assert "rest02" in ps["calendar"]
entry = ps["reminders"]["rest02"]
assert not entry[0].done() assert not entry[0].done()
entry[0].cancel() entry[0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -1090,7 +1066,6 @@ class TestRestore:
asyncio.run(inner()) asyncio.run(inner())
def test_skips_active_rids(self): def test_skips_active_rids(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
fire_dt = datetime.now(timezone.utc) + timedelta(hours=1) fire_dt = datetime.now(timezone.utc) + timedelta(hours=1)
data = { data = {
@@ -1103,18 +1078,18 @@ class TestRestore:
async def inner(): async def inner():
# Pre-populate with an active task # Pre-populate with an active task
ps = _ps(bot)
dummy = asyncio.create_task(asyncio.sleep(9999)) dummy = asyncio.create_task(asyncio.sleep(9999))
_reminders["skip01"] = (dummy, "#ch", "alice", "active", "12:00:00 UTC", False) ps["reminders"]["skip01"] = (dummy, "#ch", "alice", "active", "12:00:00 UTC", False)
_restore(bot) _restore(bot)
# Should still be the dummy task, not replaced # Should still be the dummy task, not replaced
assert _reminders["skip01"][0] is dummy assert ps["reminders"]["skip01"][0] is dummy
dummy.cancel() dummy.cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_past_at_cleaned_up(self): def test_past_at_cleaned_up(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
past_dt = datetime.now(timezone.utc) - timedelta(hours=1) past_dt = datetime.now(timezone.utc) - timedelta(hours=1)
data = { data = {
@@ -1128,13 +1103,12 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
# Past at-reminder should be deleted from state, not scheduled # Past at-reminder should be deleted from state, not scheduled
assert "past01" not in _reminders assert "past01" not in _ps(bot)["reminders"]
assert bot.state.get("remind", "past01") is None assert bot.state.get("remind", "past01") is None
asyncio.run(inner()) asyncio.run(inner())
def test_past_yearly_recalculated(self): def test_past_yearly_recalculated(self):
_clear()
bot = _FakeBot() bot = _FakeBot()
past_dt = datetime.now(timezone.utc) - timedelta(days=30) past_dt = datetime.now(timezone.utc) - timedelta(days=30)
data = { data = {
@@ -1147,13 +1121,14 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "yearly01" in _reminders ps = _ps(bot)
assert "yearly01" in ps["reminders"]
# fire_iso should have been updated to a future date # fire_iso should have been updated to a future date
raw = bot.state.get("remind", "yearly01") raw = bot.state.get("remind", "yearly01")
updated = json.loads(raw) updated = json.loads(raw)
new_fire = datetime.fromisoformat(updated["fire_iso"]) new_fire = datetime.fromisoformat(updated["fire_iso"])
assert new_fire > datetime.now(timezone.utc) assert new_fire > datetime.now(timezone.utc)
_reminders["yearly01"][0].cancel() ps["reminders"]["yearly01"][0].cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())

View File

@@ -21,15 +21,13 @@ from plugins.rss import ( # noqa: E402
_MAX_SEEN, _MAX_SEEN,
_delete, _delete,
_derive_name, _derive_name,
_errors,
_feeds,
_load, _load,
_parse_atom, _parse_atom,
_parse_date, _parse_date,
_parse_feed, _parse_feed,
_parse_rss, _parse_rss,
_poll_once, _poll_once,
_pollers, _ps,
_restore, _restore,
_save, _save,
_start_poller, _start_poller,
@@ -158,6 +156,7 @@ class _FakeBot:
self.sent: list[tuple[str, str]] = [] self.sent: list[tuple[str, str]] = []
self.replied: list[str] = [] self.replied: list[str] = []
self.state = _FakeState() self.state = _FakeState()
self._pstate: dict = {}
self._admin = admin self._admin = admin
async def send(self, target: str, text: str) -> None: async def send(self, target: str, text: str) -> None:
@@ -190,13 +189,7 @@ def _pm(text: str, nick: str = "alice") -> Message:
def _clear() -> None: def _clear() -> None:
"""Reset module-level state between tests.""" """No-op -- state is per-bot now, each _FakeBot starts fresh."""
for task in _pollers.values():
if task and not task.done():
task.cancel()
_pollers.clear()
_feeds.clear()
_errors.clear()
def _fake_fetch_ok(url, etag="", last_modified=""): def _fake_fetch_ok(url, etag="", last_modified=""):
@@ -512,8 +505,8 @@ class TestCmdRssAdd:
assert data["name"] == "testfeed" assert data["name"] == "testfeed"
assert data["channel"] == "#test" assert data["channel"] == "#test"
assert len(data["seen"]) == 3 assert len(data["seen"]) == 3
assert "#test:testfeed" in _pollers assert "#test:testfeed" in _ps(bot)["pollers"]
_stop_poller("#test:testfeed") _stop_poller(bot, "#test:testfeed")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -527,7 +520,7 @@ class TestCmdRssAdd:
await cmd_rss(bot, _msg("!rss add https://hnrss.org/newest")) await cmd_rss(bot, _msg("!rss add https://hnrss.org/newest"))
await asyncio.sleep(0) await asyncio.sleep(0)
assert "Subscribed 'hnrss'" in bot.replied[0] assert "Subscribed 'hnrss'" in bot.replied[0]
_stop_poller("#test:hnrss") _stop_poller(bot, "#test:hnrss")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -562,7 +555,7 @@ class TestCmdRssAdd:
with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok):
await cmd_rss(bot, _msg("!rss add https://other.com/feed myfeed")) await cmd_rss(bot, _msg("!rss add https://other.com/feed myfeed"))
assert "already exists" in bot.replied[0] assert "already exists" in bot.replied[0]
_stop_poller("#test:myfeed") _stop_poller(bot, "#test:myfeed")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -608,7 +601,7 @@ class TestCmdRssAdd:
await asyncio.sleep(0) await asyncio.sleep(0)
data = _load(bot, "#test:test") data = _load(bot, "#test:test")
assert data["url"] == "https://example.com/feed" assert data["url"] == "https://example.com/feed"
_stop_poller("#test:test") _stop_poller(bot, "#test:test")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -631,7 +624,7 @@ class TestCmdRssDel:
await cmd_rss(bot, _msg("!rss del delfeed")) await cmd_rss(bot, _msg("!rss del delfeed"))
assert "Unsubscribed 'delfeed'" in bot.replied[0] assert "Unsubscribed 'delfeed'" in bot.replied[0]
assert _load(bot, "#test:delfeed") is None assert _load(bot, "#test:delfeed") is None
assert "#test:delfeed" not in _pollers assert "#test:delfeed" not in _ps(bot)["pollers"]
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -819,7 +812,7 @@ class TestPollOnce:
} }
key = "#test:f304" key = "#test:f304"
_save(bot, key, data) _save(bot, key, data)
_feeds[key] = data _ps(bot)["feeds"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_304): with patch.object(_mod, "_fetch_feed", _fake_fetch_304):
@@ -839,13 +832,13 @@ class TestPollOnce:
} }
key = "#test:ferr" key = "#test:ferr"
_save(bot, key, data) _save(bot, key, data)
_feeds[key] = data _ps(bot)["feeds"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_error): with patch.object(_mod, "_fetch_feed", _fake_fetch_error):
await _poll_once(bot, key) await _poll_once(bot, key)
await _poll_once(bot, key) await _poll_once(bot, key)
assert _errors[key] == 2 assert _ps(bot)["errors"][key] == 2
updated = _load(bot, key) updated = _load(bot, key)
assert updated["last_error"] == "Connection refused" assert updated["last_error"] == "Connection refused"
@@ -880,7 +873,7 @@ class TestPollOnce:
} }
key = "#test:big" key = "#test:big"
_save(bot, key, data) _save(bot, key, data)
_feeds[key] = data _ps(bot)["feeds"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_fetch_feed", fake_big): with patch.object(_mod, "_fetch_feed", fake_big):
@@ -902,7 +895,7 @@ class TestPollOnce:
} }
key = "#test:quiet" key = "#test:quiet"
_save(bot, key, data) _save(bot, key, data)
_feeds[key] = data _ps(bot)["feeds"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok):
@@ -926,7 +919,7 @@ class TestPollOnce:
} }
key = "#test:etag" key = "#test:etag"
_save(bot, key, data) _save(bot, key, data)
_feeds[key] = data _ps(bot)["feeds"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok):
@@ -954,10 +947,10 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:restored" in _pollers assert "#test:restored" in _ps(bot)["pollers"]
task = _pollers["#test:restored"] task = _ps(bot)["pollers"]["#test:restored"]
assert not task.done() assert not task.done()
_stop_poller("#test:restored") _stop_poller(bot, "#test:restored")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -975,10 +968,10 @@ class TestRestore:
async def inner(): async def inner():
# Pre-place an active task # Pre-place an active task
dummy = asyncio.create_task(asyncio.sleep(9999)) dummy = asyncio.create_task(asyncio.sleep(9999))
_pollers["#test:active"] = dummy _ps(bot)["pollers"]["#test:active"] = dummy
_restore(bot) _restore(bot)
# Should not have replaced it # Should not have replaced it
assert _pollers["#test:active"] is dummy assert _ps(bot)["pollers"]["#test:active"] is dummy
dummy.cancel() dummy.cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -998,13 +991,13 @@ class TestRestore:
# Place a completed task # Place a completed task
done_task = asyncio.create_task(asyncio.sleep(0)) done_task = asyncio.create_task(asyncio.sleep(0))
await done_task await done_task
_pollers["#test:done"] = done_task _ps(bot)["pollers"]["#test:done"] = done_task
_restore(bot) _restore(bot)
# Should have been replaced # Should have been replaced
new_task = _pollers["#test:done"] new_task = _ps(bot)["pollers"]["#test:done"]
assert new_task is not done_task assert new_task is not done_task
assert not new_task.done() assert not new_task.done()
_stop_poller("#test:done") _stop_poller(bot, "#test:done")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -1016,7 +1009,7 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:bad" not in _pollers assert "#test:bad" not in _ps(bot)["pollers"]
asyncio.run(inner()) asyncio.run(inner())
@@ -1033,8 +1026,8 @@ class TestRestore:
async def inner(): async def inner():
msg = _msg("", target="botname") msg = _msg("", target="botname")
await on_connect(bot, msg) await on_connect(bot, msg)
assert "#test:conn" in _pollers assert "#test:conn" in _ps(bot)["pollers"]
_stop_poller("#test:conn") _stop_poller(bot, "#test:conn")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -1055,16 +1048,17 @@ class TestPollerManagement:
} }
key = "#test:mgmt" key = "#test:mgmt"
_save(bot, key, data) _save(bot, key, data)
_feeds[key] = data _ps(bot)["feeds"][key] = data
async def inner(): async def inner():
_start_poller(bot, key) _start_poller(bot, key)
assert key in _pollers ps = _ps(bot)
assert not _pollers[key].done() assert key in ps["pollers"]
_stop_poller(key) assert not ps["pollers"][key].done()
_stop_poller(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
assert key not in _pollers assert key not in ps["pollers"]
assert key not in _feeds assert key not in ps["feeds"]
asyncio.run(inner()) asyncio.run(inner())
@@ -1078,22 +1072,24 @@ class TestPollerManagement:
} }
key = "#test:idem" key = "#test:idem"
_save(bot, key, data) _save(bot, key, data)
_feeds[key] = data _ps(bot)["feeds"][key] = data
async def inner(): async def inner():
_start_poller(bot, key) _start_poller(bot, key)
first = _pollers[key] ps = _ps(bot)
first = ps["pollers"][key]
_start_poller(bot, key) _start_poller(bot, key)
assert _pollers[key] is first assert ps["pollers"][key] is first
_stop_poller(key) _stop_poller(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_stop_nonexistent(self): def test_stop_nonexistent(self):
_clear() _clear()
bot = _FakeBot()
# Should not raise # Should not raise
_stop_poller("#test:nonexistent") _stop_poller(bot, "#test:nonexistent")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

759
tests/test_teams.py Normal file
View File

@@ -0,0 +1,759 @@
"""Tests for the Microsoft Teams adapter."""
import asyncio
import base64
import hashlib
import hmac
import json
from derp.plugin import PluginRegistry
from derp.teams import (
_MAX_BODY,
TeamsBot,
TeamsMessage,
_build_teams_message,
_http_response,
_json_response,
_parse_activity,
_strip_mention,
_verify_hmac,
)
# -- Helpers -----------------------------------------------------------------
def _make_bot(secret="", admins=None, operators=None, trusted=None,
incoming_url=""):
"""Create a TeamsBot with test config."""
config = {
"teams": {
"enabled": True,
"bot_name": "derp",
"bind": "127.0.0.1",
"port": 0,
"webhook_secret": secret,
"incoming_webhook_url": incoming_url,
"admins": admins or [],
"operators": operators or [],
"trusted": trusted or [],
},
"bot": {
"prefix": "!",
"paste_threshold": 4,
"plugins_dir": "plugins",
"rate_limit": 2.0,
"rate_burst": 5,
},
}
registry = PluginRegistry()
return TeamsBot("teams-test", config, registry)
def _activity(text="hello", nick="Alice", aad_id="aad-123",
conv_id="conv-456", msg_type="message"):
"""Build a minimal Teams Activity dict."""
return {
"type": msg_type,
"from": {"name": nick, "aadObjectId": aad_id},
"conversation": {"id": conv_id},
"text": text,
}
def _teams_msg(text="!ping", nick="Alice", aad_id="aad-123",
target="conv-456"):
"""Create a TeamsMessage for command testing."""
return TeamsMessage(
raw={}, nick=nick, prefix=aad_id, text=text, target=target,
params=[target, text],
)
def _sign_teams(secret: str, body: bytes) -> str:
"""Generate Teams HMAC-SHA256 Authorization header value."""
key = base64.b64decode(secret)
sig = base64.b64encode(
hmac.new(key, body, hashlib.sha256).digest(),
).decode("ascii")
return f"HMAC {sig}"
class _FakeReader:
"""Mock asyncio.StreamReader from raw HTTP bytes."""
def __init__(self, data: bytes) -> None:
self._data = data
self._pos = 0
async def readline(self) -> bytes:
start = self._pos
idx = self._data.find(b"\n", start)
if idx == -1:
self._pos = len(self._data)
return self._data[start:]
self._pos = idx + 1
return self._data[start:self._pos]
async def readexactly(self, n: int) -> bytes:
chunk = self._data[self._pos:self._pos + n]
self._pos += n
return chunk
class _FakeWriter:
"""Mock asyncio.StreamWriter that captures output."""
def __init__(self) -> None:
self.data = b""
self._closed = False
def write(self, data: bytes) -> None:
self.data += data
def close(self) -> None:
self._closed = True
async def wait_closed(self) -> None:
pass
def _build_request(method: str, path: str, body: bytes,
headers: dict[str, str] | None = None) -> bytes:
"""Build raw HTTP request bytes."""
hdrs = headers or {}
if "Content-Length" not in hdrs:
hdrs["Content-Length"] = str(len(body))
lines = [f"{method} {path} HTTP/1.1"]
for k, v in hdrs.items():
lines.append(f"{k}: {v}")
lines.append("")
lines.append("")
return "\r\n".join(lines).encode("utf-8") + body
# -- Test helpers for registering commands -----------------------------------
async def _echo_handler(bot, msg):
"""Simple command handler that echoes text."""
args = msg.text.split(None, 1)
reply = args[1] if len(args) > 1 else "no args"
await bot.reply(msg, reply)
async def _admin_handler(bot, msg):
"""Admin-only command handler."""
await bot.reply(msg, "admin action done")
# ---------------------------------------------------------------------------
# TestTeamsMessage
# ---------------------------------------------------------------------------
class TestTeamsMessage:
def test_defaults(self):
msg = TeamsMessage(raw={}, nick=None, prefix=None, text=None,
target=None)
assert msg.is_channel is True
assert msg.command == "PRIVMSG"
assert msg.params == []
assert msg.tags == {}
assert msg._replies == []
def test_custom_values(self):
msg = TeamsMessage(
raw={"type": "message"}, nick="Alice", prefix="aad-123",
text="hello", target="conv-456", is_channel=True,
command="PRIVMSG", params=["conv-456", "hello"],
tags={"key": "val"},
)
assert msg.nick == "Alice"
assert msg.prefix == "aad-123"
assert msg.text == "hello"
assert msg.target == "conv-456"
assert msg.tags == {"key": "val"}
def test_duck_type_compat(self):
"""TeamsMessage has the same attribute names as IRC Message."""
msg = _teams_msg()
attrs = ["raw", "nick", "prefix", "text", "target",
"is_channel", "command", "params", "tags"]
for attr in attrs:
assert hasattr(msg, attr), f"missing attribute: {attr}"
def test_replies_buffer(self):
msg = _teams_msg()
assert msg._replies == []
msg._replies.append("pong")
msg._replies.append("line2")
assert len(msg._replies) == 2
def test_raw_dict(self):
activity = {"type": "message", "id": "123"}
msg = TeamsMessage(raw=activity, nick=None, prefix=None,
text=None, target=None)
assert msg.raw is activity
def test_prefix_is_aad_id(self):
msg = _teams_msg(aad_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
assert msg.prefix == "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# ---------------------------------------------------------------------------
# TestVerifyHmac
# ---------------------------------------------------------------------------
class TestVerifyHmac:
def test_valid_signature(self):
# base64-encoded secret
secret = base64.b64encode(b"test-secret").decode()
body = b'{"type":"message","text":"hello"}'
auth = _sign_teams(secret, body)
assert _verify_hmac(secret, body, auth) is True
def test_invalid_signature(self):
secret = base64.b64encode(b"test-secret").decode()
body = b'{"type":"message","text":"hello"}'
assert _verify_hmac(secret, body, "HMAC badsignature") is False
def test_missing_hmac_prefix(self):
secret = base64.b64encode(b"test-secret").decode()
body = b'{"text":"hello"}'
# No "HMAC " prefix
key = base64.b64decode(secret)
sig = base64.b64encode(
hmac.new(key, body, hashlib.sha256).digest()
).decode()
assert _verify_hmac(secret, body, sig) is False
def test_empty_secret_allows_all(self):
assert _verify_hmac("", b"any body", "") is True
assert _verify_hmac("", b"any body", "HMAC whatever") is True
def test_invalid_base64_secret(self):
assert _verify_hmac("not-valid-b64!!!", b"body", "HMAC x") is False
# ---------------------------------------------------------------------------
# TestStripMention
# ---------------------------------------------------------------------------
class TestStripMention:
def test_strip_at_mention(self):
assert _strip_mention("<at>derp</at> !help", "derp") == "!help"
def test_strip_with_extra_spaces(self):
assert _strip_mention("<at>derp</at> !ping", "derp") == "!ping"
def test_no_mention(self):
assert _strip_mention("!help", "derp") == "!help"
def test_multiple_mentions(self):
text = "<at>derp</at> hello <at>other</at> world"
assert _strip_mention(text, "derp") == "hello world"
def test_empty_text(self):
assert _strip_mention("", "derp") == ""
def test_mention_only(self):
assert _strip_mention("<at>derp</at>", "derp") == ""
# ---------------------------------------------------------------------------
# TestParseActivity
# ---------------------------------------------------------------------------
class TestParseActivity:
def test_valid_activity(self):
body = json.dumps({"type": "message", "text": "hello"}).encode()
result = _parse_activity(body)
assert result == {"type": "message", "text": "hello"}
def test_invalid_json(self):
assert _parse_activity(b"not json") is None
def test_not_a_dict(self):
assert _parse_activity(b'["array"]') is None
def test_empty_body(self):
assert _parse_activity(b"") is None
def test_unicode_error(self):
assert _parse_activity(b"\xff\xfe") is None
# ---------------------------------------------------------------------------
# TestBuildTeamsMessage
# ---------------------------------------------------------------------------
class TestBuildTeamsMessage:
def test_basic_message(self):
activity = _activity(text="<at>derp</at> !ping")
msg = _build_teams_message(activity, "derp")
assert msg.nick == "Alice"
assert msg.prefix == "aad-123"
assert msg.text == "!ping"
assert msg.target == "conv-456"
assert msg.is_channel is True
assert msg.command == "PRIVMSG"
def test_strips_mention(self):
activity = _activity(text="<at>Bot</at> !help commands")
msg = _build_teams_message(activity, "Bot")
assert msg.text == "!help commands"
def test_missing_from(self):
activity = {"type": "message", "text": "hello",
"conversation": {"id": "conv"}}
msg = _build_teams_message(activity, "derp")
assert msg.nick is None
assert msg.prefix is None
def test_missing_conversation(self):
activity = {"type": "message", "text": "hello",
"from": {"name": "Alice", "aadObjectId": "aad"}}
msg = _build_teams_message(activity, "derp")
assert msg.target is None
def test_raw_preserved(self):
activity = _activity()
msg = _build_teams_message(activity, "derp")
assert msg.raw is activity
def test_params_populated(self):
activity = _activity(text="<at>derp</at> !test arg")
msg = _build_teams_message(activity, "derp")
assert msg.params[0] == "conv-456"
assert msg.params[1] == "!test arg"
# ---------------------------------------------------------------------------
# TestTeamsBotReply
# ---------------------------------------------------------------------------
class TestTeamsBotReply:
def test_reply_appends(self):
bot = _make_bot()
msg = _teams_msg()
asyncio.run(bot.reply(msg, "pong"))
assert msg._replies == ["pong"]
def test_multi_reply(self):
bot = _make_bot()
msg = _teams_msg()
async def _run():
await bot.reply(msg, "line 1")
await bot.reply(msg, "line 2")
await bot.reply(msg, "line 3")
asyncio.run(_run())
assert msg._replies == ["line 1", "line 2", "line 3"]
def test_long_reply_under_threshold(self):
bot = _make_bot()
msg = _teams_msg()
lines = ["a", "b", "c"]
asyncio.run(bot.long_reply(msg, lines))
assert msg._replies == ["a", "b", "c"]
def test_long_reply_over_threshold_no_paste(self):
"""Over threshold with no FlaskPaste sends all lines."""
bot = _make_bot()
msg = _teams_msg()
lines = ["a", "b", "c", "d", "e", "f"] # 6 > threshold of 4
asyncio.run(bot.long_reply(msg, lines))
assert msg._replies == lines
def test_long_reply_empty(self):
bot = _make_bot()
msg = _teams_msg()
asyncio.run(bot.long_reply(msg, []))
assert msg._replies == []
def test_action_format(self):
"""action() maps to italic text via send()."""
bot = _make_bot(incoming_url="http://example.com/hook")
# action sends to incoming webhook; without actual URL it logs debug
bot._incoming_url = ""
asyncio.run(bot.action("conv", "does a thing"))
# No incoming URL, so send() is a no-op (debug log)
def test_send_no_incoming_url(self):
"""send() is a no-op when no incoming_webhook_url is configured."""
bot = _make_bot()
# Should not raise
asyncio.run(bot.send("target", "text"))
# ---------------------------------------------------------------------------
# TestTeamsBotTier
# ---------------------------------------------------------------------------
class TestTeamsBotTier:
def test_admin_tier(self):
bot = _make_bot(admins=["aad-admin"])
msg = _teams_msg(aad_id="aad-admin")
assert bot._get_tier(msg) == "admin"
def test_oper_tier(self):
bot = _make_bot(operators=["aad-oper"])
msg = _teams_msg(aad_id="aad-oper")
assert bot._get_tier(msg) == "oper"
def test_trusted_tier(self):
bot = _make_bot(trusted=["aad-trusted"])
msg = _teams_msg(aad_id="aad-trusted")
assert bot._get_tier(msg) == "trusted"
def test_user_tier_default(self):
bot = _make_bot()
msg = _teams_msg(aad_id="aad-unknown")
assert bot._get_tier(msg) == "user"
def test_no_prefix(self):
bot = _make_bot(admins=["aad-admin"])
msg = _teams_msg(aad_id=None)
msg.prefix = None
assert bot._get_tier(msg) == "user"
def test_is_admin_true(self):
bot = _make_bot(admins=["aad-admin"])
msg = _teams_msg(aad_id="aad-admin")
assert bot._is_admin(msg) is True
def test_is_admin_false(self):
bot = _make_bot()
msg = _teams_msg(aad_id="aad-nobody")
assert bot._is_admin(msg) is False
def test_priority_order(self):
"""Admin takes priority over oper and trusted."""
bot = _make_bot(admins=["aad-x"], operators=["aad-x"],
trusted=["aad-x"])
msg = _teams_msg(aad_id="aad-x")
assert bot._get_tier(msg) == "admin"
# ---------------------------------------------------------------------------
# TestTeamsBotDispatch
# ---------------------------------------------------------------------------
class TestTeamsBotDispatch:
def test_dispatch_known_command(self):
bot = _make_bot()
bot.registry.register_command(
"echo", _echo_handler, help="echo", plugin="test")
msg = _teams_msg(text="!echo world")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == ["world"]
def test_dispatch_unknown_command(self):
bot = _make_bot()
msg = _teams_msg(text="!nonexistent")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == []
def test_dispatch_no_prefix(self):
bot = _make_bot()
msg = _teams_msg(text="just a message")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == []
def test_dispatch_empty_text(self):
bot = _make_bot()
msg = _teams_msg(text="")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == []
def test_dispatch_none_text(self):
bot = _make_bot()
msg = _teams_msg(text=None)
msg.text = None
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == []
def test_dispatch_ambiguous(self):
bot = _make_bot()
bot.registry.register_command(
"ping", _echo_handler, plugin="test")
bot.registry.register_command(
"plugins", _echo_handler, plugin="test")
msg = _teams_msg(text="!p")
asyncio.run(bot._dispatch_command(msg))
assert len(msg._replies) == 1
assert "Ambiguous" in msg._replies[0]
def test_dispatch_tier_denied(self):
bot = _make_bot()
bot.registry.register_command(
"secret", _admin_handler, plugin="test", tier="admin")
msg = _teams_msg(text="!secret", aad_id="aad-nobody")
asyncio.run(bot._dispatch_command(msg))
assert len(msg._replies) == 1
assert "Permission denied" in msg._replies[0]
def test_dispatch_tier_allowed(self):
bot = _make_bot(admins=["aad-admin"])
bot.registry.register_command(
"secret", _admin_handler, plugin="test", tier="admin")
msg = _teams_msg(text="!secret", aad_id="aad-admin")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == ["admin action done"]
def test_dispatch_prefix_match(self):
"""Unambiguous prefix resolves to the full command."""
bot = _make_bot()
bot.registry.register_command(
"echo", _echo_handler, plugin="test")
msg = _teams_msg(text="!ec hello")
asyncio.run(bot._dispatch_command(msg))
assert msg._replies == ["hello"]
# ---------------------------------------------------------------------------
# TestTeamsBotNoOps
# ---------------------------------------------------------------------------
class TestTeamsBotNoOps:
def test_join_noop(self):
bot = _make_bot()
asyncio.run(bot.join("#channel"))
def test_part_noop(self):
bot = _make_bot()
asyncio.run(bot.part("#channel", "reason"))
def test_kick_noop(self):
bot = _make_bot()
asyncio.run(bot.kick("#channel", "nick", "reason"))
def test_mode_noop(self):
bot = _make_bot()
asyncio.run(bot.mode("#channel", "+o", "nick"))
def test_set_topic_noop(self):
bot = _make_bot()
asyncio.run(bot.set_topic("#channel", "new topic"))
def test_quit_stops(self):
bot = _make_bot()
bot._running = True
asyncio.run(bot.quit())
assert bot._running is False
# ---------------------------------------------------------------------------
# TestHTTPHandler
# ---------------------------------------------------------------------------
class TestHTTPHandler:
def _b64_secret(self):
return base64.b64encode(b"test-secret-key").decode()
def test_valid_post_with_reply(self):
secret = self._b64_secret()
bot = _make_bot(secret=secret)
bot.registry.register_command(
"ping", _echo_handler, plugin="test")
activity = _activity(text="<at>derp</at> !ping")
body = json.dumps(activity).encode()
auth = _sign_teams(secret, body)
raw = _build_request("POST", "/api/messages", body, {
"Content-Length": str(len(body)),
"Content-Type": "application/json",
"Authorization": auth,
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"200 OK" in writer.data
resp_body = writer.data.split(b"\r\n\r\n", 1)[1]
data = json.loads(resp_body)
assert data["type"] == "message"
def test_get_405(self):
bot = _make_bot()
raw = _build_request("GET", "/api/messages", b"")
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"405" in writer.data
def test_wrong_path_404(self):
bot = _make_bot()
raw = _build_request("POST", "/wrong/path", b"")
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"404" in writer.data
def test_bad_signature_401(self):
secret = self._b64_secret()
bot = _make_bot(secret=secret)
body = json.dumps(_activity()).encode()
raw = _build_request("POST", "/api/messages", body, {
"Content-Length": str(len(body)),
"Authorization": "HMAC badsignature",
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"401" in writer.data
def test_bad_json_400(self):
bot = _make_bot()
body = b"not json at all"
raw = _build_request("POST", "/api/messages", body, {
"Content-Length": str(len(body)),
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"400" in writer.data
assert b"invalid JSON" in writer.data
def test_non_message_activity(self):
bot = _make_bot()
body = json.dumps({"type": "conversationUpdate"}).encode()
raw = _build_request("POST", "/api/messages", body, {
"Content-Length": str(len(body)),
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"200 OK" in writer.data
resp_body = writer.data.split(b"\r\n\r\n", 1)[1]
data = json.loads(resp_body)
assert data["text"] == ""
def test_body_too_large_413(self):
bot = _make_bot()
raw = _build_request("POST", "/api/messages", b"", {
"Content-Length": str(_MAX_BODY + 1),
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"413" in writer.data
def test_command_dispatch_full_cycle(self):
"""Full request lifecycle: receive, dispatch, reply."""
bot = _make_bot()
async def _pong(b, m):
await b.reply(m, "pong")
bot.registry.register_command("ping", _pong, plugin="test")
activity = _activity(text="<at>derp</at> !ping")
body = json.dumps(activity).encode()
raw = _build_request("POST", "/api/messages", body, {
"Content-Length": str(len(body)),
"Content-Type": "application/json",
})
reader = _FakeReader(raw)
writer = _FakeWriter()
asyncio.run(bot._handle_connection(reader, writer))
assert b"200 OK" in writer.data
resp_body = writer.data.split(b"\r\n\r\n", 1)[1]
data = json.loads(resp_body)
assert data["text"] == "pong"
# ---------------------------------------------------------------------------
# TestHttpResponse
# ---------------------------------------------------------------------------
class TestHttpResponse:
def test_plain_200(self):
resp = _http_response(200, "OK", "sent")
assert b"200 OK" in resp
assert b"sent" in resp
assert b"text/plain" in resp
def test_json_response(self):
resp = _json_response(200, "OK", {"type": "message", "text": "hi"})
assert b"200 OK" in resp
assert b"application/json" in resp
body = resp.split(b"\r\n\r\n", 1)[1]
data = json.loads(body)
assert data["text"] == "hi"
def test_404_response(self):
resp = _http_response(404, "Not Found")
assert b"404 Not Found" in resp
assert b"Content-Length: 0" in resp
# ---------------------------------------------------------------------------
# TestTeamsBotPluginManagement
# ---------------------------------------------------------------------------
class TestTeamsBotPluginManagement:
def test_load_plugin_not_found(self):
bot = _make_bot()
ok, msg = bot.load_plugin("nonexistent_xyz")
assert ok is False
assert "not found" in msg
def test_load_plugin_already_loaded(self):
bot = _make_bot()
bot.registry._modules["test"] = object()
ok, msg = bot.load_plugin("test")
assert ok is False
assert "already loaded" in msg
def test_unload_core_refused(self):
bot = _make_bot()
ok, msg = bot.unload_plugin("core")
assert ok is False
assert "cannot unload core" in msg
def test_unload_not_loaded(self):
bot = _make_bot()
ok, msg = bot.unload_plugin("nonexistent")
assert ok is False
assert "not loaded" in msg
def test_reload_delegates(self):
bot = _make_bot()
ok, msg = bot.reload_plugin("nonexistent")
assert ok is False
assert "not loaded" in msg
class TestTeamsBotConfig:
def test_proxy_default_true(self):
bot = _make_bot()
assert bot._proxy is True
def test_proxy_disabled(self):
config = {
"teams": {
"enabled": True,
"bot_name": "derp",
"bind": "127.0.0.1",
"port": 8081,
"webhook_secret": "",
"incoming_webhook_url": "",
"proxy": False,
"admins": [],
"operators": [],
"trusted": [],
},
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
}
bot = TeamsBot("test", config, PluginRegistry())
assert bot._proxy is False

786
tests/test_telegram.py Normal file
View File

@@ -0,0 +1,786 @@
"""Tests for the Telegram adapter."""
import asyncio
from unittest.mock import MagicMock, patch
from derp.plugin import PluginRegistry
from derp.telegram import (
_MAX_MSG_LEN,
TelegramBot,
TelegramMessage,
_build_telegram_message,
_split_message,
_strip_bot_suffix,
)
# -- Helpers -----------------------------------------------------------------
def _make_bot(token="test:token", admins=None, operators=None, trusted=None,
prefix=None):
"""Create a TelegramBot with test config."""
config = {
"telegram": {
"enabled": True,
"bot_token": token,
"poll_timeout": 1,
"admins": admins or [],
"operators": operators or [],
"trusted": trusted or [],
},
"bot": {
"prefix": prefix or "!",
"paste_threshold": 4,
"plugins_dir": "plugins",
"rate_limit": 2.0,
"rate_burst": 5,
},
}
registry = PluginRegistry()
bot = TelegramBot("tg-test", config, registry)
bot.nick = "TestBot"
bot._bot_username = "testbot"
return bot
def _update(text="!ping", nick="Alice", user_id=123,
chat_id=-456, chat_type="group", username="alice"):
"""Build a minimal Telegram Update dict."""
return {
"update_id": 1000,
"message": {
"message_id": 1,
"from": {
"id": user_id,
"first_name": nick,
"username": username,
},
"chat": {
"id": chat_id,
"type": chat_type,
},
"text": text,
},
}
def _tg_msg(text="!ping", nick="Alice", user_id="123",
target="-456", is_channel=True):
"""Create a TelegramMessage for command testing."""
return TelegramMessage(
raw={}, nick=nick, prefix=user_id, text=text, target=target,
is_channel=is_channel,
params=[target, text],
)
# -- Test helpers for registering commands -----------------------------------
async def _echo_handler(bot, msg):
"""Simple command handler that echoes text."""
args = msg.text.split(None, 1)
reply = args[1] if len(args) > 1 else "no args"
await bot.reply(msg, reply)
async def _admin_handler(bot, msg):
"""Admin-only command handler."""
await bot.reply(msg, "admin action done")
# ---------------------------------------------------------------------------
# TestTelegramMessage
# ---------------------------------------------------------------------------
class TestTelegramMessage:
def test_defaults(self):
msg = TelegramMessage(raw={}, nick=None, prefix=None, text=None,
target=None)
assert msg.is_channel is True
assert msg.command == "PRIVMSG"
assert msg.params == []
assert msg.tags == {}
def test_custom_values(self):
msg = TelegramMessage(
raw={"update_id": 1}, nick="Alice", prefix="123",
text="hello", target="-456", is_channel=True,
command="PRIVMSG", params=["-456", "hello"],
tags={"key": "val"},
)
assert msg.nick == "Alice"
assert msg.prefix == "123"
assert msg.text == "hello"
assert msg.target == "-456"
assert msg.tags == {"key": "val"}
def test_duck_type_compat(self):
"""TelegramMessage has the same attribute names as IRC Message."""
msg = _tg_msg()
attrs = ["raw", "nick", "prefix", "text", "target",
"is_channel", "command", "params", "tags"]
for attr in attrs:
assert hasattr(msg, attr), f"missing attribute: {attr}"
def test_dm_message(self):
msg = _tg_msg(is_channel=False)
assert msg.is_channel is False
def test_prefix_is_user_id(self):
msg = _tg_msg(user_id="999888777")
assert msg.prefix == "999888777"
# ---------------------------------------------------------------------------
# TestBuildTelegramMessage
# ---------------------------------------------------------------------------
class TestBuildTelegramMessage:
def test_group_message(self):
update = _update(text="!ping", chat_type="group")
msg = _build_telegram_message(update, "testbot")
assert msg is not None
assert msg.nick == "Alice"
assert msg.prefix == "123"
assert msg.text == "!ping"
assert msg.target == "-456"
assert msg.is_channel is True
def test_dm_message(self):
update = _update(chat_type="private", chat_id=789)
msg = _build_telegram_message(update, "testbot")
assert msg is not None
assert msg.is_channel is False
assert msg.target == "789"
def test_supergroup_message(self):
update = _update(chat_type="supergroup")
msg = _build_telegram_message(update, "testbot")
assert msg is not None
assert msg.is_channel is True
def test_missing_from(self):
update = {"update_id": 1, "message": {
"message_id": 1,
"chat": {"id": -456, "type": "group"},
"text": "hello",
}}
msg = _build_telegram_message(update, "testbot")
assert msg is not None
assert msg.nick is None
assert msg.prefix is None
def test_missing_text(self):
update = {"update_id": 1, "message": {
"message_id": 1,
"from": {"id": 123, "first_name": "Alice"},
"chat": {"id": -456, "type": "group"},
}}
msg = _build_telegram_message(update, "testbot")
assert msg is not None
assert msg.text == ""
def test_no_message(self):
update = {"update_id": 1}
msg = _build_telegram_message(update, "testbot")
assert msg is None
def test_strips_bot_suffix(self):
update = _update(text="!help@testbot")
msg = _build_telegram_message(update, "testbot")
assert msg is not None
assert msg.text == "!help"
def test_edited_message(self):
update = {
"update_id": 1,
"edited_message": {
"message_id": 1,
"from": {"id": 123, "first_name": "Alice"},
"chat": {"id": -456, "type": "group"},
"text": "!ping",
},
}
msg = _build_telegram_message(update, "testbot")
assert msg is not None
assert msg.text == "!ping"
def test_raw_preserved(self):
update = _update()
msg = _build_telegram_message(update, "testbot")
assert msg.raw is update
def test_username_fallback_for_nick(self):
update = _update()
# Remove first_name, keep username
update["message"]["from"] = {"id": 123, "username": "alice_u"}
msg = _build_telegram_message(update, "testbot")
assert msg.nick == "alice_u"
# ---------------------------------------------------------------------------
# TestStripBotSuffix
# ---------------------------------------------------------------------------
class TestStripBotSuffix:
def test_strip_command(self):
assert _strip_bot_suffix("!help@mybot", "mybot") == "!help"
def test_strip_with_args(self):
assert _strip_bot_suffix("!echo@mybot hello", "mybot") == "!echo hello"
def test_no_suffix(self):
assert _strip_bot_suffix("!help", "mybot") == "!help"
def test_case_insensitive(self):
assert _strip_bot_suffix("!help@MyBot", "mybot") == "!help"
def test_empty_username(self):
assert _strip_bot_suffix("!help@bot", "") == "!help@bot"
def test_plain_text(self):
assert _strip_bot_suffix("hello world", "mybot") == "hello world"
# ---------------------------------------------------------------------------
# TestTelegramBotReply
# ---------------------------------------------------------------------------
class TestTelegramBotReply:
def test_send_calls_api(self):
bot = _make_bot()
with patch.object(bot, "_api_call", return_value={"ok": True}):
asyncio.run(bot.send("-456", "hello"))
bot._api_call.assert_called_once_with(
"sendMessage", {"chat_id": "-456", "text": "hello"})
def test_reply_sends_to_target(self):
bot = _make_bot()
msg = _tg_msg(target="-456")
sent: list[tuple[str, str]] = []
async def _fake_send(target, text):
sent.append((target, text))
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot.reply(msg, "pong"))
assert sent == [("-456", "pong")]
def test_reply_no_target(self):
bot = _make_bot()
msg = _tg_msg(target=None)
msg.target = None
with patch.object(bot, "send") as mock_send:
asyncio.run(bot.reply(msg, "pong"))
mock_send.assert_not_called()
def test_long_reply_under_threshold(self):
bot = _make_bot()
msg = _tg_msg()
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot.long_reply(msg, ["a", "b", "c"]))
assert sent == ["a", "b", "c"]
def test_long_reply_over_threshold_no_paste(self):
bot = _make_bot()
msg = _tg_msg()
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot.long_reply(msg, ["a", "b", "c", "d", "e"]))
assert sent == ["a", "b", "c", "d", "e"]
def test_long_reply_empty(self):
bot = _make_bot()
msg = _tg_msg()
with patch.object(bot, "send") as mock_send:
asyncio.run(bot.long_reply(msg, []))
mock_send.assert_not_called()
def test_action_format(self):
bot = _make_bot()
sent: list[tuple[str, str]] = []
async def _fake_send(target, text):
sent.append((target, text))
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot.action("-456", "does a thing"))
assert sent == [("-456", "_does a thing_")]
# ---------------------------------------------------------------------------
# TestTelegramBotDispatch
# ---------------------------------------------------------------------------
class TestTelegramBotDispatch:
def test_dispatch_known_command(self):
bot = _make_bot()
bot.registry.register_command(
"echo", _echo_handler, help="echo", plugin="test")
msg = _tg_msg(text="!echo world")
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot._dispatch_command(msg))
assert sent == ["world"]
def test_dispatch_unknown_command(self):
bot = _make_bot()
msg = _tg_msg(text="!nonexistent")
with patch.object(bot, "send") as mock_send:
asyncio.run(bot._dispatch_command(msg))
mock_send.assert_not_called()
def test_dispatch_no_prefix(self):
bot = _make_bot()
msg = _tg_msg(text="just a message")
with patch.object(bot, "send") as mock_send:
asyncio.run(bot._dispatch_command(msg))
mock_send.assert_not_called()
def test_dispatch_empty_text(self):
bot = _make_bot()
msg = _tg_msg(text="")
with patch.object(bot, "send") as mock_send:
asyncio.run(bot._dispatch_command(msg))
mock_send.assert_not_called()
def test_dispatch_none_text(self):
bot = _make_bot()
msg = _tg_msg()
msg.text = None
with patch.object(bot, "send") as mock_send:
asyncio.run(bot._dispatch_command(msg))
mock_send.assert_not_called()
def test_dispatch_ambiguous(self):
bot = _make_bot()
bot.registry.register_command("ping", _echo_handler, plugin="test")
bot.registry.register_command("plugins", _echo_handler, plugin="test")
msg = _tg_msg(text="!p")
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot._dispatch_command(msg))
assert len(sent) == 1
assert "Ambiguous" in sent[0]
def test_dispatch_tier_denied(self):
bot = _make_bot()
bot.registry.register_command(
"secret", _admin_handler, plugin="test", tier="admin")
msg = _tg_msg(text="!secret", user_id="999")
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot._dispatch_command(msg))
assert len(sent) == 1
assert "Permission denied" in sent[0]
def test_dispatch_tier_allowed(self):
bot = _make_bot(admins=[123])
bot.registry.register_command(
"secret", _admin_handler, plugin="test", tier="admin")
msg = _tg_msg(text="!secret", user_id="123")
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot._dispatch_command(msg))
assert sent == ["admin action done"]
def test_dispatch_prefix_match(self):
bot = _make_bot()
bot.registry.register_command("echo", _echo_handler, plugin="test")
msg = _tg_msg(text="!ec hello")
sent: list[str] = []
async def _fake_send(target, text):
sent.append(text)
with patch.object(bot, "send", side_effect=_fake_send):
asyncio.run(bot._dispatch_command(msg))
assert sent == ["hello"]
# ---------------------------------------------------------------------------
# TestTelegramBotTier
# ---------------------------------------------------------------------------
class TestTelegramBotTier:
def test_admin_tier(self):
bot = _make_bot(admins=[111])
msg = _tg_msg(user_id="111")
assert bot._get_tier(msg) == "admin"
def test_oper_tier(self):
bot = _make_bot(operators=[222])
msg = _tg_msg(user_id="222")
assert bot._get_tier(msg) == "oper"
def test_trusted_tier(self):
bot = _make_bot(trusted=[333])
msg = _tg_msg(user_id="333")
assert bot._get_tier(msg) == "trusted"
def test_user_tier_default(self):
bot = _make_bot()
msg = _tg_msg(user_id="999")
assert bot._get_tier(msg) == "user"
def test_no_prefix(self):
bot = _make_bot(admins=[111])
msg = _tg_msg()
msg.prefix = None
assert bot._get_tier(msg) == "user"
def test_is_admin_true(self):
bot = _make_bot(admins=[111])
msg = _tg_msg(user_id="111")
assert bot._is_admin(msg) is True
def test_is_admin_false(self):
bot = _make_bot()
msg = _tg_msg(user_id="999")
assert bot._is_admin(msg) is False
def test_priority_order(self):
"""Admin takes priority over oper and trusted."""
bot = _make_bot(admins=[111], operators=[111], trusted=[111])
msg = _tg_msg(user_id="111")
assert bot._get_tier(msg) == "admin"
# ---------------------------------------------------------------------------
# TestTelegramBotNoOps
# ---------------------------------------------------------------------------
class TestTelegramBotNoOps:
def test_join_noop(self):
bot = _make_bot()
asyncio.run(bot.join("#channel"))
def test_part_noop(self):
bot = _make_bot()
asyncio.run(bot.part("#channel", "reason"))
def test_kick_noop(self):
bot = _make_bot()
asyncio.run(bot.kick("#channel", "nick", "reason"))
def test_mode_noop(self):
bot = _make_bot()
asyncio.run(bot.mode("#channel", "+o", "nick"))
def test_set_topic_noop(self):
bot = _make_bot()
asyncio.run(bot.set_topic("#channel", "new topic"))
def test_quit_stops(self):
bot = _make_bot()
bot._running = True
asyncio.run(bot.quit())
assert bot._running is False
# ---------------------------------------------------------------------------
# TestTelegramBotPoll
# ---------------------------------------------------------------------------
class TestTelegramBotPoll:
def test_poll_updates_parses(self):
bot = _make_bot()
result = {
"ok": True,
"result": [
{"update_id": 100, "message": {
"message_id": 1,
"from": {"id": 123, "first_name": "Alice"},
"chat": {"id": -456, "type": "group"},
"text": "hello",
}},
],
}
with patch.object(bot, "_api_call", return_value=result):
updates = bot._poll_updates()
assert len(updates) == 1
assert bot._offset == 101
def test_poll_updates_empty(self):
bot = _make_bot()
with patch.object(bot, "_api_call",
return_value={"ok": True, "result": []}):
updates = bot._poll_updates()
assert updates == []
assert bot._offset == 0
def test_poll_updates_failed(self):
bot = _make_bot()
with patch.object(bot, "_api_call",
return_value={"ok": False, "description": "err"}):
updates = bot._poll_updates()
assert updates == []
def test_offset_advances(self):
bot = _make_bot()
result = {
"ok": True,
"result": [
{"update_id": 50, "message": {
"message_id": 1,
"from": {"id": 1, "first_name": "A"},
"chat": {"id": -1, "type": "group"},
"text": "a",
}},
{"update_id": 51, "message": {
"message_id": 2,
"from": {"id": 2, "first_name": "B"},
"chat": {"id": -2, "type": "group"},
"text": "b",
}},
],
}
with patch.object(bot, "_api_call", return_value=result):
bot._poll_updates()
assert bot._offset == 52
def test_start_getme_failure(self):
config = {
"telegram": {
"enabled": True, "bot_token": "t", "poll_timeout": 1,
"admins": [], "operators": [], "trusted": [],
},
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
}
bot = TelegramBot("tg-test", config, PluginRegistry())
with patch.object(bot, "_api_call",
return_value={"ok": False}):
asyncio.run(bot.start())
assert bot.nick == ""
def test_start_getme_exception(self):
config = {
"telegram": {
"enabled": True, "bot_token": "t", "poll_timeout": 1,
"admins": [], "operators": [], "trusted": [],
},
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
}
bot = TelegramBot("tg-test", config, PluginRegistry())
with patch.object(bot, "_api_call",
side_effect=Exception("network")):
asyncio.run(bot.start())
assert bot.nick == ""
# ---------------------------------------------------------------------------
# TestTelegramApiCall
# ---------------------------------------------------------------------------
class TestTelegramApiCall:
def test_api_url(self):
bot = _make_bot(token="123:ABC")
url = bot._api_url("getMe")
assert url == "https://api.telegram.org/bot123:ABC/getMe"
def test_api_call_get(self):
bot = _make_bot()
mock_resp = MagicMock()
mock_resp.read.return_value = b'{"ok": true, "result": {}}'
with patch("derp.telegram.http.urlopen", return_value=mock_resp):
result = bot._api_call("getMe")
assert result["ok"] is True
def test_api_call_post(self):
bot = _make_bot()
mock_resp = MagicMock()
mock_resp.read.return_value = b'{"ok": true, "result": {}}'
with patch("derp.telegram.http.urlopen", return_value=mock_resp):
result = bot._api_call("sendMessage", {"chat_id": "1", "text": "hi"})
assert result["ok"] is True
def test_split_long_message(self):
# Build a message that exceeds 4096 bytes
lines = [f"line {i}: {'x' * 100}" for i in range(50)]
text = "\n".join(lines)
chunks = _split_message(text)
assert len(chunks) > 1
for chunk in chunks:
assert len(chunk.encode("utf-8")) <= _MAX_MSG_LEN
def test_short_message_no_split(self):
chunks = _split_message("hello world")
assert chunks == ["hello world"]
def test_send_splits_long_text(self):
bot = _make_bot()
lines = [f"line {i}: {'x' * 100}" for i in range(50)]
text = "\n".join(lines)
calls: list[dict] = []
def _fake_api_call(method, payload=None):
if method == "sendMessage" and payload:
calls.append(payload)
return {"ok": True}
with patch.object(bot, "_api_call", side_effect=_fake_api_call):
asyncio.run(bot.send("-456", text))
assert len(calls) > 1
for call in calls:
assert len(call["text"].encode("utf-8")) <= _MAX_MSG_LEN
# ---------------------------------------------------------------------------
# TestPluginManagement
# ---------------------------------------------------------------------------
class TestPluginManagement:
def test_load_plugin_not_found(self):
bot = _make_bot()
ok, msg = bot.load_plugin("nonexistent_xyz")
assert ok is False
assert "not found" in msg
def test_load_plugin_already_loaded(self):
bot = _make_bot()
bot.registry._modules["test"] = object()
ok, msg = bot.load_plugin("test")
assert ok is False
assert "already loaded" in msg
def test_unload_core_refused(self):
bot = _make_bot()
ok, msg = bot.unload_plugin("core")
assert ok is False
assert "cannot unload core" in msg
def test_unload_not_loaded(self):
bot = _make_bot()
ok, msg = bot.unload_plugin("nonexistent")
assert ok is False
assert "not loaded" in msg
def test_reload_delegates(self):
bot = _make_bot()
ok, msg = bot.reload_plugin("nonexistent")
assert ok is False
assert "not loaded" in msg
# ---------------------------------------------------------------------------
# TestSplitMessage
# ---------------------------------------------------------------------------
class TestSplitMessage:
def test_short_text(self):
assert _split_message("hi") == ["hi"]
def test_exact_boundary(self):
text = "a" * 4096
result = _split_message(text)
assert len(result) == 1
def test_multi_line_split(self):
lines = ["line " + str(i) for i in range(1000)]
text = "\n".join(lines)
chunks = _split_message(text)
assert len(chunks) > 1
reassembled = "\n".join(chunks)
assert reassembled == text
def test_empty_text(self):
assert _split_message("") == [""]
# ---------------------------------------------------------------------------
# TestTelegramBotConfig
# ---------------------------------------------------------------------------
class TestTelegramBotConfig:
def test_prefix_from_telegram_section(self):
config = {
"telegram": {
"enabled": True,
"bot_token": "t",
"poll_timeout": 1,
"prefix": "/",
"admins": [],
"operators": [],
"trusted": [],
},
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
}
bot = TelegramBot("test", config, PluginRegistry())
assert bot.prefix == "/"
def test_prefix_falls_back_to_bot(self):
config = {
"telegram": {
"enabled": True,
"bot_token": "t",
"poll_timeout": 1,
"admins": [],
"operators": [],
"trusted": [],
},
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
}
bot = TelegramBot("test", config, PluginRegistry())
assert bot.prefix == "!"
def test_admins_coerced_to_str(self):
bot = _make_bot(admins=[111, 222])
assert bot._admins == ["111", "222"]
def test_proxy_default_true(self):
bot = _make_bot()
assert bot._proxy is True
def test_proxy_disabled(self):
config = {
"telegram": {
"enabled": True,
"bot_token": "t",
"poll_timeout": 1,
"proxy": False,
"admins": [],
"operators": [],
"trusted": [],
},
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
}
bot = TelegramBot("test", config, PluginRegistry())
assert bot._proxy is False

View File

@@ -19,16 +19,14 @@ _spec.loader.exec_module(_mod)
from plugins.twitch import ( # noqa: E402 from plugins.twitch import ( # noqa: E402
_compact_num, _compact_num,
_delete, _delete,
_errors,
_load, _load,
_poll_once, _poll_once,
_pollers, _ps,
_restore, _restore,
_save, _save,
_start_poller, _start_poller,
_state_key, _state_key,
_stop_poller, _stop_poller,
_streamers,
_truncate, _truncate,
_validate_name, _validate_name,
cmd_twitch, cmd_twitch,
@@ -131,6 +129,7 @@ class _FakeBot:
self.sent: list[tuple[str, str]] = [] self.sent: list[tuple[str, str]] = []
self.replied: list[str] = [] self.replied: list[str] = []
self.state = _FakeState() self.state = _FakeState()
self._pstate: dict = {}
self._admin = admin self._admin = admin
async def send(self, target: str, text: str) -> None: async def send(self, target: str, text: str) -> None:
@@ -160,13 +159,7 @@ def _pm(text: str, nick: str = "alice") -> Message:
def _clear() -> None: def _clear() -> None:
"""Reset module-level state between tests.""" """No-op -- state is per-bot now, each _FakeBot starts fresh."""
for task in _pollers.values():
if task and not task.done():
task.cancel()
_pollers.clear()
_streamers.clear()
_errors.clear()
def _fake_query_live(login): def _fake_query_live(login):
@@ -439,8 +432,8 @@ class TestCmdTwitchFollow:
assert data["name"] == "xqc" assert data["name"] == "xqc"
assert data["channel"] == "#test" assert data["channel"] == "#test"
assert data["was_live"] is False assert data["was_live"] is False
assert "#test:xqc" in _pollers assert "#test:xqc" in _ps(bot)["pollers"]
_stop_poller("#test:xqc") _stop_poller(bot, "#test:xqc")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -457,7 +450,7 @@ class TestCmdTwitchFollow:
data = _load(bot, "#test:my-streamer") data = _load(bot, "#test:my-streamer")
assert data is not None assert data is not None
assert data["name"] == "my-streamer" assert data["name"] == "my-streamer"
_stop_poller("#test:my-streamer") _stop_poller(bot, "#test:my-streamer")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -476,7 +469,7 @@ class TestCmdTwitchFollow:
assert data["stream_id"] == "12345" assert data["stream_id"] == "12345"
# Should NOT have announced (seed, not transition) # Should NOT have announced (seed, not transition)
assert len(bot.sent) == 0 assert len(bot.sent) == 0
_stop_poller("#test:xqc") _stop_poller(bot, "#test:xqc")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -585,7 +578,7 @@ class TestCmdTwitchUnfollow:
await cmd_twitch(bot, _msg("!twitch unfollow xqc")) await cmd_twitch(bot, _msg("!twitch unfollow xqc"))
assert "Unfollowed 'xqc'" in bot.replied[0] assert "Unfollowed 'xqc'" in bot.replied[0]
assert _load(bot, "#test:xqc") is None assert _load(bot, "#test:xqc") is None
assert "#test:xqc" not in _pollers assert "#test:xqc" not in _ps(bot)["pollers"]
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -798,7 +791,7 @@ class TestPollOnce:
} }
key = "#test:xqc" key = "#test:xqc"
_save(bot, key, data) _save(bot, key, data)
_streamers[key] = data _ps(bot)["streamers"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_query_stream", _fake_query_live): with patch.object(_mod, "_query_stream", _fake_query_live):
@@ -828,7 +821,7 @@ class TestPollOnce:
} }
key = "#test:xqc" key = "#test:xqc"
_save(bot, key, data) _save(bot, key, data)
_streamers[key] = data _ps(bot)["streamers"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_query_stream", _fake_query_live): with patch.object(_mod, "_query_stream", _fake_query_live):
@@ -851,7 +844,7 @@ class TestPollOnce:
} }
key = "#test:xqc" key = "#test:xqc"
_save(bot, key, data) _save(bot, key, data)
_streamers[key] = data _ps(bot)["streamers"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_query_stream", _fake_query_live_new_stream): with patch.object(_mod, "_query_stream", _fake_query_live_new_stream):
@@ -877,7 +870,7 @@ class TestPollOnce:
} }
key = "#test:xqc" key = "#test:xqc"
_save(bot, key, data) _save(bot, key, data)
_streamers[key] = data _ps(bot)["streamers"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_query_stream", _fake_query_offline): with patch.object(_mod, "_query_stream", _fake_query_offline):
@@ -899,13 +892,13 @@ class TestPollOnce:
} }
key = "#test:xqc" key = "#test:xqc"
_save(bot, key, data) _save(bot, key, data)
_streamers[key] = data _ps(bot)["streamers"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_query_stream", _fake_query_error): with patch.object(_mod, "_query_stream", _fake_query_error):
await _poll_once(bot, key) await _poll_once(bot, key)
await _poll_once(bot, key) await _poll_once(bot, key)
assert _errors[key] == 2 assert _ps(bot)["errors"][key] == 2
updated = _load(bot, key) updated = _load(bot, key)
assert updated["last_error"] == "Connection refused" assert updated["last_error"] == "Connection refused"
@@ -923,7 +916,7 @@ class TestPollOnce:
} }
key = "#test:xqc" key = "#test:xqc"
_save(bot, key, data) _save(bot, key, data)
_streamers[key] = data _ps(bot)["streamers"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_query_stream", _fake_query_live): with patch.object(_mod, "_query_stream", _fake_query_live):
@@ -947,7 +940,7 @@ class TestPollOnce:
} }
key = "#test:streamer" key = "#test:streamer"
_save(bot, key, data) _save(bot, key, data)
_streamers[key] = data _ps(bot)["streamers"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_query_stream", _fake_query_live_no_game): with patch.object(_mod, "_query_stream", _fake_query_live_no_game):
@@ -990,10 +983,10 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:xqc" in _pollers assert "#test:xqc" in _ps(bot)["pollers"]
task = _pollers["#test:xqc"] task = _ps(bot)["pollers"]["#test:xqc"]
assert not task.done() assert not task.done()
_stop_poller("#test:xqc") _stop_poller(bot, "#test:xqc")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -1011,9 +1004,9 @@ class TestRestore:
async def inner(): async def inner():
dummy = asyncio.create_task(asyncio.sleep(9999)) dummy = asyncio.create_task(asyncio.sleep(9999))
_pollers["#test:xqc"] = dummy _ps(bot)["pollers"]["#test:xqc"] = dummy
_restore(bot) _restore(bot)
assert _pollers["#test:xqc"] is dummy assert _ps(bot)["pollers"]["#test:xqc"] is dummy
dummy.cancel() dummy.cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -1033,12 +1026,12 @@ class TestRestore:
async def inner(): async def inner():
done_task = asyncio.create_task(asyncio.sleep(0)) done_task = asyncio.create_task(asyncio.sleep(0))
await done_task await done_task
_pollers["#test:xqc"] = done_task _ps(bot)["pollers"]["#test:xqc"] = done_task
_restore(bot) _restore(bot)
new_task = _pollers["#test:xqc"] new_task = _ps(bot)["pollers"]["#test:xqc"]
assert new_task is not done_task assert new_task is not done_task
assert not new_task.done() assert not new_task.done()
_stop_poller("#test:xqc") _stop_poller(bot, "#test:xqc")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -1050,7 +1043,7 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:bad" not in _pollers assert "#test:bad" not in _ps(bot)["pollers"]
asyncio.run(inner()) asyncio.run(inner())
@@ -1068,8 +1061,8 @@ class TestRestore:
async def inner(): async def inner():
msg = _msg("", target="botname") msg = _msg("", target="botname")
await on_connect(bot, msg) await on_connect(bot, msg)
assert "#test:xqc" in _pollers assert "#test:xqc" in _ps(bot)["pollers"]
_stop_poller("#test:xqc") _stop_poller(bot, "#test:xqc")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -1091,16 +1084,17 @@ class TestPollerManagement:
} }
key = "#test:xqc" key = "#test:xqc"
_save(bot, key, data) _save(bot, key, data)
_streamers[key] = data _ps(bot)["streamers"][key] = data
async def inner(): async def inner():
_start_poller(bot, key) _start_poller(bot, key)
assert key in _pollers ps = _ps(bot)
assert not _pollers[key].done() assert key in ps["pollers"]
_stop_poller(key) assert not ps["pollers"][key].done()
_stop_poller(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
assert key not in _pollers assert key not in ps["pollers"]
assert key not in _streamers assert key not in ps["streamers"]
asyncio.run(inner()) asyncio.run(inner())
@@ -1115,21 +1109,23 @@ class TestPollerManagement:
} }
key = "#test:xqc" key = "#test:xqc"
_save(bot, key, data) _save(bot, key, data)
_streamers[key] = data _ps(bot)["streamers"][key] = data
async def inner(): async def inner():
_start_poller(bot, key) _start_poller(bot, key)
first = _pollers[key] ps = _ps(bot)
first = ps["pollers"][key]
_start_poller(bot, key) _start_poller(bot, key)
assert _pollers[key] is first assert ps["pollers"][key] is first
_stop_poller(key) _stop_poller(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_stop_nonexistent(self): def test_stop_nonexistent(self):
_clear() _clear()
_stop_poller("#test:nonexistent") bot = _FakeBot()
_stop_poller(bot, "#test:nonexistent")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -25,7 +25,7 @@ from plugins.urltitle import ( # noqa: E402, I001
_extract_urls, _extract_urls,
_fetch_title, _fetch_title,
_is_ignored_url, _is_ignored_url,
_seen, _ps,
on_privmsg, on_privmsg,
) )
@@ -40,6 +40,7 @@ class _FakeBot:
self.sent: list[tuple[str, str]] = [] self.sent: list[tuple[str, str]] = []
self.nick = "derp" self.nick = "derp"
self.prefix = "!" self.prefix = "!"
self._pstate: dict = {}
self.config = { self.config = {
"flaskpaste": {"url": "https://paste.mymx.me"}, "flaskpaste": {"url": "https://paste.mymx.me"},
"urltitle": {}, "urltitle": {},
@@ -334,26 +335,28 @@ class TestFetchTitle:
class TestCooldown: class TestCooldown:
def setup_method(self): def setup_method(self):
_seen.clear() self.bot = _FakeBot()
def test_first_access_not_cooled(self): def test_first_access_not_cooled(self):
assert _check_cooldown("https://a.com", 300) is False assert _check_cooldown(self.bot, "https://a.com", 300) is False
def test_second_access_within_window(self): def test_second_access_within_window(self):
_check_cooldown("https://b.com", 300) _check_cooldown(self.bot, "https://b.com", 300)
assert _check_cooldown("https://b.com", 300) is True assert _check_cooldown(self.bot, "https://b.com", 300) is True
def test_after_cooldown_expires(self): def test_after_cooldown_expires(self):
_seen["https://c.com"] = time.monotonic() - 400 seen = _ps(self.bot)["seen"]
assert _check_cooldown("https://c.com", 300) is False seen["https://c.com"] = time.monotonic() - 400
assert _check_cooldown(self.bot, "https://c.com", 300) is False
def test_pruning(self): def test_pruning(self):
"""Cache is pruned when it exceeds max size.""" """Cache is pruned when it exceeds max size."""
seen = _ps(self.bot)["seen"]
old = time.monotonic() - 600 old = time.monotonic() - 600
for i in range(600): for i in range(600):
_seen[f"https://stale-{i}.com"] = old seen[f"https://stale-{i}.com"] = old
_check_cooldown("https://new.com", 300) _check_cooldown(self.bot, "https://new.com", 300)
assert len(_seen) < 600 assert len(seen) < 600
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -361,8 +364,6 @@ class TestCooldown:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestOnPrivmsg: class TestOnPrivmsg:
def setup_method(self):
_seen.clear()
def test_channel_url_previewed(self): def test_channel_url_previewed(self):
bot = _FakeBot() bot = _FakeBot()

797
tests/test_voice.py Normal file
View 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

View File

@@ -23,6 +23,7 @@ from plugins.webhook import ( # noqa: E402
_MAX_BODY, _MAX_BODY,
_handle_request, _handle_request,
_http_response, _http_response,
_ps,
_verify_signature, _verify_signature,
cmd_webhook, cmd_webhook,
on_connect, on_connect,
@@ -62,6 +63,7 @@ class _FakeBot:
self.replied: list[str] = [] self.replied: list[str] = []
self.actions: list[tuple[str, str]] = [] self.actions: list[tuple[str, str]] = []
self.state = _FakeState() self.state = _FakeState()
self._pstate: dict = {}
self._admin = admin self._admin = admin
self.prefix = "!" self.prefix = "!"
self.config = { self.config = {
@@ -301,14 +303,14 @@ class TestRequestHandler:
def test_counter_increments(self): def test_counter_increments(self):
bot = _FakeBot() bot = _FakeBot()
# Reset counter ps = _ps(bot)
_mod._request_count = 0 ps["request_count"] = 0
body = json.dumps({"channel": "#test", "text": "hi"}).encode() body = json.dumps({"channel": "#test", "text": "hi"}).encode()
raw = _build_request("POST", body, {"Content-Length": str(len(body))}) raw = _build_request("POST", body, {"Content-Length": str(len(body))})
reader = _FakeReader(raw) reader = _FakeReader(raw)
writer = _FakeWriter() writer = _FakeWriter()
asyncio.run(_handle_request(reader, writer, bot, "")) asyncio.run(_handle_request(reader, writer, bot, ""))
assert _mod._request_count == 1 assert ps["request_count"] == 1
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -320,28 +322,23 @@ class TestServerLifecycle:
def test_disabled_config(self): def test_disabled_config(self):
"""Server does not start when webhook is disabled.""" """Server does not start when webhook is disabled."""
bot = _FakeBot(webhook_cfg={"enabled": False}) bot = _FakeBot(webhook_cfg={"enabled": False})
msg = _msg("", target="")
msg = Message(raw="", prefix="", nick="", command="001", msg = Message(raw="", prefix="", nick="", command="001",
params=["test", "Welcome"], tags={}) params=["test", "Welcome"], tags={})
# Reset global state
_mod._server = None
asyncio.run(on_connect(bot, msg)) asyncio.run(on_connect(bot, msg))
assert _mod._server is None assert _ps(bot)["server"] is None
def test_duplicate_guard(self): def test_duplicate_guard(self):
"""Second on_connect does not create a second server.""" """Second on_connect does not create a second server."""
sentinel = object() sentinel = object()
_mod._server = sentinel
bot = _FakeBot(webhook_cfg={"enabled": True, "port": 0}) bot = _FakeBot(webhook_cfg={"enabled": True, "port": 0})
_ps(bot)["server"] = sentinel
msg = Message(raw="", prefix="", nick="", command="001", msg = Message(raw="", prefix="", nick="", command="001",
params=["test", "Welcome"], tags={}) params=["test", "Welcome"], tags={})
asyncio.run(on_connect(bot, msg)) asyncio.run(on_connect(bot, msg))
assert _mod._server is sentinel assert _ps(bot)["server"] is sentinel
_mod._server = None # cleanup
def test_on_connect_starts(self): def test_on_connect_starts(self):
"""on_connect starts the server when enabled.""" """on_connect starts the server when enabled."""
_mod._server = None
bot = _FakeBot(webhook_cfg={ bot = _FakeBot(webhook_cfg={
"enabled": True, "host": "127.0.0.1", "port": 0, "secret": "", "enabled": True, "host": "127.0.0.1", "port": 0, "secret": "",
}) })
@@ -350,10 +347,10 @@ class TestServerLifecycle:
async def _run(): async def _run():
await on_connect(bot, msg) await on_connect(bot, msg)
assert _mod._server is not None ps = _ps(bot)
_mod._server.close() assert ps["server"] is not None
await _mod._server.wait_closed() ps["server"].close()
_mod._server = None await ps["server"].wait_closed()
asyncio.run(_run()) asyncio.run(_run())
@@ -366,26 +363,25 @@ class TestServerLifecycle:
class TestWebhookCommand: class TestWebhookCommand:
def test_not_running(self): def test_not_running(self):
bot = _FakeBot() bot = _FakeBot()
_mod._server = None
asyncio.run(cmd_webhook(bot, _msg("!webhook"))) asyncio.run(cmd_webhook(bot, _msg("!webhook")))
assert any("not running" in r for r in bot.replied) assert any("not running" in r for r in bot.replied)
def test_running_shows_status(self): def test_running_shows_status(self):
bot = _FakeBot() bot = _FakeBot()
_mod._request_count = 42 ps = _ps(bot)
_mod._started = time.monotonic() - 90 # 1m 30s ago ps["request_count"] = 42
ps["started"] = time.monotonic() - 90 # 1m 30s ago
async def _run(): async def _run():
# Start a real server on port 0 to get a valid socket # Start a real server on port 0 to get a valid socket
srv = await asyncio.start_server(lambda r, w: None, srv = await asyncio.start_server(lambda r, w: None,
"127.0.0.1", 0) "127.0.0.1", 0)
_mod._server = srv ps["server"] = srv
try: try:
await cmd_webhook(bot, _msg("!webhook")) await cmd_webhook(bot, _msg("!webhook"))
finally: finally:
srv.close() srv.close()
await srv.wait_closed() await srv.wait_closed()
_mod._server = None
asyncio.run(_run()) asyncio.run(_run())
assert len(bot.replied) == 1 assert len(bot.replied) == 1

View File

@@ -18,18 +18,16 @@ _spec.loader.exec_module(_mod)
from plugins.youtube import ( # noqa: E402 from plugins.youtube import ( # noqa: E402
_MAX_ANNOUNCE, _MAX_ANNOUNCE,
_channels,
_compact_num, _compact_num,
_delete, _delete,
_derive_name, _derive_name,
_errors,
_extract_channel_id, _extract_channel_id,
_format_duration, _format_duration,
_is_youtube_url, _is_youtube_url,
_load, _load,
_parse_feed, _parse_feed,
_poll_once, _poll_once,
_pollers, _ps,
_restore, _restore,
_save, _save,
_start_poller, _start_poller,
@@ -163,6 +161,7 @@ class _FakeBot:
self.sent: list[tuple[str, str]] = [] self.sent: list[tuple[str, str]] = []
self.replied: list[str] = [] self.replied: list[str] = []
self.state = _FakeState() self.state = _FakeState()
self._pstate: dict = {}
self._admin = admin self._admin = admin
async def send(self, target: str, text: str) -> None: async def send(self, target: str, text: str) -> None:
@@ -195,13 +194,7 @@ def _pm(text: str, nick: str = "alice") -> Message:
def _clear() -> None: def _clear() -> None:
"""Reset module-level state between tests.""" """No-op -- state is per-bot now, each _FakeBot starts fresh."""
for task in _pollers.values():
if task and not task.done():
task.cancel()
_pollers.clear()
_channels.clear()
_errors.clear()
def _fake_fetch_ok(url, etag="", last_modified=""): def _fake_fetch_ok(url, etag="", last_modified=""):
@@ -491,8 +484,8 @@ class TestCmdYtFollow:
assert data["name"] == "3b1b" assert data["name"] == "3b1b"
assert data["channel"] == "#test" assert data["channel"] == "#test"
assert len(data["seen"]) == 3 assert len(data["seen"]) == 3
assert "#test:3b1b" in _pollers assert "#test:3b1b" in _ps(bot)["pollers"]
_stop_poller("#test:3b1b") _stop_poller(bot, "#test:3b1b")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -679,7 +672,7 @@ class TestCmdYtUnfollow:
await cmd_yt(bot, _msg("!yt unfollow delfeed")) await cmd_yt(bot, _msg("!yt unfollow delfeed"))
assert "Unfollowed 'delfeed'" in bot.replied[0] assert "Unfollowed 'delfeed'" in bot.replied[0]
assert _load(bot, "#test:delfeed") is None assert _load(bot, "#test:delfeed") is None
assert "#test:delfeed" not in _pollers assert "#test:delfeed" not in _ps(bot)["pollers"]
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -876,7 +869,7 @@ class TestPollOnce:
} }
key = "#test:f304" key = "#test:f304"
_save(bot, key, data) _save(bot, key, data)
_channels[key] = data _ps(bot)["channels"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_304): with patch.object(_mod, "_fetch_feed", _fake_fetch_304):
@@ -896,13 +889,13 @@ class TestPollOnce:
} }
key = "#test:ferr" key = "#test:ferr"
_save(bot, key, data) _save(bot, key, data)
_channels[key] = data _ps(bot)["channels"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_error): with patch.object(_mod, "_fetch_feed", _fake_fetch_error):
await _poll_once(bot, key) await _poll_once(bot, key)
await _poll_once(bot, key) await _poll_once(bot, key)
assert _errors[key] == 2 assert _ps(bot)["errors"][key] == 2
updated = _load(bot, key) updated = _load(bot, key)
assert updated["last_error"] == "Connection refused" assert updated["last_error"] == "Connection refused"
@@ -939,7 +932,7 @@ class TestPollOnce:
} }
key = "#test:big" key = "#test:big"
_save(bot, key, data) _save(bot, key, data)
_channels[key] = data _ps(bot)["channels"][key] = data
async def inner(): async def inner():
with ( with (
@@ -964,7 +957,7 @@ class TestPollOnce:
} }
key = "#test:quiet" key = "#test:quiet"
_save(bot, key, data) _save(bot, key, data)
_channels[key] = data _ps(bot)["channels"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok):
@@ -987,7 +980,7 @@ class TestPollOnce:
} }
key = "#test:etag" key = "#test:etag"
_save(bot, key, data) _save(bot, key, data)
_channels[key] = data _ps(bot)["channels"][key] = data
async def inner(): async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok):
@@ -1015,10 +1008,10 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:restored" in _pollers assert "#test:restored" in _ps(bot)["pollers"]
task = _pollers["#test:restored"] task = _ps(bot)["pollers"]["#test:restored"]
assert not task.done() assert not task.done()
_stop_poller("#test:restored") _stop_poller(bot, "#test:restored")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -1035,9 +1028,9 @@ class TestRestore:
async def inner(): async def inner():
dummy = asyncio.create_task(asyncio.sleep(9999)) dummy = asyncio.create_task(asyncio.sleep(9999))
_pollers["#test:active"] = dummy _ps(bot)["pollers"]["#test:active"] = dummy
_restore(bot) _restore(bot)
assert _pollers["#test:active"] is dummy assert _ps(bot)["pollers"]["#test:active"] is dummy
dummy.cancel() dummy.cancel()
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -1056,12 +1049,12 @@ class TestRestore:
async def inner(): async def inner():
done_task = asyncio.create_task(asyncio.sleep(0)) done_task = asyncio.create_task(asyncio.sleep(0))
await done_task await done_task
_pollers["#test:done"] = done_task _ps(bot)["pollers"]["#test:done"] = done_task
_restore(bot) _restore(bot)
new_task = _pollers["#test:done"] new_task = _ps(bot)["pollers"]["#test:done"]
assert new_task is not done_task assert new_task is not done_task
assert not new_task.done() assert not new_task.done()
_stop_poller("#test:done") _stop_poller(bot, "#test:done")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -1073,7 +1066,7 @@ class TestRestore:
async def inner(): async def inner():
_restore(bot) _restore(bot)
assert "#test:bad" not in _pollers assert "#test:bad" not in _ps(bot)["pollers"]
asyncio.run(inner()) asyncio.run(inner())
@@ -1090,8 +1083,8 @@ class TestRestore:
async def inner(): async def inner():
msg = _msg("", target="botname") msg = _msg("", target="botname")
await on_connect(bot, msg) await on_connect(bot, msg)
assert "#test:conn" in _pollers assert "#test:conn" in _ps(bot)["pollers"]
_stop_poller("#test:conn") _stop_poller(bot, "#test:conn")
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
@@ -1112,16 +1105,17 @@ class TestPollerManagement:
} }
key = "#test:mgmt" key = "#test:mgmt"
_save(bot, key, data) _save(bot, key, data)
_channels[key] = data _ps(bot)["channels"][key] = data
async def inner(): async def inner():
_start_poller(bot, key) _start_poller(bot, key)
assert key in _pollers ps = _ps(bot)
assert not _pollers[key].done() assert key in ps["pollers"]
_stop_poller(key) assert not ps["pollers"][key].done()
_stop_poller(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
assert key not in _pollers assert key not in ps["pollers"]
assert key not in _channels assert key not in ps["channels"]
asyncio.run(inner()) asyncio.run(inner())
@@ -1135,21 +1129,23 @@ class TestPollerManagement:
} }
key = "#test:idem" key = "#test:idem"
_save(bot, key, data) _save(bot, key, data)
_channels[key] = data _ps(bot)["channels"][key] = data
async def inner(): async def inner():
_start_poller(bot, key) _start_poller(bot, key)
first = _pollers[key] ps = _ps(bot)
first = ps["pollers"][key]
_start_poller(bot, key) _start_poller(bot, key)
assert _pollers[key] is first assert ps["pollers"][key] is first
_stop_poller(key) _stop_poller(bot, key)
await asyncio.sleep(0) await asyncio.sleep(0)
asyncio.run(inner()) asyncio.run(inner())
def test_stop_nonexistent(self): def test_stop_nonexistent(self):
_clear() _clear()
_stop_poller("#test:nonexistent") bot = _FakeBot()
_stop_poller(bot, "#test:nonexistent")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

38
tools/_common.sh Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"