Compare commits
93 Commits
e9528bd879
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e1c32f22c | ||
|
|
f470d6d958 | ||
|
|
28f4c63e99 | ||
|
|
dd4c6b95b7 | ||
|
|
b658053711 | ||
|
|
20c1d738be | ||
|
|
ecfa7cea39 | ||
|
|
ef18915807 | ||
|
|
69976196cd | ||
|
|
c851e82990 | ||
|
|
ad12843e75 | ||
|
|
62a4191200 | ||
|
|
135a3791e2 | ||
|
|
a87f75adf1 | ||
|
|
da9ed51c74 | ||
|
|
56f6b9822f | ||
|
|
09880624d5 | ||
|
|
3c475107e3 | ||
|
|
b3006b02e2 | ||
|
|
8b504364a9 | ||
|
|
40c6bf8c53 | ||
|
|
a76d46b1de | ||
|
|
0ffddb8e41 | ||
|
|
62b01c76f7 | ||
|
|
e0db0ad567 | ||
|
|
c41035ceca | ||
|
|
cd4124e07a | ||
|
|
717bf59a05 | ||
|
|
5d0e200fbe | ||
|
|
8d54322ce1 | ||
|
|
e920ec5f10 | ||
|
|
c522d30c36 | ||
|
|
068734d931 | ||
|
|
36da191b45 | ||
|
|
6083de13f9 | ||
|
|
6d6b957557 | ||
|
|
f72f55148b | ||
|
|
e9d17e8b00 | ||
|
|
3afeace6e7 | ||
|
|
b88a459142 | ||
|
|
ad1de1653e | ||
|
|
8f1df167b9 | ||
|
|
de2d1fdf15 | ||
|
|
82f5984631 | ||
|
|
1744e7087f | ||
|
|
0c0adef90d | ||
|
|
3dada3fc06 | ||
|
|
6e40daa8a9 | ||
|
|
ba1af461de | ||
|
|
004656a64f | ||
|
|
192ea717a7 | ||
|
|
7a4aa65882 | ||
|
|
2cd1d5efb1 | ||
|
|
95981275b5 | ||
|
|
66116d2caf | ||
|
|
eded764f6a | ||
|
|
9783365b1e | ||
|
|
165938a801 | ||
|
|
221cb1f06b | ||
|
|
c4908f2a63 | ||
|
|
c493583a71 | ||
|
|
7c099d8cf0 | ||
|
|
e127f72660 | ||
|
|
7b9359c152 | ||
|
|
9fbf45f67d | ||
|
|
039f060b50 | ||
|
|
df20c154ca | ||
|
|
ab924444de | ||
|
|
ec55c2aef1 | ||
|
|
f899241d73 | ||
|
|
f189cbd290 | ||
|
|
9d58a5d073 | ||
|
|
e4e1e219f0 | ||
|
|
6b7d733650 | ||
|
|
c5c61e63cc | ||
|
|
67b2dc827d | ||
|
|
eae36aa1f9 | ||
|
|
d884d2bb55 | ||
|
|
d756e7c020 | ||
|
|
7206b27fb0 | ||
|
|
47b13c3f1f | ||
|
|
b074356ec6 | ||
|
|
9d4cb09069 | ||
|
|
ca46042c41 | ||
|
|
37c858f4d7 | ||
|
|
0d92e6ed31 | ||
|
|
3bcba8b0a9 | ||
|
|
4a304f2498 | ||
|
|
4a165e8b28 | ||
|
|
014b609686 | ||
|
|
c8879f6089 | ||
|
|
144193e3bb | ||
|
|
073659607e |
@@ -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
2
.gitleaks.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[allowlist]
|
||||||
|
paths = ["tests/"]
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
79
ROADMAP.md
79
ROADMAP.md
@@ -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
182
TASKS.md
@@ -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
116
TODO.md
@@ -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)
|
||||||
|
|||||||
@@ -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
515
docs/API.md
Normal 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
333
docs/AUDIO.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# Audio Engine -- Issues, Fixes, and Consolidation Notes
|
||||||
|
|
||||||
|
Technical reference for the Mumble audio pipeline: known issues,
|
||||||
|
applied fixes, architectural decisions, and areas for future work.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
yt-dlp -> ffmpeg (decode to s16le 48kHz mono) -> PCM frames (20ms)
|
||||||
|
-> volume ramp/scale -> pymumble sound_output -> Opus encode -> Mumble
|
||||||
|
```
|
||||||
|
|
||||||
|
Key components:
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `src/derp/mumble.py` | `stream_audio()` -- PCM feed loop, volume ramp, seek |
|
||||||
|
| `plugins/music.py` | Queue, play loop, fade orchestration, duck monitor |
|
||||||
|
|
||||||
|
### Volume control layers (evaluated per-frame, highest priority first)
|
||||||
|
|
||||||
|
1. **fade_vol** -- active during fade-out (skip/stop/pause); set to 0 as target
|
||||||
|
2. **duck_vol** -- voice-activated ducking; snap to floor, linear restore
|
||||||
|
3. **volume** -- user-set level (0-100)
|
||||||
|
|
||||||
|
The play loop passes a lambda to `stream_audio`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
volume=lambda: (
|
||||||
|
ps["fade_vol"] if ps["fade_vol"] is not None else
|
||||||
|
ps["duck_vol"] if ps["duck_vol"] is not None else
|
||||||
|
ps["volume"]
|
||||||
|
) / 100.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-frame volume ramping
|
||||||
|
|
||||||
|
`stream_audio` never jumps to the target volume. Each 20ms frame is
|
||||||
|
ramped from `_cur_vol` toward `target` by at most `step`:
|
||||||
|
|
||||||
|
- **_max_step** = 0.005 (~4s full ramp) -- ceiling for normal changes
|
||||||
|
- **fade_in_step** -- computed from fade-in duration (default 5s)
|
||||||
|
- **fade_step** -- override from plugin (fade-out on skip/stop/pause)
|
||||||
|
|
||||||
|
When `abs(diff) < 0.0001`, flat scaling is used (avoids ramp artifacts
|
||||||
|
on steady-state frames). Otherwise, `_scale_pcm_ramp()` linearly
|
||||||
|
interpolates across all 960 samples in the frame.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues and Fixes
|
||||||
|
|
||||||
|
### 1. Alpine ffmpeg lacks librubberband
|
||||||
|
|
||||||
|
**Symptom:** 13/15 voice audition samples failed. `rubberband` audio
|
||||||
|
filter unavailable in ffmpeg.
|
||||||
|
|
||||||
|
**Root cause:** Alpine's ffmpeg package is compiled without
|
||||||
|
`--enable-librubberband`.
|
||||||
|
|
||||||
|
**Fix:** Added `rubberband` CLI package to `Containerfile`. Created
|
||||||
|
`_split_fx()` in `plugins/voice.py` to parse FX chains: pitch-shifting
|
||||||
|
goes through the `rubberband` CLI binary, remaining filters (bass, echo)
|
||||||
|
through ffmpeg. Two-stage pipeline.
|
||||||
|
|
||||||
|
**Files:** `Containerfile`, `plugins/voice.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Self-ducking between bots
|
||||||
|
|
||||||
|
**Symptom:** derp's music volume dropped when merlin spoke (TTS).
|
||||||
|
|
||||||
|
**Root cause:** merlin's TTS output triggered `_on_sound_received`,
|
||||||
|
which updated the shared `registry._voice_ts` timestamp. derp's duck
|
||||||
|
monitor saw recent voice activity and ducked.
|
||||||
|
|
||||||
|
**Fix:** `_on_sound_received` checks `registry._bots` and returns early
|
||||||
|
for any bot username -- no timestamp update, no listener dispatch.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _on_sound_received(self, user, sound_chunk) -> None:
|
||||||
|
name = user["name"] if isinstance(user, dict) else None
|
||||||
|
bots = getattr(self.registry, "_bots", {})
|
||||||
|
if name and name in bots:
|
||||||
|
return # ignore audio from bots entirely
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files:** `src/derp/mumble.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Click/pop on skip/stop (fade-out cancellation)
|
||||||
|
|
||||||
|
**Symptom:** Audible glitch at the end of fade-out when skipping or
|
||||||
|
stopping a track.
|
||||||
|
|
||||||
|
**Root cause:** `_fade_and_cancel()` fades volume to 0 over ~3s, then
|
||||||
|
calls `task.cancel()`. In `stream_audio`, `CancelledError` triggers
|
||||||
|
`clear_buffer()`, which drops any frames still queued in pymumble's
|
||||||
|
output -- including frames that were encoded at non-zero amplitude a
|
||||||
|
few frames earlier. The sudden buffer wipe produces a click.
|
||||||
|
|
||||||
|
**Fix (two-part):**
|
||||||
|
|
||||||
|
1. **Plugin side** (`music.py`): Added 150ms post-fade drain before
|
||||||
|
cancel, giving pymumble time to flush remaining silent frames.
|
||||||
|
|
||||||
|
2. **Engine side** (`mumble.py`): `CancelledError` handler only calls
|
||||||
|
`clear_buffer()` if `_cur_vol > 0.01`. When a fade-out has already
|
||||||
|
driven volume to ~0, the remaining buffer frames are silent and
|
||||||
|
clearing them is unnecessary.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# mumble.py -- CancelledError handler
|
||||||
|
if _cur_vol > 0.01:
|
||||||
|
self._mumble.sound_output.clear_buffer()
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# music.py -- _fade_and_cancel()
|
||||||
|
await asyncio.sleep(duration)
|
||||||
|
await asyncio.sleep(0.15) # drain window
|
||||||
|
task.cancel()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files:** `src/derp/mumble.py`, `plugins/music.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Fade-out math
|
||||||
|
|
||||||
|
**How it works:** `_fade_and_cancel(duration=3.0)` computes the
|
||||||
|
per-frame step from the current effective volume:
|
||||||
|
|
||||||
|
```python
|
||||||
|
cur_vol = (duck_vol or volume) / 100.0
|
||||||
|
n_frames = duration / 0.02 # 150 frames for 3s
|
||||||
|
step = cur_vol / n_frames
|
||||||
|
```
|
||||||
|
|
||||||
|
The play loop sets `ps["fade_vol"] = 0` (the target) and
|
||||||
|
`ps["fade_step"] = step` (the rate). `stream_audio` ramps `_cur_vol`
|
||||||
|
toward 0 at `step` per frame. At 50% volume: step = 0.0033, reaching
|
||||||
|
zero in exactly 150 frames (3.0s).
|
||||||
|
|
||||||
|
**Note:** `fade_vol` is set to 0 immediately, making the volume lambda
|
||||||
|
return 0 as the target. The ramp code smoothly transitions -- there is
|
||||||
|
no abrupt jump because `_cur_vol` tracks actual output level, not the
|
||||||
|
target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Self-mute lifecycle
|
||||||
|
|
||||||
|
**Requirement:** merlin mutes on connect, unmutes only when emitting
|
||||||
|
audio (TTS), re-mutes after a delay.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```
|
||||||
|
connect -> mute()
|
||||||
|
stream_audio start -> cancel pending mute task, unmute()
|
||||||
|
stream_audio finally -> spawn _delayed_mute(3.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
The 3-second delay prevents rapid mute/unmute flicker on back-to-back
|
||||||
|
TTS. The mute task is cancelled if new audio starts before it fires.
|
||||||
|
|
||||||
|
**Config:** `self_mute = true` in `[[mumble.extra]]`
|
||||||
|
|
||||||
|
**Files:** `src/derp/mumble.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Self-deafen on connect
|
||||||
|
|
||||||
|
**Requirement:** merlin deafens on connect (no audio reception needed).
|
||||||
|
|
||||||
|
**Implementation:** `self_deaf = true` config flag, calls
|
||||||
|
`self._mumble.users.myself.deafen()` in `_on_connected`.
|
||||||
|
|
||||||
|
**Files:** `src/derp/mumble.py`, `config/derp.toml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pause/Resume
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
`!pause` toggles between paused and playing states:
|
||||||
|
|
||||||
|
**Pause:** Captures current track + elapsed position + monotonic
|
||||||
|
timestamp. Fades out, cancels play loop. Queue is preserved.
|
||||||
|
|
||||||
|
**Unpause:** Re-inserts track at queue front, starts play loop with
|
||||||
|
seek. Two special behaviors:
|
||||||
|
|
||||||
|
1. **Rewind:** 3s rewind on unpause for continuity (only if paused >= 3s
|
||||||
|
to prevent anti-flood: rapid toggle doesn't compound the rewind).
|
||||||
|
|
||||||
|
2. **Stale stream:** If paused > 45s, cached stream files (in
|
||||||
|
`data/music/cache/`) are deleted so the play loop re-downloads.
|
||||||
|
Kept files (`data/music/`) are never deleted. Stream URLs from
|
||||||
|
YouTube et al. expire within minutes.
|
||||||
|
|
||||||
|
3. **Fade-in:** Unpause always uses `fade_in=True` (5s ramp from 0).
|
||||||
|
|
||||||
|
**State cleanup:** `!stop` clears `ps["paused"]`. The play loop's
|
||||||
|
`finally` block skips `_cleanup_track` when paused (preserves the file).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Autoplay
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
When `autoplay = true` (config), the play loop stays alive after the
|
||||||
|
queue empties:
|
||||||
|
|
||||||
|
1. Waits for silence (duck_silence threshold, default 15s)
|
||||||
|
2. Picks one random kept track
|
||||||
|
3. Plays it
|
||||||
|
4. On completion, loops back to step 1
|
||||||
|
|
||||||
|
This replaces the previous bulk-queue approach (shuffle all kept tracks
|
||||||
|
at once). Benefits: no large upfront queue, silence-aware gaps between
|
||||||
|
tracks, indefinite looping.
|
||||||
|
|
||||||
|
### Resume persistence
|
||||||
|
|
||||||
|
A background task saves track URL + elapsed position to the state DB
|
||||||
|
every 10 seconds during playback:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _periodic_save():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
el = cur_seek + progress[0] * 0.02
|
||||||
|
if el > 1.0:
|
||||||
|
_save_resume(bot, track, el)
|
||||||
|
```
|
||||||
|
|
||||||
|
On hard kill: resumes from at most ~10s behind. On normal track
|
||||||
|
completion: `_clear_resume()` wipes the state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voice Ducking
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
voice detected -> duck_vol = floor (instant)
|
||||||
|
silence > duck_silence -> linear restore over duck_restore seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
The duck monitor runs as a background task alongside the play loop.
|
||||||
|
It updates `ps["duck_vol"]` which the volume lambda reads per-frame.
|
||||||
|
|
||||||
|
### Restore ramp
|
||||||
|
|
||||||
|
Restoration is linear from floor to user volume. The per-frame ramp in
|
||||||
|
`stream_audio` further smooths each 1-second update from the monitor,
|
||||||
|
eliminating audible steps.
|
||||||
|
|
||||||
|
### Bot audio isolation
|
||||||
|
|
||||||
|
Bot usernames (from `registry._bots`) are excluded from
|
||||||
|
`_on_sound_received` entirely -- no timestamp update, no listener
|
||||||
|
dispatch. This prevents self-ducking between derp and merlin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seek (in-stream pipeline swap)
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
Seek rebuilds the ffmpeg pipeline at the new position without cancelling
|
||||||
|
the play loop task. This avoids the overhead of re-downloading.
|
||||||
|
|
||||||
|
1. Set `_seek_fading = True`, `_seek_fade_out = 10` (0.2s ramp-down)
|
||||||
|
2. Continue reading frames, scaling by decreasing ratio
|
||||||
|
3. At fade-out = 0: kill ffmpeg, clear buffer, spawn new pipeline
|
||||||
|
4. 0.5s fade-in on the new pipeline
|
||||||
|
|
||||||
|
### Consolidation note
|
||||||
|
|
||||||
|
Seek fade-out (10 frames / 0.2s) is much shorter than skip/stop
|
||||||
|
fade-out (3s). This is intentional -- seek should feel responsive.
|
||||||
|
The mechanisms are separate: seek uses frame-counting in
|
||||||
|
`stream_audio`, skip/stop uses `_fade_and_cancel` in the plugin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consolidation Opportunities
|
||||||
|
|
||||||
|
### Volume control unification
|
||||||
|
|
||||||
|
Three volume layers (fade_vol, duck_vol, volume) evaluated in a lambda
|
||||||
|
per-frame. Works but the priority logic is implicit. A future refactor
|
||||||
|
could use a single `effective_volume()` method that explicitly resolves
|
||||||
|
priority and makes the per-frame cost clearer.
|
||||||
|
|
||||||
|
### Fade-out ownership
|
||||||
|
|
||||||
|
Skip/stop/pause all route through `_fade_and_cancel()` -- good. But the
|
||||||
|
fade target is communicated indirectly via `ps["fade_vol"] = 0` and
|
||||||
|
`ps["fade_step"]`, read by a lambda in the play loop, evaluated in
|
||||||
|
`stream_audio`. A more explicit signal (e.g. an asyncio.Event or a
|
||||||
|
dedicated fade state machine in `stream_audio`) could simplify reasoning
|
||||||
|
about timing.
|
||||||
|
|
||||||
|
### Buffer drain timing
|
||||||
|
|
||||||
|
The 150ms post-fade drain is empirical. A more robust approach would be
|
||||||
|
to query `sound_output.get_buffer_size()` and wait for it to drop below
|
||||||
|
a threshold before cancelling. This would adapt to varying network
|
||||||
|
conditions and pymumble buffer sizes.
|
||||||
|
|
||||||
|
### Track duration
|
||||||
|
|
||||||
|
Duration is probed via `ffprobe` after download (blocking, run in
|
||||||
|
executor). For kept tracks, it's stored in state metadata. This is
|
||||||
|
duplicated -- kept track metadata already has duration from
|
||||||
|
`_fetch_metadata` (yt-dlp). The `ffprobe` path is the fallback for
|
||||||
|
non-kept tracks. Could unify by always probing locally.
|
||||||
|
|
||||||
|
### Periodic resume save interval
|
||||||
|
|
||||||
|
Currently 10s fixed. Could be adaptive -- save more frequently near
|
||||||
|
the start of a track (where losing position is more noticeable) and
|
||||||
|
less frequently later. Marginal benefit vs. complexity though.
|
||||||
@@ -53,28 +53,53 @@ format = "json" # JSONL output (default: "text")
|
|||||||
## Container
|
## 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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
713
docs/USAGE.md
713
docs/USAGE.md
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
73
patches/apply_pymumble_ssl.py
Normal file
73
patches/apply_pymumble_ssl.py
Normal 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
118
plugins/_musicbrainz.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""MusicBrainz API helper for music discovery fallback.
|
||||||
|
|
||||||
|
Private module (underscore prefix) -- plugin loader skips it.
|
||||||
|
All functions are blocking; callers should run them in an executor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from urllib.request import Request
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE = "https://musicbrainz.org/ws/2"
|
||||||
|
_UA = "derp-bot/2.0.0 (https://git.mymx.me/username/derp)"
|
||||||
|
|
||||||
|
# Rate limit: MusicBrainz requires max 1 request/second.
|
||||||
|
# We use 1.1s between calls to stay well within limits.
|
||||||
|
_RATE_INTERVAL = 1.1
|
||||||
|
_last_request: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _mb_request(path: str, params: dict | None = None) -> dict:
|
||||||
|
"""Rate-limited GET to MusicBrainz API. Blocking."""
|
||||||
|
global _last_request
|
||||||
|
from derp.http import urlopen
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - _last_request
|
||||||
|
if elapsed < _RATE_INTERVAL:
|
||||||
|
time.sleep(_RATE_INTERVAL - elapsed)
|
||||||
|
|
||||||
|
qs = "&".join(f"{k}={v}" for k, v in (params or {}).items())
|
||||||
|
url = f"{_BASE}/{path}?fmt=json&{qs}" if qs else f"{_BASE}/{path}?fmt=json"
|
||||||
|
req = Request(url, headers={"User-Agent": _UA})
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = urlopen(req, timeout=10, proxy=False)
|
||||||
|
_last_request = time.monotonic()
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except Exception:
|
||||||
|
_last_request = time.monotonic()
|
||||||
|
log.warning("musicbrainz: request failed: %s", path, exc_info=True)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def mb_search_artist(name: str) -> str | None:
|
||||||
|
"""Search for an artist by name, return MBID or None."""
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
data = _mb_request("artist", {"query": quote(name), "limit": "1"})
|
||||||
|
artists = data.get("artists", [])
|
||||||
|
if not artists:
|
||||||
|
return None
|
||||||
|
# Require a reasonable score to avoid false matches
|
||||||
|
score = artists[0].get("score", 0)
|
||||||
|
if score < 50:
|
||||||
|
return None
|
||||||
|
return artists[0].get("id")
|
||||||
|
|
||||||
|
|
||||||
|
def mb_artist_tags(mbid: str) -> list[str]:
|
||||||
|
"""Fetch top 5 tags for an artist by MBID."""
|
||||||
|
data = _mb_request(f"artist/{mbid}", {"inc": "tags"})
|
||||||
|
tags = data.get("tags", [])
|
||||||
|
if not tags:
|
||||||
|
return []
|
||||||
|
# Sort by count descending, take top 5
|
||||||
|
sorted_tags = sorted(tags, key=lambda t: t.get("count", 0), reverse=True)
|
||||||
|
return [t["name"] for t in sorted_tags[:5] if t.get("name")]
|
||||||
|
|
||||||
|
|
||||||
|
def mb_find_similar_recordings(artist: str, tags: list[str],
|
||||||
|
limit: int = 10) -> list[dict]:
|
||||||
|
"""Find recordings by other artists sharing top tags.
|
||||||
|
|
||||||
|
Searches MusicBrainz for recordings tagged with the top 2 tags,
|
||||||
|
excluding the original artist. Returns [{"artist": str, "title": str}].
|
||||||
|
"""
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
if not tags:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Use top 2 tags for the query
|
||||||
|
tag_query = " AND ".join(f'tag:"{t}"' for t in tags[:2])
|
||||||
|
query = f'({tag_query}) AND NOT artist:"{artist}"'
|
||||||
|
|
||||||
|
data = _mb_request("recording", {
|
||||||
|
"query": quote(query),
|
||||||
|
"limit": str(limit),
|
||||||
|
})
|
||||||
|
recordings = data.get("recordings", [])
|
||||||
|
if not recordings:
|
||||||
|
return []
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
results = []
|
||||||
|
for rec in recordings:
|
||||||
|
title = rec.get("title", "")
|
||||||
|
credits = rec.get("artist-credit", [])
|
||||||
|
if not credits or not title:
|
||||||
|
continue
|
||||||
|
rec_artist = credits[0].get("name", "") if credits else ""
|
||||||
|
if not rec_artist:
|
||||||
|
continue
|
||||||
|
# Skip the original artist (case-insensitive)
|
||||||
|
if rec_artist.lower() == artist.lower():
|
||||||
|
continue
|
||||||
|
# Deduplicate by artist+title
|
||||||
|
key = f"{rec_artist.lower()}:{title.lower()}"
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
results.append({"artist": rec_artist, "title": title})
|
||||||
|
|
||||||
|
return results
|
||||||
187
plugins/alert.py
187
plugins/alert.py
@@ -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
85
plugins/alias.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Plugin: user-defined command aliases (persistent)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from derp.plugin import command
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NS = "alias"
|
||||||
|
|
||||||
|
|
||||||
|
@command("alias", help="Aliases: !alias add|del|list|clear")
|
||||||
|
async def cmd_alias(bot, message):
|
||||||
|
"""Create short aliases for existing bot commands.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
!alias add <name> <target> Create alias (e.g. !alias add s skip)
|
||||||
|
!alias del <name> Remove alias
|
||||||
|
!alias list Show all aliases
|
||||||
|
!alias clear Remove all aliases (admin only)
|
||||||
|
"""
|
||||||
|
parts = message.text.split(None, 3)
|
||||||
|
if len(parts) < 2:
|
||||||
|
await bot.reply(message, "Usage: !alias <add|del|list|clear> [args]")
|
||||||
|
return
|
||||||
|
|
||||||
|
sub = parts[1].lower()
|
||||||
|
|
||||||
|
if sub == "add":
|
||||||
|
if len(parts) < 4:
|
||||||
|
await bot.reply(message, "Usage: !alias add <name> <target>")
|
||||||
|
return
|
||||||
|
name = parts[2].lower()
|
||||||
|
target = parts[3].lower()
|
||||||
|
|
||||||
|
# Cannot shadow an existing registered command
|
||||||
|
if name in bot.registry.commands:
|
||||||
|
await bot.reply(message, f"'{name}' is already a registered command")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cannot alias to another alias (single-level only)
|
||||||
|
if bot.state.get(_NS, target) is not None:
|
||||||
|
await bot.reply(message, f"'{target}' is itself an alias; no chaining")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Target must resolve to a real command
|
||||||
|
if target not in bot.registry.commands:
|
||||||
|
await bot.reply(message, f"unknown command: {target}")
|
||||||
|
return
|
||||||
|
|
||||||
|
bot.state.set(_NS, name, target)
|
||||||
|
await bot.reply(message, f"alias: {name} -> {target}")
|
||||||
|
|
||||||
|
elif sub == "del":
|
||||||
|
if len(parts) < 3:
|
||||||
|
await bot.reply(message, "Usage: !alias del <name>")
|
||||||
|
return
|
||||||
|
name = parts[2].lower()
|
||||||
|
if bot.state.delete(_NS, name):
|
||||||
|
await bot.reply(message, f"alias removed: {name}")
|
||||||
|
else:
|
||||||
|
await bot.reply(message, f"no alias: {name}")
|
||||||
|
|
||||||
|
elif sub == "list":
|
||||||
|
keys = bot.state.keys(_NS)
|
||||||
|
if not keys:
|
||||||
|
await bot.reply(message, "No aliases defined")
|
||||||
|
return
|
||||||
|
entries = []
|
||||||
|
for key in sorted(keys):
|
||||||
|
target = bot.state.get(_NS, key)
|
||||||
|
entries.append(f"{key} -> {target}")
|
||||||
|
await bot.reply(message, "Aliases: " + ", ".join(entries))
|
||||||
|
|
||||||
|
elif sub == "clear":
|
||||||
|
if not bot._is_admin(message):
|
||||||
|
await bot.reply(message, "Permission denied: clear requires admin")
|
||||||
|
return
|
||||||
|
count = bot.state.clear(_NS)
|
||||||
|
await bot.reply(message, f"Cleared {count} alias(es)")
|
||||||
|
|
||||||
|
else:
|
||||||
|
await bot.reply(message, "Usage: !alias <add|del|list|clear> [args]")
|
||||||
125
plugins/core.py
125
plugins/core.py
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
492
plugins/lastfm.py
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
"""Plugin: music discovery via Last.fm API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from derp.plugin import command
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE = "https://ws.audioscrobbler.com/2.0/"
|
||||||
|
|
||||||
|
|
||||||
|
# -- Config ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_key(bot) -> str:
|
||||||
|
"""Resolve Last.fm API key from env or config."""
|
||||||
|
return (os.environ.get("LASTFM_API_KEY", "")
|
||||||
|
or bot.config.get("lastfm", {}).get("api_key", ""))
|
||||||
|
|
||||||
|
|
||||||
|
# -- API helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _api_call(api_key: str, method: str, **params) -> dict:
|
||||||
|
"""Blocking Last.fm API call. Run in executor."""
|
||||||
|
from derp.http import urlopen
|
||||||
|
|
||||||
|
qs = urlencode({
|
||||||
|
"method": method,
|
||||||
|
"api_key": api_key,
|
||||||
|
"format": "json",
|
||||||
|
**params,
|
||||||
|
})
|
||||||
|
url = f"{_BASE}?{qs}"
|
||||||
|
try:
|
||||||
|
resp = urlopen(url, timeout=10)
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except Exception:
|
||||||
|
log.exception("lastfm: API call failed: %s", method)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_similar_artists(api_key: str, artist: str,
|
||||||
|
limit: int = 10) -> list[dict]:
|
||||||
|
"""Fetch similar artists for a given artist name."""
|
||||||
|
data = _api_call(api_key, "artist.getSimilar",
|
||||||
|
artist=artist, limit=str(limit))
|
||||||
|
artists = data.get("similarartists", {}).get("artist", [])
|
||||||
|
if isinstance(artists, dict):
|
||||||
|
artists = [artists]
|
||||||
|
return artists
|
||||||
|
|
||||||
|
|
||||||
|
def _get_top_tags(api_key: str, artist: str) -> list[dict]:
|
||||||
|
"""Fetch top tags for an artist."""
|
||||||
|
data = _api_call(api_key, "artist.getTopTags", artist=artist)
|
||||||
|
tags = data.get("toptags", {}).get("tag", [])
|
||||||
|
if isinstance(tags, dict):
|
||||||
|
tags = [tags]
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def _get_similar_tracks(api_key: str, artist: str, track: str,
|
||||||
|
limit: int = 10) -> list[dict]:
|
||||||
|
"""Fetch similar tracks for a given artist + track."""
|
||||||
|
data = _api_call(api_key, "track.getSimilar",
|
||||||
|
artist=artist, track=track, limit=str(limit))
|
||||||
|
tracks = data.get("similartracks", {}).get("track", [])
|
||||||
|
if isinstance(tracks, dict):
|
||||||
|
tracks = [tracks]
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
|
||||||
|
def _search_track(api_key: str, query: str,
|
||||||
|
limit: int = 5) -> list[dict]:
|
||||||
|
"""Search Last.fm for tracks matching a query."""
|
||||||
|
data = _api_call(api_key, "track.search",
|
||||||
|
track=query, limit=str(limit))
|
||||||
|
results = data.get("results", {}).get("trackmatches", {}).get("track", [])
|
||||||
|
if isinstance(results, dict):
|
||||||
|
results = [results]
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# -- Metadata extraction -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_title(raw_title: str) -> tuple[str, str]:
|
||||||
|
"""Split a raw track title into (artist, title).
|
||||||
|
|
||||||
|
Tries common separators: `` - ``, `` -- ``, `` | ``, `` ~ ``.
|
||||||
|
Returns ``("", raw_title)`` if no separator is found.
|
||||||
|
"""
|
||||||
|
for sep in (" - ", " -- ", " | ", " ~ "):
|
||||||
|
if sep in raw_title:
|
||||||
|
parts = raw_title.split(sep, 1)
|
||||||
|
return (parts[0].strip(), parts[1].strip())
|
||||||
|
return ("", raw_title)
|
||||||
|
|
||||||
|
|
||||||
|
def _music_bot(bot):
|
||||||
|
"""Return the bot instance that owns music playback.
|
||||||
|
|
||||||
|
Checks the calling bot first, then peer bots via the shared registry.
|
||||||
|
Returns the first bot with an active music state, or ``bot`` as fallback.
|
||||||
|
"""
|
||||||
|
candidates = [bot]
|
||||||
|
for peer in getattr(getattr(bot, "registry", None), "_bots", {}).values():
|
||||||
|
if peer is not bot:
|
||||||
|
candidates.append(peer)
|
||||||
|
for b in candidates:
|
||||||
|
music_ps = getattr(b, "_pstate", {}).get("music", {})
|
||||||
|
if music_ps.get("current") is not None or music_ps.get("queue"):
|
||||||
|
return b
|
||||||
|
# No active music state -- prefer a bot that allows the music plugin
|
||||||
|
for b in candidates:
|
||||||
|
only = getattr(b, "_only_plugins", None)
|
||||||
|
if only is not None and "music" in only:
|
||||||
|
return b
|
||||||
|
return bot
|
||||||
|
|
||||||
|
|
||||||
|
def _current_meta(bot) -> tuple[str, str]:
|
||||||
|
"""Extract artist and title from the currently playing track.
|
||||||
|
|
||||||
|
Returns (artist, title). Either or both may be empty.
|
||||||
|
Checks the music bot (via ``_music_bot``) for now-playing metadata.
|
||||||
|
"""
|
||||||
|
mb = _music_bot(bot)
|
||||||
|
music_ps = getattr(mb, "_pstate", {}).get("music", {})
|
||||||
|
current = music_ps.get("current")
|
||||||
|
if current is not None:
|
||||||
|
raw_title = current.title or ""
|
||||||
|
if raw_title:
|
||||||
|
return _parse_title(raw_title)
|
||||||
|
return ("", "")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Discovery orchestrator --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def discover_similar(bot, last_track_title: str) -> tuple[str, str] | None:
|
||||||
|
"""Find a similar track via Last.fm or MusicBrainz fallback.
|
||||||
|
|
||||||
|
Returns ``(artist, title)`` or ``None`` if nothing found.
|
||||||
|
"""
|
||||||
|
artist, title = _parse_title(last_track_title)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
# -- Last.fm path --
|
||||||
|
api_key = _get_api_key(bot)
|
||||||
|
if api_key and artist:
|
||||||
|
try:
|
||||||
|
similar = await loop.run_in_executor(
|
||||||
|
None, _get_similar_tracks, api_key, artist, title, 20,
|
||||||
|
)
|
||||||
|
if similar:
|
||||||
|
pick = random.choice(similar)
|
||||||
|
pick_artist = pick.get("artist", {}).get("name", "")
|
||||||
|
pick_title = pick.get("name", "")
|
||||||
|
if pick_artist and pick_title:
|
||||||
|
return (pick_artist, pick_title)
|
||||||
|
except Exception:
|
||||||
|
log.warning("lastfm: discover via Last.fm failed", exc_info=True)
|
||||||
|
|
||||||
|
# -- MusicBrainz fallback --
|
||||||
|
if artist:
|
||||||
|
try:
|
||||||
|
from plugins._musicbrainz import (
|
||||||
|
mb_artist_tags,
|
||||||
|
mb_find_similar_recordings,
|
||||||
|
mb_search_artist,
|
||||||
|
)
|
||||||
|
|
||||||
|
mbid = await loop.run_in_executor(None, mb_search_artist, artist)
|
||||||
|
if mbid:
|
||||||
|
tags = await loop.run_in_executor(None, mb_artist_tags, mbid)
|
||||||
|
if tags:
|
||||||
|
picks = await loop.run_in_executor(
|
||||||
|
None, mb_find_similar_recordings, artist, tags, 20,
|
||||||
|
)
|
||||||
|
if picks:
|
||||||
|
pick = random.choice(picks)
|
||||||
|
return (pick["artist"], pick["title"])
|
||||||
|
except Exception:
|
||||||
|
log.warning("lastfm: discover via MusicBrainz failed",
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# -- Formatting --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_match(m: float | str) -> str:
|
||||||
|
"""Format a Last.fm match score as a percentage."""
|
||||||
|
try:
|
||||||
|
return f"{float(m) * 100:.0f}%"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# -- Playlist helpers --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _search_queries(similar: list[dict], similar_artists: list[dict],
|
||||||
|
mb_results: list[dict], limit: int = 10) -> list[str]:
|
||||||
|
"""Normalize discovery results into YouTube search strings.
|
||||||
|
|
||||||
|
Processes track results (``{artist: {name}, name}``), artist results
|
||||||
|
(``{name}``), and MusicBrainz results (``{artist, title}``) into a
|
||||||
|
flat list of search query strings, up to *limit*.
|
||||||
|
"""
|
||||||
|
queries: list[str] = []
|
||||||
|
for t in similar:
|
||||||
|
a = t.get("artist", {}).get("name", "")
|
||||||
|
n = t.get("name", "")
|
||||||
|
q = f"{a} {n}".strip()
|
||||||
|
if q:
|
||||||
|
queries.append(q)
|
||||||
|
for a in similar_artists:
|
||||||
|
name = a.get("name", "")
|
||||||
|
if name:
|
||||||
|
queries.append(name)
|
||||||
|
for r in mb_results:
|
||||||
|
q = f"{r.get('artist', '')} {r.get('title', '')}".strip()
|
||||||
|
if q:
|
||||||
|
queries.append(q)
|
||||||
|
return queries[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_playlist(bot, queries: list[str],
|
||||||
|
requester: str) -> list:
|
||||||
|
"""Resolve search queries to Track objects via yt-dlp in parallel.
|
||||||
|
|
||||||
|
Uses the music plugin's ``_resolve_tracks`` and ``_Track`` to build
|
||||||
|
a playlist. Returns a list of ``_Track`` objects (empty on failure).
|
||||||
|
"""
|
||||||
|
music_mod = bot.registry._modules.get("music")
|
||||||
|
if not music_mod:
|
||||||
|
return []
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
resolve = music_mod._resolve_tracks
|
||||||
|
Track = music_mod._Track
|
||||||
|
|
||||||
|
pool = _get_yt_pool()
|
||||||
|
|
||||||
|
async def _resolve_one(query: str):
|
||||||
|
try:
|
||||||
|
pairs = await loop.run_in_executor(
|
||||||
|
pool, resolve, f"ytsearch1:{query}", 1,
|
||||||
|
)
|
||||||
|
if pairs:
|
||||||
|
url, title = pairs[0]
|
||||||
|
return Track(url=url, title=title, requester=requester)
|
||||||
|
except Exception:
|
||||||
|
log.debug("lastfm: resolve failed for %r", query)
|
||||||
|
return None
|
||||||
|
|
||||||
|
tasks = [_resolve_one(q) for q in queries]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
return [t for t in results if t is not None]
|
||||||
|
|
||||||
|
|
||||||
|
_yt_pool = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_yt_pool():
|
||||||
|
"""Lazy-init a shared ThreadPoolExecutor for yt-dlp resolution."""
|
||||||
|
global _yt_pool
|
||||||
|
if _yt_pool is None:
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
_yt_pool = ThreadPoolExecutor(max_workers=4)
|
||||||
|
return _yt_pool
|
||||||
|
|
||||||
|
|
||||||
|
async def _display_results(bot, message, similar: list[dict],
|
||||||
|
similar_artists: list[dict],
|
||||||
|
mb_results: list[dict],
|
||||||
|
artist: str, title: str) -> None:
|
||||||
|
"""Format and display discovery results (list mode)."""
|
||||||
|
if similar:
|
||||||
|
lines = [f"Similar to {artist} - {title}:"]
|
||||||
|
for t in similar[:8]:
|
||||||
|
t_artist = t.get("artist", {}).get("name", "")
|
||||||
|
t_name = t.get("name", "?")
|
||||||
|
match = _fmt_match(t.get("match", ""))
|
||||||
|
suffix = f" ({match})" if match else ""
|
||||||
|
lines.append(f" {t_artist} - {t_name}{suffix}")
|
||||||
|
await bot.long_reply(message, lines, label="similar tracks")
|
||||||
|
return
|
||||||
|
|
||||||
|
search_artist = artist or title
|
||||||
|
if similar_artists:
|
||||||
|
lines = [f"Similar to {search_artist}:"]
|
||||||
|
for a in similar_artists[:8]:
|
||||||
|
name = a.get("name", "?")
|
||||||
|
match = _fmt_match(a.get("match", ""))
|
||||||
|
suffix = f" ({match})" if match else ""
|
||||||
|
lines.append(f" {name}{suffix}")
|
||||||
|
await bot.long_reply(message, lines, label="similar artists")
|
||||||
|
return
|
||||||
|
|
||||||
|
if mb_results:
|
||||||
|
lines = [f"Similar to {search_artist}:"]
|
||||||
|
for r in mb_results[:8]:
|
||||||
|
lines.append(f" {r['artist']} - {r['title']}")
|
||||||
|
await bot.long_reply(message, lines, label="similar tracks")
|
||||||
|
return
|
||||||
|
|
||||||
|
await bot.reply(message, f"No similar artists found for '{search_artist}'")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Commands ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@command("similar", help="Music: !similar [list] [artist] -- discover & play similar music")
|
||||||
|
async def cmd_similar(bot, message):
|
||||||
|
"""Discover and play similar music.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
!similar Discover + play similar to current track
|
||||||
|
!similar <artist> Discover + play similar to named artist
|
||||||
|
!similar list Show similar (display only)
|
||||||
|
!similar list <artist> Show similar for named artist
|
||||||
|
"""
|
||||||
|
api_key = _get_api_key(bot)
|
||||||
|
|
||||||
|
parts = message.text.split(None, 2)
|
||||||
|
# !similar list [artist]
|
||||||
|
list_mode = len(parts) >= 2 and parts[1].lower() == "list"
|
||||||
|
if list_mode:
|
||||||
|
query = parts[2].strip() if len(parts) > 2 else ""
|
||||||
|
else:
|
||||||
|
query = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
# Resolve artist from query or current track
|
||||||
|
if query:
|
||||||
|
artist = query
|
||||||
|
title = ""
|
||||||
|
else:
|
||||||
|
artist, title = _current_meta(bot)
|
||||||
|
if not artist and not title:
|
||||||
|
await bot.reply(message, "Nothing playing and no artist given")
|
||||||
|
return
|
||||||
|
|
||||||
|
# -- Last.fm path --
|
||||||
|
similar: list[dict] = []
|
||||||
|
similar_artists: list[dict] = []
|
||||||
|
if api_key:
|
||||||
|
# Try track-level similarity first if we have both artist + title
|
||||||
|
if artist and title:
|
||||||
|
similar = await loop.run_in_executor(
|
||||||
|
None, _get_similar_tracks, api_key, artist, title,
|
||||||
|
)
|
||||||
|
# Fall back to artist-level similarity
|
||||||
|
if not similar:
|
||||||
|
search_artist = artist or title
|
||||||
|
similar_artists = await loop.run_in_executor(
|
||||||
|
None, _get_similar_artists, api_key, search_artist,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- MusicBrainz fallback --
|
||||||
|
mb_results: list[dict] = []
|
||||||
|
if not similar and not similar_artists:
|
||||||
|
search_artist = artist or title
|
||||||
|
try:
|
||||||
|
from plugins._musicbrainz import (
|
||||||
|
mb_artist_tags,
|
||||||
|
mb_find_similar_recordings,
|
||||||
|
mb_search_artist,
|
||||||
|
)
|
||||||
|
mbid = await loop.run_in_executor(
|
||||||
|
None, mb_search_artist, search_artist,
|
||||||
|
)
|
||||||
|
if mbid:
|
||||||
|
tags = await loop.run_in_executor(None, mb_artist_tags, mbid)
|
||||||
|
if tags:
|
||||||
|
mb_results = await loop.run_in_executor(
|
||||||
|
None, mb_find_similar_recordings, search_artist,
|
||||||
|
tags, 20,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.warning("lastfm: MusicBrainz fallback failed", exc_info=True)
|
||||||
|
|
||||||
|
# Nothing found at all
|
||||||
|
if not similar and not similar_artists and not mb_results:
|
||||||
|
search_artist = artist or title
|
||||||
|
await bot.reply(message, f"No similar artists found for '{search_artist}'")
|
||||||
|
return
|
||||||
|
|
||||||
|
# -- List mode (display only) --
|
||||||
|
if list_mode:
|
||||||
|
await _display_results(bot, message, similar, similar_artists,
|
||||||
|
mb_results, artist, title)
|
||||||
|
return
|
||||||
|
|
||||||
|
# -- Play mode (default): build playlist and transition --
|
||||||
|
search_artist = artist or title
|
||||||
|
queries = _search_queries(similar, similar_artists, mb_results, limit=10)
|
||||||
|
if not queries:
|
||||||
|
await bot.reply(message, f"No similar artists found for '{search_artist}'")
|
||||||
|
return
|
||||||
|
|
||||||
|
music_mod = bot.registry._modules.get("music")
|
||||||
|
if not music_mod:
|
||||||
|
# No music plugin -- fall back to display
|
||||||
|
await _display_results(bot, message, similar, similar_artists,
|
||||||
|
mb_results, artist, title)
|
||||||
|
return
|
||||||
|
|
||||||
|
await bot.reply(message, f"Discovering similar to {search_artist}...")
|
||||||
|
tracks = await _resolve_playlist(bot, queries, message.nick)
|
||||||
|
if not tracks:
|
||||||
|
await bot.reply(message, "No playable tracks resolved")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Transition on the music bot (derp), not the calling bot (may be merlin)
|
||||||
|
dj = _music_bot(bot)
|
||||||
|
ps = music_mod._ps(dj)
|
||||||
|
await music_mod._fade_and_cancel(dj, duration=3.0)
|
||||||
|
ps["queue"].clear()
|
||||||
|
ps["current"] = None
|
||||||
|
ps["queue"] = list(tracks)
|
||||||
|
music_mod._ensure_loop(dj, fade_in=True)
|
||||||
|
await bot.reply(message, f"Playing {len(tracks)} similar tracks for {search_artist}")
|
||||||
|
|
||||||
|
|
||||||
|
@command("tags", help="Music: !tags [artist] -- show genre tags")
|
||||||
|
async def cmd_tags(bot, message):
|
||||||
|
"""Show genre/style tags for an artist.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
!tags Tags for currently playing artist
|
||||||
|
!tags <artist> Tags for named artist
|
||||||
|
"""
|
||||||
|
api_key = _get_api_key(bot)
|
||||||
|
|
||||||
|
parts = message.text.split(None, 1)
|
||||||
|
query = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
if query:
|
||||||
|
artist = query
|
||||||
|
else:
|
||||||
|
artist, title = _current_meta(bot)
|
||||||
|
artist = artist or title
|
||||||
|
if not artist:
|
||||||
|
await bot.reply(message, "Nothing playing and no artist given")
|
||||||
|
return
|
||||||
|
|
||||||
|
# -- Last.fm path --
|
||||||
|
tags = []
|
||||||
|
if api_key:
|
||||||
|
tags = await loop.run_in_executor(
|
||||||
|
None, _get_top_tags, api_key, artist,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- MusicBrainz fallback --
|
||||||
|
if not tags:
|
||||||
|
try:
|
||||||
|
from plugins._musicbrainz import mb_artist_tags, mb_search_artist
|
||||||
|
mbid = await loop.run_in_executor(None, mb_search_artist, artist)
|
||||||
|
if mbid:
|
||||||
|
mb_tags = await loop.run_in_executor(
|
||||||
|
None, mb_artist_tags, mbid,
|
||||||
|
)
|
||||||
|
if mb_tags:
|
||||||
|
await bot.reply(message, f"{artist}: {', '.join(mb_tags)}")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
log.warning("lastfm: MusicBrainz tag fallback failed",
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
|
if not tags:
|
||||||
|
await bot.reply(message, f"No tags found for '{artist}'")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show top tags with counts
|
||||||
|
tag_names = [t.get("name", "?") for t in tags[:10] if t.get("name")]
|
||||||
|
await bot.reply(message, f"{artist}: {', '.join(tag_names)}")
|
||||||
298
plugins/llm.py
Normal file
298
plugins/llm.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"""Plugin: LLM chat via OpenRouter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from derp.http import urlopen as _urlopen
|
||||||
|
from derp.plugin import command
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# -- Constants ---------------------------------------------------------------
|
||||||
|
|
||||||
|
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
_DEFAULT_MODEL = "openrouter/auto"
|
||||||
|
_TIMEOUT = 30
|
||||||
|
_MAX_HISTORY = 20
|
||||||
|
_MAX_REPLY_LEN = 400
|
||||||
|
_COOLDOWN = 5
|
||||||
|
|
||||||
|
_DEFAULT_SYSTEM = (
|
||||||
|
"You are a helpful IRC bot assistant. Keep responses concise and under 200 words."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Per-bot runtime state ---------------------------------------------------
|
||||||
|
|
||||||
|
def _ps(bot):
|
||||||
|
"""Per-bot plugin runtime state."""
|
||||||
|
return bot._pstate.setdefault("llm", {
|
||||||
|
"histories": {}, # {nick: [{"role": ..., "content": ...}, ...]}
|
||||||
|
"cooldowns": {}, # {nick: monotonic_ts}
|
||||||
|
"model": "", # override per-bot; empty = use default
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# -- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_api_key(bot) -> str:
|
||||||
|
"""Resolve API key from env or config."""
|
||||||
|
return (
|
||||||
|
os.environ.get("OPENROUTER_API_KEY", "")
|
||||||
|
or bot.config.get("openrouter", {}).get("api_key", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_model(bot) -> str:
|
||||||
|
"""Resolve current model."""
|
||||||
|
ps = _ps(bot)
|
||||||
|
return (
|
||||||
|
ps["model"]
|
||||||
|
or bot.config.get("openrouter", {}).get("model", "")
|
||||||
|
or _DEFAULT_MODEL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_system_prompt(bot) -> str:
|
||||||
|
"""Resolve system prompt from config or default."""
|
||||||
|
return bot.config.get("openrouter", {}).get("system_prompt", _DEFAULT_SYSTEM)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, max_len: int = _MAX_REPLY_LEN) -> str:
|
||||||
|
"""Truncate text with ellipsis if needed."""
|
||||||
|
if len(text) <= max_len:
|
||||||
|
return text
|
||||||
|
return text[: max_len - 3].rstrip() + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def _check_cooldown(bot, nick: str) -> bool:
|
||||||
|
"""Return True if the user is within cooldown period."""
|
||||||
|
ps = _ps(bot)
|
||||||
|
last = ps["cooldowns"].get(nick, 0)
|
||||||
|
return (time.monotonic() - last) < _COOLDOWN
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cooldown(bot, nick: str) -> None:
|
||||||
|
"""Record a cooldown timestamp for a user."""
|
||||||
|
_ps(bot)["cooldowns"][nick] = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Blocking HTTP call ------------------------------------------------------
|
||||||
|
|
||||||
|
def _chat_request(api_key: str, model: str, messages: list[dict]) -> dict:
|
||||||
|
"""Blocking OpenRouter chat completion. Run via executor.
|
||||||
|
|
||||||
|
Returns the parsed JSON response dict.
|
||||||
|
Raises on HTTP or connection errors.
|
||||||
|
"""
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(_API_URL, data=payload, method="POST")
|
||||||
|
req.add_header("Authorization", f"Bearer {api_key}")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp = _urlopen(req, timeout=_TIMEOUT)
|
||||||
|
raw = resp.read()
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_reply(data: dict) -> str:
|
||||||
|
"""Extract reply text from OpenRouter response.
|
||||||
|
|
||||||
|
Handles reasoning models that return content="" with a reasoning field.
|
||||||
|
"""
|
||||||
|
choices = data.get("choices", [])
|
||||||
|
if not choices:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
msg = choices[0].get("message", {})
|
||||||
|
content = (msg.get("content") or "").strip()
|
||||||
|
if content:
|
||||||
|
return content
|
||||||
|
|
||||||
|
# Fallback for reasoning models
|
||||||
|
reasoning = (msg.get("reasoning") or "").strip()
|
||||||
|
return reasoning
|
||||||
|
|
||||||
|
|
||||||
|
# -- Command handlers --------------------------------------------------------
|
||||||
|
|
||||||
|
@command("ask", help="Ask: !ask <question>")
|
||||||
|
async def cmd_ask(bot, message):
|
||||||
|
"""Single-shot LLM question (no history).
|
||||||
|
|
||||||
|
Usage: !ask <question>
|
||||||
|
"""
|
||||||
|
parts = message.text.split(None, 1)
|
||||||
|
if len(parts) < 2 or not parts[1].strip():
|
||||||
|
await bot.reply(message, "Usage: !ask <question>")
|
||||||
|
return
|
||||||
|
|
||||||
|
api_key = _get_api_key(bot)
|
||||||
|
if not api_key:
|
||||||
|
await bot.reply(message, "OpenRouter API key not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
nick = message.nick
|
||||||
|
if _check_cooldown(bot, nick):
|
||||||
|
await bot.reply(message, "Cooldown -- wait a few seconds")
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt = parts[1].strip()
|
||||||
|
model = _get_model(bot)
|
||||||
|
system = _get_system_prompt(bot)
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
]
|
||||||
|
|
||||||
|
_set_cooldown(bot, nick)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
data = await loop.run_in_executor(
|
||||||
|
None, _chat_request, api_key, model, messages,
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code == 429:
|
||||||
|
await bot.reply(message, "Rate limited by OpenRouter -- try again later")
|
||||||
|
else:
|
||||||
|
await bot.reply(message, f"API error: HTTP {exc.code}")
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
_log.warning("LLM request failed: %s", exc)
|
||||||
|
await bot.reply(message, f"Request failed: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = _extract_reply(data)
|
||||||
|
if not reply:
|
||||||
|
await bot.reply(message, "No response from model")
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = _truncate(reply).split("\n")
|
||||||
|
await bot.long_reply(message, lines, label="llm")
|
||||||
|
|
||||||
|
|
||||||
|
@command("chat", help="Chat: !chat <msg> | clear | model [name] | models")
|
||||||
|
async def cmd_chat(bot, message):
|
||||||
|
"""Conversational LLM chat with per-user history.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
!chat <message> Send a message (maintains history)
|
||||||
|
!chat clear Clear your conversation history
|
||||||
|
!chat model Show current model
|
||||||
|
!chat model <name> Switch model
|
||||||
|
!chat models List popular free models
|
||||||
|
"""
|
||||||
|
parts = message.text.split(None, 2)
|
||||||
|
if len(parts) < 2 or not parts[1].strip():
|
||||||
|
await bot.reply(message, "Usage: !chat <message> | clear | model [name] | models")
|
||||||
|
return
|
||||||
|
|
||||||
|
sub = parts[1].strip().lower()
|
||||||
|
|
||||||
|
# -- Subcommands ---------------------------------------------------------
|
||||||
|
|
||||||
|
if sub == "clear":
|
||||||
|
ps = _ps(bot)
|
||||||
|
nick = message.nick
|
||||||
|
if nick in ps["histories"]:
|
||||||
|
del ps["histories"][nick]
|
||||||
|
await bot.reply(message, "Conversation cleared")
|
||||||
|
return
|
||||||
|
|
||||||
|
if sub == "model":
|
||||||
|
if len(parts) > 2 and parts[2].strip():
|
||||||
|
new_model = parts[2].strip()
|
||||||
|
_ps(bot)["model"] = new_model
|
||||||
|
await bot.reply(message, f"Model set to: {new_model}")
|
||||||
|
else:
|
||||||
|
await bot.reply(message, f"Current model: {_get_model(bot)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if sub == "models":
|
||||||
|
models = [
|
||||||
|
"openrouter/auto -- auto-route to best available",
|
||||||
|
"google/gemma-3-27b-it:free",
|
||||||
|
"meta-llama/llama-3.3-70b-instruct:free",
|
||||||
|
"deepseek/deepseek-r1:free",
|
||||||
|
"qwen/qwen3-235b-a22b:free",
|
||||||
|
"mistralai/mistral-small-3.1-24b-instruct:free",
|
||||||
|
]
|
||||||
|
await bot.long_reply(message, models, label="models")
|
||||||
|
return
|
||||||
|
|
||||||
|
# -- Chat path -----------------------------------------------------------
|
||||||
|
|
||||||
|
api_key = _get_api_key(bot)
|
||||||
|
if not api_key:
|
||||||
|
await bot.reply(message, "OpenRouter API key not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
nick = message.nick
|
||||||
|
if _check_cooldown(bot, nick):
|
||||||
|
await bot.reply(message, "Cooldown -- wait a few seconds")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reconstruct full user text (sub might be part of the message)
|
||||||
|
user_text = message.text.split(None, 1)[1].strip()
|
||||||
|
|
||||||
|
ps = _ps(bot)
|
||||||
|
history = ps["histories"].setdefault(nick, [])
|
||||||
|
|
||||||
|
# Build messages
|
||||||
|
system = _get_system_prompt(bot)
|
||||||
|
history.append({"role": "user", "content": user_text})
|
||||||
|
|
||||||
|
# Cap history
|
||||||
|
if len(history) > _MAX_HISTORY:
|
||||||
|
history[:] = history[-_MAX_HISTORY:]
|
||||||
|
|
||||||
|
messages = [{"role": "system", "content": system}] + history
|
||||||
|
|
||||||
|
model = _get_model(bot)
|
||||||
|
_set_cooldown(bot, nick)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
data = await loop.run_in_executor(
|
||||||
|
None, _chat_request, api_key, model, messages,
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
# Remove the failed user message from history
|
||||||
|
history.pop()
|
||||||
|
if exc.code == 429:
|
||||||
|
await bot.reply(message, "Rate limited by OpenRouter -- try again later")
|
||||||
|
else:
|
||||||
|
await bot.reply(message, f"API error: HTTP {exc.code}")
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
history.pop()
|
||||||
|
_log.warning("LLM request failed: %s", exc)
|
||||||
|
await bot.reply(message, f"Request failed: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = _extract_reply(data)
|
||||||
|
if not reply:
|
||||||
|
history.pop()
|
||||||
|
await bot.reply(message, "No response from model")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store assistant reply in history
|
||||||
|
history.append({"role": "assistant", "content": reply})
|
||||||
|
if len(history) > _MAX_HISTORY:
|
||||||
|
history[:] = history[-_MAX_HISTORY:]
|
||||||
|
|
||||||
|
lines = _truncate(reply).split("\n")
|
||||||
|
await bot.long_reply(message, lines, label="llm")
|
||||||
276
plugins/mumble_admin.py
Normal file
276
plugins/mumble_admin.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"""Plugin: Mumble server administration via chat commands."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from derp.plugin import command
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _find_user(bot, name: str):
|
||||||
|
"""Case-insensitive user lookup by name. Returns pymumble User or None."""
|
||||||
|
mumble = getattr(bot, "_mumble", None)
|
||||||
|
if mumble is None:
|
||||||
|
return None
|
||||||
|
lower = name.lower()
|
||||||
|
for sid in list(mumble.users):
|
||||||
|
user = mumble.users[sid]
|
||||||
|
if user["name"].lower() == lower:
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_channel(bot, name: str):
|
||||||
|
"""Case-insensitive channel lookup by name. Returns pymumble Channel or None."""
|
||||||
|
mumble = getattr(bot, "_mumble", None)
|
||||||
|
if mumble is None:
|
||||||
|
return None
|
||||||
|
lower = name.lower()
|
||||||
|
for cid in list(mumble.channels):
|
||||||
|
chan = mumble.channels[cid]
|
||||||
|
if chan["name"].lower() == lower:
|
||||||
|
return chan
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_name(bot, channel_id: int) -> str:
|
||||||
|
"""Resolve a channel ID to its name, or return the ID as string."""
|
||||||
|
mumble = getattr(bot, "_mumble", None)
|
||||||
|
if mumble is None:
|
||||||
|
return str(channel_id)
|
||||||
|
chan = mumble.channels.get(channel_id)
|
||||||
|
if chan is None:
|
||||||
|
return str(channel_id)
|
||||||
|
return chan["name"]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Sub-handlers ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_kick(bot, message, args: list[str]) -> None:
|
||||||
|
if not args:
|
||||||
|
await bot.reply(message, "Usage: !mu kick <user> [reason]")
|
||||||
|
return
|
||||||
|
user = _find_user(bot, args[0])
|
||||||
|
if user is None:
|
||||||
|
await bot.reply(message, f"User not found: {args[0]}")
|
||||||
|
return
|
||||||
|
reason = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
|
user.kick(reason)
|
||||||
|
await bot.reply(message, f"Kicked {user['name']}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_ban(bot, message, args: list[str]) -> None:
|
||||||
|
if not args:
|
||||||
|
await bot.reply(message, "Usage: !mu ban <user> [reason]")
|
||||||
|
return
|
||||||
|
user = _find_user(bot, args[0])
|
||||||
|
if user is None:
|
||||||
|
await bot.reply(message, f"User not found: {args[0]}")
|
||||||
|
return
|
||||||
|
reason = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
|
user.ban(reason)
|
||||||
|
await bot.reply(message, f"Banned {user['name']}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_mute(bot, message, args: list[str]) -> None:
|
||||||
|
if not args:
|
||||||
|
await bot.reply(message, "Usage: !mu mute <user>")
|
||||||
|
return
|
||||||
|
user = _find_user(bot, args[0])
|
||||||
|
if user is None:
|
||||||
|
await bot.reply(message, f"User not found: {args[0]}")
|
||||||
|
return
|
||||||
|
user.mute()
|
||||||
|
await bot.reply(message, f"Muted {user['name']}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_unmute(bot, message, args: list[str]) -> None:
|
||||||
|
if not args:
|
||||||
|
await bot.reply(message, "Usage: !mu unmute <user>")
|
||||||
|
return
|
||||||
|
user = _find_user(bot, args[0])
|
||||||
|
if user is None:
|
||||||
|
await bot.reply(message, f"User not found: {args[0]}")
|
||||||
|
return
|
||||||
|
user.unmute()
|
||||||
|
await bot.reply(message, f"Unmuted {user['name']}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_deafen(bot, message, args: list[str]) -> None:
|
||||||
|
if not args:
|
||||||
|
await bot.reply(message, "Usage: !mu deafen <user>")
|
||||||
|
return
|
||||||
|
user = _find_user(bot, args[0])
|
||||||
|
if user is None:
|
||||||
|
await bot.reply(message, f"User not found: {args[0]}")
|
||||||
|
return
|
||||||
|
user.deafen()
|
||||||
|
await bot.reply(message, f"Deafened {user['name']}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_undeafen(bot, message, args: list[str]) -> None:
|
||||||
|
if not args:
|
||||||
|
await bot.reply(message, "Usage: !mu undeafen <user>")
|
||||||
|
return
|
||||||
|
user = _find_user(bot, args[0])
|
||||||
|
if user is None:
|
||||||
|
await bot.reply(message, f"User not found: {args[0]}")
|
||||||
|
return
|
||||||
|
user.undeafen()
|
||||||
|
await bot.reply(message, f"Undeafened {user['name']}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_move(bot, message, args: list[str]) -> None:
|
||||||
|
if len(args) < 2:
|
||||||
|
await bot.reply(message, "Usage: !mu move <user> <channel>")
|
||||||
|
return
|
||||||
|
user = _find_user(bot, args[0])
|
||||||
|
if user is None:
|
||||||
|
await bot.reply(message, f"User not found: {args[0]}")
|
||||||
|
return
|
||||||
|
chan = _find_channel(bot, " ".join(args[1:]))
|
||||||
|
if chan is None:
|
||||||
|
await bot.reply(message, f"Channel not found: {' '.join(args[1:])}")
|
||||||
|
return
|
||||||
|
user.move_in(chan["channel_id"])
|
||||||
|
await bot.reply(message, f"Moved {user['name']} to {chan['name']}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_users(bot, message, args: list[str]) -> None:
|
||||||
|
mumble = getattr(bot, "_mumble", None)
|
||||||
|
if mumble is None:
|
||||||
|
return
|
||||||
|
bots = getattr(bot.registry, "_bots", {})
|
||||||
|
lines: list[str] = []
|
||||||
|
for sid in sorted(mumble.users):
|
||||||
|
user = mumble.users[sid]
|
||||||
|
name = user["name"]
|
||||||
|
flags: list[str] = []
|
||||||
|
if name in bots:
|
||||||
|
flags.append("bot")
|
||||||
|
if user.get("mute") or user.get("self_mute"):
|
||||||
|
flags.append("muted")
|
||||||
|
if user.get("deaf") or user.get("self_deaf"):
|
||||||
|
flags.append("deaf")
|
||||||
|
chan = _channel_name(bot, user.get("channel_id", 0))
|
||||||
|
tag = f" [{', '.join(flags)}]" if flags else ""
|
||||||
|
lines.append(f" {name} in {chan}{tag}")
|
||||||
|
header = f"Online: {len(lines)} user(s)"
|
||||||
|
await bot.reply(message, header + "\n" + "\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_channels(bot, message, args: list[str]) -> None:
|
||||||
|
mumble = getattr(bot, "_mumble", None)
|
||||||
|
if mumble is None:
|
||||||
|
return
|
||||||
|
lines: list[str] = []
|
||||||
|
for cid in sorted(mumble.channels):
|
||||||
|
chan = mumble.channels[cid]
|
||||||
|
name = chan["name"]
|
||||||
|
# Count users in this channel
|
||||||
|
count = sum(
|
||||||
|
1 for sid in mumble.users
|
||||||
|
if mumble.users[sid].get("channel_id") == cid
|
||||||
|
)
|
||||||
|
lines.append(f" {name} ({count})")
|
||||||
|
await bot.reply(message, "Channels:\n" + "\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_mkchan(bot, message, args: list[str]) -> None:
|
||||||
|
if not args:
|
||||||
|
await bot.reply(message, "Usage: !mu mkchan <name> [temp]")
|
||||||
|
return
|
||||||
|
mumble = getattr(bot, "_mumble", None)
|
||||||
|
if mumble is None:
|
||||||
|
return
|
||||||
|
name = args[0]
|
||||||
|
temp = len(args) > 1 and args[1].lower() in ("temp", "temporary", "true")
|
||||||
|
mumble.channels.new_channel(0, name, temporary=temp)
|
||||||
|
label = " (temporary)" if temp else ""
|
||||||
|
await bot.reply(message, f"Created channel: {name}{label}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_rmchan(bot, message, args: list[str]) -> None:
|
||||||
|
if not args:
|
||||||
|
await bot.reply(message, "Usage: !mu rmchan <channel>")
|
||||||
|
return
|
||||||
|
chan = _find_channel(bot, " ".join(args))
|
||||||
|
if chan is None:
|
||||||
|
await bot.reply(message, f"Channel not found: {' '.join(args)}")
|
||||||
|
return
|
||||||
|
name = chan["name"]
|
||||||
|
chan.remove()
|
||||||
|
await bot.reply(message, f"Removed channel: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_rename(bot, message, args: list[str]) -> None:
|
||||||
|
if len(args) < 2:
|
||||||
|
await bot.reply(message, "Usage: !mu rename <channel> <new-name>")
|
||||||
|
return
|
||||||
|
chan = _find_channel(bot, args[0])
|
||||||
|
if chan is None:
|
||||||
|
await bot.reply(message, f"Channel not found: {args[0]}")
|
||||||
|
return
|
||||||
|
old = chan["name"]
|
||||||
|
chan.rename_channel(args[1])
|
||||||
|
await bot.reply(message, f"Renamed {old} to {args[1]}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_desc(bot, message, args: list[str]) -> None:
|
||||||
|
if len(args) < 2:
|
||||||
|
await bot.reply(message, "Usage: !mu desc <channel> <text>")
|
||||||
|
return
|
||||||
|
chan = _find_channel(bot, args[0])
|
||||||
|
if chan is None:
|
||||||
|
await bot.reply(message, f"Channel not found: {args[0]}")
|
||||||
|
return
|
||||||
|
text = " ".join(args[1:])
|
||||||
|
chan.set_channel_description(text)
|
||||||
|
await bot.reply(message, f"Set description for {chan['name']}")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Dispatch table ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_SUBS: dict[str, object] = {
|
||||||
|
"kick": _sub_kick,
|
||||||
|
"ban": _sub_ban,
|
||||||
|
"mute": _sub_mute,
|
||||||
|
"unmute": _sub_unmute,
|
||||||
|
"deafen": _sub_deafen,
|
||||||
|
"undeafen": _sub_undeafen,
|
||||||
|
"move": _sub_move,
|
||||||
|
"users": _sub_users,
|
||||||
|
"channels": _sub_channels,
|
||||||
|
"mkchan": _sub_mkchan,
|
||||||
|
"rmchan": _sub_rmchan,
|
||||||
|
"rename": _sub_rename,
|
||||||
|
"desc": _sub_desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
_USAGE = (
|
||||||
|
"Usage: !mu <action> [args]\n"
|
||||||
|
"Actions: kick, ban, mute, unmute, deafen, undeafen, move, "
|
||||||
|
"users, channels, mkchan, rmchan, rename, desc"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@command("mu", help="Mumble admin: !mu <action> [args]", tier="admin")
|
||||||
|
async def cmd_mu(bot, message):
|
||||||
|
"""Mumble server administration commands."""
|
||||||
|
parts = message.text.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
await bot.reply(message, _USAGE)
|
||||||
|
return
|
||||||
|
sub = parts[1].lower()
|
||||||
|
handler = _SUBS.get(sub)
|
||||||
|
if handler is None:
|
||||||
|
await bot.reply(message, _USAGE)
|
||||||
|
return
|
||||||
|
await handler(bot, message, parts[2:])
|
||||||
1924
plugins/music.py
Normal file
1924
plugins/music.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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})")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
617
plugins/voice.py
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
"""Plugin: voice STT/TTS for Mumble channels.
|
||||||
|
|
||||||
|
Listens for voice audio via pymumble's sound callback, buffers PCM per
|
||||||
|
user, transcribes via Whisper STT on silence, and provides TTS playback
|
||||||
|
via Piper. Commands: !listen, !say.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import wave
|
||||||
|
|
||||||
|
from derp.http import urlopen as _urlopen
|
||||||
|
from derp.plugin import command
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# -- Constants ---------------------------------------------------------------
|
||||||
|
|
||||||
|
_SAMPLE_RATE = 48000
|
||||||
|
_CHANNELS = 1
|
||||||
|
_SAMPLE_WIDTH = 2 # s16le = 2 bytes per sample
|
||||||
|
|
||||||
|
_SILENCE_GAP = 1.5 # seconds of silence before flushing
|
||||||
|
_MIN_DURATION = 0.5 # discard utterances shorter than this
|
||||||
|
_MAX_DURATION = 30.0 # cap buffer at this many seconds
|
||||||
|
_MIN_BYTES = int(_MIN_DURATION * _SAMPLE_RATE * _SAMPLE_WIDTH)
|
||||||
|
_MAX_BYTES = int(_MAX_DURATION * _SAMPLE_RATE * _SAMPLE_WIDTH)
|
||||||
|
_FLUSH_INTERVAL = 0.5 # flush monitor poll interval
|
||||||
|
_MAX_SAY_LEN = 500 # max characters for !say
|
||||||
|
|
||||||
|
_WHISPER_URL = "http://192.168.129.9:8080/inference"
|
||||||
|
_PIPER_URL = "http://192.168.129.9:5100/"
|
||||||
|
|
||||||
|
|
||||||
|
# -- Per-bot state -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ps(bot):
|
||||||
|
"""Per-bot plugin runtime state."""
|
||||||
|
cfg = getattr(bot, "config", {}).get("voice", {})
|
||||||
|
trigger = cfg.get("trigger", "")
|
||||||
|
# Bias Whisper toward the trigger word unless explicitly configured
|
||||||
|
default_prompt = f"{trigger.capitalize()}, " if trigger else ""
|
||||||
|
return bot._pstate.setdefault("voice", {
|
||||||
|
"listen": False,
|
||||||
|
"trigger": trigger,
|
||||||
|
"buffers": {}, # {username: bytearray}
|
||||||
|
"last_ts": {}, # {username: float monotonic}
|
||||||
|
"flush_task": None,
|
||||||
|
"lock": threading.Lock(),
|
||||||
|
"silence_gap": cfg.get("silence_gap", _SILENCE_GAP),
|
||||||
|
"whisper_url": cfg.get("whisper_url", _WHISPER_URL),
|
||||||
|
"piper_url": cfg.get("piper_url", _PIPER_URL),
|
||||||
|
"voice": cfg.get("voice", ""),
|
||||||
|
"length_scale": cfg.get("length_scale", 1.0),
|
||||||
|
"noise_scale": cfg.get("noise_scale", 0.667),
|
||||||
|
"noise_w": cfg.get("noise_w", 0.8),
|
||||||
|
"fx": cfg.get("fx", ""),
|
||||||
|
"initial_prompt": cfg.get("initial_prompt", default_prompt),
|
||||||
|
"_listener_registered": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# -- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mumble(bot) -> bool:
|
||||||
|
"""Check if bot supports voice streaming."""
|
||||||
|
return hasattr(bot, "stream_audio")
|
||||||
|
|
||||||
|
|
||||||
|
def _pcm_to_wav(pcm: bytes) -> bytes:
|
||||||
|
"""Wrap raw s16le 48kHz mono PCM in a WAV container."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with wave.open(buf, "wb") as wf:
|
||||||
|
wf.setnchannels(_CHANNELS)
|
||||||
|
wf.setsampwidth(_SAMPLE_WIDTH)
|
||||||
|
wf.setframerate(_SAMPLE_RATE)
|
||||||
|
wf.writeframes(pcm)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Acknowledge tone --------------------------------------------------------
|
||||||
|
|
||||||
|
_ACK_FREQ = (880, 1320) # A5 -> E6 ascending
|
||||||
|
_ACK_NOTE_DUR = 0.15 # seconds per note
|
||||||
|
_ACK_AMP = 12000 # gentle amplitude
|
||||||
|
_ACK_FRAME = 960 # 20ms at 48kHz, matches Mumble native
|
||||||
|
|
||||||
|
|
||||||
|
async def _ack_tone(bot) -> None:
|
||||||
|
"""Play a short two-tone ascending chime via pymumble sound_output."""
|
||||||
|
mu = getattr(bot, "_mumble", None)
|
||||||
|
if mu is None:
|
||||||
|
return
|
||||||
|
so = mu.sound_output
|
||||||
|
if so is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Unmute if self-muted (stream_audio handles re-mute later)
|
||||||
|
if getattr(bot, "_self_mute", False):
|
||||||
|
if bot._mute_task and not bot._mute_task.done():
|
||||||
|
bot._mute_task.cancel()
|
||||||
|
bot._mute_task = None
|
||||||
|
try:
|
||||||
|
mu.users.myself.unmute()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
frames_per_note = int(_ACK_NOTE_DUR / 0.02) # 0.02s per frame
|
||||||
|
for freq in _ACK_FREQ:
|
||||||
|
for i in range(frames_per_note):
|
||||||
|
samples = []
|
||||||
|
for j in range(_ACK_FRAME):
|
||||||
|
t = (i * _ACK_FRAME + j) / _SAMPLE_RATE
|
||||||
|
samples.append(int(_ACK_AMP * math.sin(2 * math.pi * freq * t)))
|
||||||
|
pcm = struct.pack(f"<{_ACK_FRAME}h", *samples)
|
||||||
|
so.add_sound(pcm)
|
||||||
|
while so.get_buffer_size() > 0.5:
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
# Wait for tone to finish
|
||||||
|
while so.get_buffer_size() > 0:
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
# -- STT: Sound listener (pymumble thread) ----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _on_voice(bot, user, sound_chunk):
|
||||||
|
"""Buffer incoming voice PCM per user. Runs on pymumble thread."""
|
||||||
|
ps = _ps(bot)
|
||||||
|
if not ps["listen"] and not ps["trigger"]:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
name = user["name"]
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
name = None
|
||||||
|
if not name or name == bot.nick:
|
||||||
|
return
|
||||||
|
pcm = sound_chunk.pcm
|
||||||
|
if not pcm:
|
||||||
|
return
|
||||||
|
with ps["lock"]:
|
||||||
|
if name not in ps["buffers"]:
|
||||||
|
ps["buffers"][name] = bytearray()
|
||||||
|
buf = ps["buffers"][name]
|
||||||
|
buf.extend(pcm)
|
||||||
|
if len(buf) > _MAX_BYTES:
|
||||||
|
ps["buffers"][name] = bytearray(buf[-_MAX_BYTES:])
|
||||||
|
ps["last_ts"][name] = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
# -- STT: Whisper transcription ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _transcribe(ps, pcm: bytes) -> str:
|
||||||
|
"""POST PCM (as WAV) to Whisper and return transcribed text. Blocking."""
|
||||||
|
wav_data = _pcm_to_wav(pcm)
|
||||||
|
boundary = "----derp_voice_boundary"
|
||||||
|
body = (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="file"; filename="audio.wav"\r\n'
|
||||||
|
f"Content-Type: audio/wav\r\n\r\n"
|
||||||
|
).encode() + wav_data + (
|
||||||
|
f"\r\n--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="response_format"\r\n\r\n'
|
||||||
|
f"json"
|
||||||
|
).encode()
|
||||||
|
# Bias Whisper toward the trigger word when configured
|
||||||
|
prompt = ps.get("initial_prompt", "")
|
||||||
|
if prompt:
|
||||||
|
body += (
|
||||||
|
f"\r\n--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="initial_prompt"\r\n\r\n'
|
||||||
|
f"{prompt}"
|
||||||
|
).encode()
|
||||||
|
body += f"\r\n--{boundary}--\r\n".encode()
|
||||||
|
req = urllib.request.Request(ps["whisper_url"], data=body, method="POST")
|
||||||
|
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||||
|
resp = _urlopen(req, timeout=30, proxy=False)
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
resp.close()
|
||||||
|
return data.get("text", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
# -- STT: Flush monitor (asyncio background task) ---------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _flush_monitor(bot):
|
||||||
|
"""Poll for silence gaps and transcribe completed utterances."""
|
||||||
|
ps = _ps(bot)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
while ps["listen"] or ps["trigger"]:
|
||||||
|
await asyncio.sleep(_FLUSH_INTERVAL)
|
||||||
|
now = time.monotonic()
|
||||||
|
to_flush: list[tuple[str, bytes]] = []
|
||||||
|
|
||||||
|
with ps["lock"]:
|
||||||
|
for name in list(ps["last_ts"]):
|
||||||
|
elapsed = now - ps["last_ts"][name]
|
||||||
|
if elapsed >= ps["silence_gap"] and name in ps["buffers"]:
|
||||||
|
pcm = bytes(ps["buffers"].pop(name))
|
||||||
|
del ps["last_ts"][name]
|
||||||
|
to_flush.append((name, pcm))
|
||||||
|
|
||||||
|
for name, pcm in to_flush:
|
||||||
|
if len(pcm) < _MIN_BYTES:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = await loop.run_in_executor(
|
||||||
|
None, _transcribe, ps, pcm,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception("voice: transcription failed for %s", name)
|
||||||
|
continue
|
||||||
|
if not text or text.strip("., ") == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
trigger = ps["trigger"]
|
||||||
|
if trigger and text.lower().startswith(trigger.lower()):
|
||||||
|
remainder = text[len(trigger):].strip().lstrip(",.;:!?")
|
||||||
|
if remainder:
|
||||||
|
log.info("voice: trigger from %s: %s", name, remainder)
|
||||||
|
bot._spawn(
|
||||||
|
_tts_play(bot, remainder), name="voice-tts",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ps["listen"]:
|
||||||
|
log.info("voice: %s said: %s", name, text)
|
||||||
|
await bot.action("0", f"heard {name} say: {text}")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
log.exception("voice: flush monitor error")
|
||||||
|
|
||||||
|
|
||||||
|
# -- TTS: Piper fetch + playback --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_tts(piper_url: str, text: str) -> str | None:
|
||||||
|
"""POST text to Piper TTS and save the WAV response. Blocking."""
|
||||||
|
import tempfile
|
||||||
|
try:
|
||||||
|
payload = json.dumps({"text": text}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
piper_url, data=payload, method="POST",
|
||||||
|
)
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
resp = _urlopen(req, timeout=30, proxy=False)
|
||||||
|
data = resp.read()
|
||||||
|
resp.close()
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".wav", prefix="derp_tts_", delete=False,
|
||||||
|
)
|
||||||
|
tmp.write(data)
|
||||||
|
tmp.close()
|
||||||
|
return tmp.name
|
||||||
|
except Exception:
|
||||||
|
log.exception("voice: TTS fetch failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _tts_play(bot, text: str):
|
||||||
|
"""Fetch TTS audio and play it via stream_audio.
|
||||||
|
|
||||||
|
Uses the configured voice profile (voice, fx, piper params) when set,
|
||||||
|
otherwise falls back to Piper's default voice.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ps = _ps(bot)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
if ps["voice"] or ps["fx"]:
|
||||||
|
wav_path = await loop.run_in_executor(
|
||||||
|
None, lambda: _fetch_tts_voice(
|
||||||
|
ps["piper_url"], text,
|
||||||
|
voice=ps["voice"],
|
||||||
|
length_scale=ps["length_scale"],
|
||||||
|
noise_scale=ps["noise_scale"],
|
||||||
|
noise_w=ps["noise_w"],
|
||||||
|
fx=ps["fx"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
wav_path = await loop.run_in_executor(
|
||||||
|
None, _fetch_tts, ps["piper_url"], text,
|
||||||
|
)
|
||||||
|
if wav_path is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Signal music plugin to duck, wait for it to take effect
|
||||||
|
bot.registry._tts_active = True
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
await _ack_tone(bot)
|
||||||
|
done = asyncio.Event()
|
||||||
|
await bot.stream_audio(str(wav_path), volume=1.0, on_done=done)
|
||||||
|
await done.wait()
|
||||||
|
finally:
|
||||||
|
bot.registry._tts_active = False
|
||||||
|
Path(wav_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Listener lifecycle -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_listener(bot):
|
||||||
|
"""Register the sound listener callback (idempotent)."""
|
||||||
|
ps = _ps(bot)
|
||||||
|
if ps["_listener_registered"]:
|
||||||
|
return
|
||||||
|
if not hasattr(bot, "_sound_listeners"):
|
||||||
|
return
|
||||||
|
bot._sound_listeners.append(lambda user, chunk: _on_voice(bot, user, chunk))
|
||||||
|
ps["_listener_registered"] = True
|
||||||
|
log.info("voice: registered sound listener")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_flush_task(bot):
|
||||||
|
"""Start the flush monitor if not running."""
|
||||||
|
ps = _ps(bot)
|
||||||
|
task = ps.get("flush_task")
|
||||||
|
if task and not task.done():
|
||||||
|
return
|
||||||
|
ps["flush_task"] = bot._spawn(
|
||||||
|
_flush_monitor(bot), name="voice-flush-monitor",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_flush_task(bot):
|
||||||
|
"""Cancel the flush monitor."""
|
||||||
|
ps = _ps(bot)
|
||||||
|
task = ps.get("flush_task")
|
||||||
|
if task and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
ps["flush_task"] = None
|
||||||
|
|
||||||
|
|
||||||
|
# -- Commands ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@command("listen", help="Voice: !listen [on|off] -- toggle STT", tier="admin")
|
||||||
|
async def cmd_listen(bot, message):
|
||||||
|
"""Toggle voice-to-text transcription."""
|
||||||
|
if not _is_mumble(bot):
|
||||||
|
await bot.reply(message, "Voice is Mumble-only")
|
||||||
|
return
|
||||||
|
|
||||||
|
ps = _ps(bot)
|
||||||
|
parts = message.text.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
state = "on" if ps["listen"] else "off"
|
||||||
|
trigger = ps["trigger"]
|
||||||
|
info = f"Listen: {state}"
|
||||||
|
if trigger:
|
||||||
|
info += f" | Trigger: {trigger}"
|
||||||
|
await bot.reply(message, info)
|
||||||
|
return
|
||||||
|
|
||||||
|
sub = parts[1].lower()
|
||||||
|
if sub == "on":
|
||||||
|
ps["listen"] = True
|
||||||
|
_ensure_listener(bot)
|
||||||
|
_ensure_flush_task(bot)
|
||||||
|
await bot.reply(message, "Listening for voice")
|
||||||
|
elif sub == "off":
|
||||||
|
ps["listen"] = False
|
||||||
|
if not ps["trigger"]:
|
||||||
|
with ps["lock"]:
|
||||||
|
ps["buffers"].clear()
|
||||||
|
ps["last_ts"].clear()
|
||||||
|
_stop_flush_task(bot)
|
||||||
|
await bot.reply(message, "Stopped listening")
|
||||||
|
else:
|
||||||
|
await bot.reply(message, "Usage: !listen [on|off]")
|
||||||
|
|
||||||
|
|
||||||
|
@command("say", help="Voice: !say <text> -- text-to-speech")
|
||||||
|
async def cmd_say(bot, message):
|
||||||
|
"""Speak text aloud via Piper TTS."""
|
||||||
|
if not _is_mumble(bot):
|
||||||
|
await bot.reply(message, "Voice is Mumble-only")
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = message.text.split(None, 1)
|
||||||
|
if len(parts) < 2:
|
||||||
|
await bot.reply(message, "Usage: !say <text>")
|
||||||
|
return
|
||||||
|
|
||||||
|
text = parts[1].strip()
|
||||||
|
if len(text) > _MAX_SAY_LEN:
|
||||||
|
await bot.reply(message, f"Text too long (max {_MAX_SAY_LEN} chars)")
|
||||||
|
return
|
||||||
|
|
||||||
|
bot._spawn(_tts_play(bot, text), name="voice-tts")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_fx(fx: str) -> tuple[list[str], str]:
|
||||||
|
"""Split FX chain into rubberband CLI args and ffmpeg filter string.
|
||||||
|
|
||||||
|
Alpine's ffmpeg lacks librubberband, so pitch shifting is handled by
|
||||||
|
the ``rubberband`` CLI tool and remaining filters by ffmpeg.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
parts = fx.split(",")
|
||||||
|
rb_args: list[str] = []
|
||||||
|
ff_parts: list[str] = []
|
||||||
|
for part in parts:
|
||||||
|
if part.startswith("rubberband="):
|
||||||
|
opts: dict[str, str] = {}
|
||||||
|
for kv in part[len("rubberband="):].split(":"):
|
||||||
|
k, _, v = kv.partition("=")
|
||||||
|
opts[k] = v
|
||||||
|
if "pitch" in opts:
|
||||||
|
semitones = 12 * math.log2(float(opts["pitch"]))
|
||||||
|
rb_args += ["--pitch", f"{semitones:.2f}"]
|
||||||
|
if opts.get("formant") == "1":
|
||||||
|
rb_args.append("--formant")
|
||||||
|
else:
|
||||||
|
ff_parts.append(part)
|
||||||
|
return rb_args, ",".join(ff_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_tts_voice(piper_url: str, text: str, *, voice: str = "",
|
||||||
|
speaker_id: int = 0, length_scale: float = 1.0,
|
||||||
|
noise_scale: float = 0.667, noise_w: float = 0.8,
|
||||||
|
fx: str = "") -> str | None:
|
||||||
|
"""Fetch TTS with explicit voice params and optional FX. Blocking.
|
||||||
|
|
||||||
|
Pitch shifting uses the ``rubberband`` CLI (Alpine ffmpeg has no
|
||||||
|
librubberband); remaining audio filters go through ffmpeg.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
payload = {"text": text}
|
||||||
|
if voice:
|
||||||
|
payload["voice"] = voice
|
||||||
|
if speaker_id:
|
||||||
|
payload["speaker_id"] = speaker_id
|
||||||
|
payload["length_scale"] = length_scale
|
||||||
|
payload["noise_scale"] = noise_scale
|
||||||
|
payload["noise_w"] = noise_w
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
req = urllib.request.Request(piper_url, data=data, method="POST")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
resp = _urlopen(req, timeout=30, proxy=False)
|
||||||
|
wav_data = resp.read()
|
||||||
|
resp.close()
|
||||||
|
if not wav_data:
|
||||||
|
return None
|
||||||
|
tmp = tempfile.NamedTemporaryFile(suffix=".wav", prefix="derp_aud_", delete=False)
|
||||||
|
tmp.write(wav_data)
|
||||||
|
tmp.close()
|
||||||
|
if not fx:
|
||||||
|
return tmp.name
|
||||||
|
|
||||||
|
rb_args, ff_filters = _split_fx(fx)
|
||||||
|
current = tmp.name
|
||||||
|
|
||||||
|
# Pitch shift via rubberband CLI
|
||||||
|
if rb_args:
|
||||||
|
rb_out = tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".wav", prefix="derp_aud_", delete=False,
|
||||||
|
)
|
||||||
|
rb_out.close()
|
||||||
|
r = subprocess.run(
|
||||||
|
["rubberband"] + rb_args + [current, rb_out.name],
|
||||||
|
capture_output=True, timeout=15,
|
||||||
|
)
|
||||||
|
os.unlink(current)
|
||||||
|
if r.returncode != 0:
|
||||||
|
log.warning("voice: rubberband failed: %s", r.stderr[:200])
|
||||||
|
os.unlink(rb_out.name)
|
||||||
|
return None
|
||||||
|
current = rb_out.name
|
||||||
|
|
||||||
|
# Remaining filters via ffmpeg
|
||||||
|
if ff_filters:
|
||||||
|
ff_out = tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".wav", prefix="derp_aud_", delete=False,
|
||||||
|
)
|
||||||
|
ff_out.close()
|
||||||
|
r = subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-i", current, "-af", ff_filters, ff_out.name],
|
||||||
|
capture_output=True, timeout=15,
|
||||||
|
)
|
||||||
|
os.unlink(current)
|
||||||
|
if r.returncode != 0:
|
||||||
|
log.warning("voice: ffmpeg failed: %s", r.stderr[:200])
|
||||||
|
os.unlink(ff_out.name)
|
||||||
|
return None
|
||||||
|
current = ff_out.name
|
||||||
|
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
@command("audition", help="Voice: !audition -- play voice samples", tier="admin")
|
||||||
|
async def cmd_audition(bot, message):
|
||||||
|
"""Play voice samples through Mumble for comparison."""
|
||||||
|
if not _is_mumble(bot):
|
||||||
|
return
|
||||||
|
|
||||||
|
ps = _ps(bot)
|
||||||
|
piper_url = ps["piper_url"]
|
||||||
|
phrase = "The sorcerer has arrived. I have seen things beyond your understanding."
|
||||||
|
|
||||||
|
# FX building blocks
|
||||||
|
_deep = "rubberband=pitch=0.87:formant=1"
|
||||||
|
_bass = "bass=g=6:f=110:w=0.6"
|
||||||
|
_bass_heavy = "equalizer=f=80:t=h:w=150:g=8"
|
||||||
|
_echo_subtle = "aecho=0.8:0.6:25|40:0.25|0.15"
|
||||||
|
_echo_chamber = "aecho=0.8:0.88:60:0.35"
|
||||||
|
_echo_cave = "aecho=0.8:0.7:40|70|100:0.3|0.2|0.1"
|
||||||
|
|
||||||
|
samples = [
|
||||||
|
# -- Base voices (no FX) for reference
|
||||||
|
("ryan-high raw", "en_US-ryan-high", 0, ""),
|
||||||
|
("lessac-high raw", "en_US-lessac-high", 0, ""),
|
||||||
|
# -- Deep pitch only
|
||||||
|
("ryan deep", "en_US-ryan-high", 0,
|
||||||
|
_deep),
|
||||||
|
("lessac deep", "en_US-lessac-high", 0,
|
||||||
|
_deep),
|
||||||
|
# -- Deep + bass boost
|
||||||
|
("ryan deep+bass", "en_US-ryan-high", 0,
|
||||||
|
f"{_deep},{_bass}"),
|
||||||
|
("lessac deep+bass", "en_US-lessac-high", 0,
|
||||||
|
f"{_deep},{_bass}"),
|
||||||
|
# -- Deep + heavy bass
|
||||||
|
("ryan deep+heavy bass", "en_US-ryan-high", 0,
|
||||||
|
f"{_deep},{_bass_heavy}"),
|
||||||
|
# -- Deep + bass + subtle echo
|
||||||
|
("ryan deep+bass+echo", "en_US-ryan-high", 0,
|
||||||
|
f"{_deep},{_bass},{_echo_subtle}"),
|
||||||
|
("lessac deep+bass+echo", "en_US-lessac-high", 0,
|
||||||
|
f"{_deep},{_bass},{_echo_subtle}"),
|
||||||
|
# -- Deep + bass + chamber reverb
|
||||||
|
("ryan deep+bass+chamber", "en_US-ryan-high", 0,
|
||||||
|
f"{_deep},{_bass},{_echo_chamber}"),
|
||||||
|
("lessac deep+bass+chamber", "en_US-lessac-high", 0,
|
||||||
|
f"{_deep},{_bass},{_echo_chamber}"),
|
||||||
|
# -- Deep + heavy bass + cave reverb
|
||||||
|
("ryan deep+heavybass+cave", "en_US-ryan-high", 0,
|
||||||
|
f"{_deep},{_bass_heavy},{_echo_cave}"),
|
||||||
|
# -- Libritts best candidates with full sorcerer chain
|
||||||
|
("libritts #20 deep+bass+echo", "en_US-libritts_r-medium", 20,
|
||||||
|
f"{_deep},{_bass},{_echo_subtle}"),
|
||||||
|
("libritts #22 deep+bass+echo", "en_US-libritts_r-medium", 22,
|
||||||
|
f"{_deep},{_bass},{_echo_subtle}"),
|
||||||
|
("libritts #79 deep+bass+chamber", "en_US-libritts_r-medium", 79,
|
||||||
|
f"{_deep},{_bass},{_echo_chamber}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
await bot.reply(message, f"Auditioning {len(samples)} voice samples...")
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
for i, (label, voice, sid, fx) in enumerate(samples, 1):
|
||||||
|
await bot.send("0", f"[{i}/{len(samples)}] {label}")
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
sample_wav = await loop.run_in_executor(
|
||||||
|
None, lambda v=voice, s=sid, f=fx: _fetch_tts_voice(
|
||||||
|
piper_url, phrase, voice=v, speaker_id=s,
|
||||||
|
length_scale=1.15, noise_scale=0.4, noise_w=0.5, fx=f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if sample_wav is None:
|
||||||
|
await bot.send("0", " (failed)")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
done = asyncio.Event()
|
||||||
|
await bot.stream_audio(sample_wav, volume=1.0, on_done=done)
|
||||||
|
await done.wait()
|
||||||
|
finally:
|
||||||
|
Path(sample_wav).unlink(missing_ok=True)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
await bot.send("0", "Audition complete.")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Plugin lifecycle --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def on_connected(bot) -> None:
|
||||||
|
"""Re-register listener after reconnect; play TTS greeting on first connect."""
|
||||||
|
if not _is_mumble(bot):
|
||||||
|
return
|
||||||
|
ps = _ps(bot)
|
||||||
|
if ps["listen"] or ps["trigger"]:
|
||||||
|
_ensure_listener(bot)
|
||||||
|
_ensure_flush_task(bot)
|
||||||
|
|
||||||
|
# Greet via TTS on first connection only
|
||||||
|
greet = getattr(bot, "config", {}).get("mumble", {}).get("greet")
|
||||||
|
if greet and not ps.get("_greeted"):
|
||||||
|
ps["_greeted"] = True
|
||||||
|
ready = getattr(bot, "_is_audio_ready", None)
|
||||||
|
if ready:
|
||||||
|
for _ in range(20):
|
||||||
|
if ready():
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
bot._spawn(_tts_play(bot, greet), name="voice-greet")
|
||||||
@@ -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}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
4
requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-e .
|
||||||
|
pymumble>=1.6
|
||||||
|
pytest>=7.0
|
||||||
|
ruff>=0.4
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
129
src/derp/cli.py
129
src/derp/cli.py
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
124
src/derp/http.py
124
src/derp/http.py
@@ -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():
|
||||||
|
|||||||
@@ -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
1040
src/derp/mumble.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
532
src/derp/teams.py
Normal 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
490
src/derp/telegram.py
Normal 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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
212
tests/test_alias.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""Tests for the alias plugin."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from derp.plugin import PluginRegistry
|
||||||
|
|
||||||
|
# -- Load plugin module directly ---------------------------------------------
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location("alias", "plugins/alias.py")
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules["alias"] = _mod
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Fakes -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeState:
|
||||||
|
def __init__(self):
|
||||||
|
self._store: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
def get(self, ns: str, key: str) -> str | None:
|
||||||
|
return self._store.get(ns, {}).get(key)
|
||||||
|
|
||||||
|
def set(self, ns: str, key: str, value: str) -> None:
|
||||||
|
self._store.setdefault(ns, {})[key] = value
|
||||||
|
|
||||||
|
def delete(self, ns: str, key: str) -> bool:
|
||||||
|
if ns in self._store and key in self._store[ns]:
|
||||||
|
del self._store[ns][key]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def keys(self, ns: str) -> list[str]:
|
||||||
|
return list(self._store.get(ns, {}).keys())
|
||||||
|
|
||||||
|
def clear(self, ns: str) -> int:
|
||||||
|
count = len(self._store.get(ns, {}))
|
||||||
|
self._store.pop(ns, None)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBot:
|
||||||
|
def __init__(self, *, admin: bool = False):
|
||||||
|
self.replied: list[str] = []
|
||||||
|
self.state = _FakeState()
|
||||||
|
self.registry = PluginRegistry()
|
||||||
|
self._admin = admin
|
||||||
|
|
||||||
|
async def reply(self, message, text: str) -> None:
|
||||||
|
self.replied.append(text)
|
||||||
|
|
||||||
|
def _is_admin(self, message) -> bool:
|
||||||
|
return self._admin
|
||||||
|
|
||||||
|
|
||||||
|
class _Msg:
|
||||||
|
def __init__(self, text="!alias"):
|
||||||
|
self.text = text
|
||||||
|
self.nick = "Alice"
|
||||||
|
self.target = "#test"
|
||||||
|
self.is_channel = True
|
||||||
|
self.prefix = "Alice!~alice@host"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestAliasAdd
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAliasAdd:
|
||||||
|
def test_add_creates_alias(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
# Register a target command
|
||||||
|
async def _noop(b, m): pass
|
||||||
|
bot.registry.register_command("skip", _noop, plugin="music")
|
||||||
|
msg = _Msg(text="!alias add s skip")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert bot.state.get("alias", "s") == "skip"
|
||||||
|
assert any("s -> skip" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_add_rejects_existing_command(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
async def _noop(b, m): pass
|
||||||
|
bot.registry.register_command("skip", _noop, plugin="music")
|
||||||
|
msg = _Msg(text="!alias add skip stop")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("already a registered command" in r for r in bot.replied)
|
||||||
|
assert bot.state.get("alias", "skip") is None
|
||||||
|
|
||||||
|
def test_add_rejects_chaining(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
async def _noop(b, m): pass
|
||||||
|
bot.registry.register_command("skip", _noop, plugin="music")
|
||||||
|
bot.state.set("alias", "sk", "skip")
|
||||||
|
msg = _Msg(text="!alias add x sk")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("no chaining" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_add_rejects_unknown_target(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!alias add s nonexistent")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("unknown command" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_add_lowercases_name(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
async def _noop(b, m): pass
|
||||||
|
bot.registry.register_command("skip", _noop, plugin="music")
|
||||||
|
msg = _Msg(text="!alias add S skip")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert bot.state.get("alias", "s") == "skip"
|
||||||
|
|
||||||
|
def test_add_missing_args(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!alias add s")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("Usage" in r for r in bot.replied)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestAliasDel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAliasDel:
|
||||||
|
def test_del_removes_alias(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.state.set("alias", "s", "skip")
|
||||||
|
msg = _Msg(text="!alias del s")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert bot.state.get("alias", "s") is None
|
||||||
|
assert any("removed" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_del_nonexistent(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!alias del x")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("no alias" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_del_missing_name(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!alias del")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("Usage" in r for r in bot.replied)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestAliasList
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAliasList:
|
||||||
|
def test_list_empty(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!alias list")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("No aliases" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_list_shows_entries(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.state.set("alias", "s", "skip")
|
||||||
|
bot.state.set("alias", "np", "nowplaying")
|
||||||
|
msg = _Msg(text="!alias list")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("s -> skip" in r for r in bot.replied)
|
||||||
|
assert any("np -> nowplaying" in r for r in bot.replied)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestAliasClear
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAliasClear:
|
||||||
|
def test_clear_as_admin(self):
|
||||||
|
bot = _FakeBot(admin=True)
|
||||||
|
bot.state.set("alias", "s", "skip")
|
||||||
|
bot.state.set("alias", "np", "nowplaying")
|
||||||
|
msg = _Msg(text="!alias clear")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("Cleared 2" in r for r in bot.replied)
|
||||||
|
assert bot.state.keys("alias") == []
|
||||||
|
|
||||||
|
def test_clear_denied_non_admin(self):
|
||||||
|
bot = _FakeBot(admin=False)
|
||||||
|
bot.state.set("alias", "s", "skip")
|
||||||
|
msg = _Msg(text="!alias clear")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("Permission denied" in r for r in bot.replied)
|
||||||
|
assert bot.state.get("alias", "s") == "skip"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestAliasUsage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAliasUsage:
|
||||||
|
def test_no_subcommand(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!alias")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("Usage" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_unknown_subcommand(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!alias foo")
|
||||||
|
asyncio.run(_mod.cmd_alias(bot, msg))
|
||||||
|
assert any("Usage" in r for r in bot.replied)
|
||||||
@@ -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
288
tests/test_core.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""Tests for the core plugin."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# -- Load plugin module directly ---------------------------------------------
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location("core", "plugins/core.py")
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules["core"] = _mod
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Fakes -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeHandler:
|
||||||
|
name: str
|
||||||
|
callback: object
|
||||||
|
help: str = ""
|
||||||
|
plugin: str = ""
|
||||||
|
admin: bool = False
|
||||||
|
tier: str = "user"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self._bots: dict = {}
|
||||||
|
self.commands: dict = {}
|
||||||
|
self._modules: dict = {}
|
||||||
|
self.events: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBot:
|
||||||
|
def __init__(self, *, mumble: bool = False):
|
||||||
|
self.replied: list[str] = []
|
||||||
|
self.registry = _FakeRegistry()
|
||||||
|
self.nick = "derp"
|
||||||
|
self.prefix = "!"
|
||||||
|
self._receive_sound = False
|
||||||
|
if mumble:
|
||||||
|
self._mumble = MagicMock()
|
||||||
|
|
||||||
|
def _plugin_allowed(self, plugin: str, channel) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def reply(self, message, text: str) -> None:
|
||||||
|
self.replied.append(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_listener():
|
||||||
|
"""Create a fake listener bot (merlin) with _receive_sound=True."""
|
||||||
|
listener = _FakeBot(mumble=True)
|
||||||
|
listener.nick = "merlin"
|
||||||
|
listener._receive_sound = True
|
||||||
|
return listener
|
||||||
|
|
||||||
|
|
||||||
|
class _Msg:
|
||||||
|
def __init__(self, text="!deaf"):
|
||||||
|
self.text = text
|
||||||
|
self.nick = "Alice"
|
||||||
|
self.target = "0"
|
||||||
|
self.is_channel = True
|
||||||
|
self.prefix = "Alice"
|
||||||
|
|
||||||
|
|
||||||
|
# -- Tests -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeafCommand:
|
||||||
|
def test_deaf_targets_listener(self):
|
||||||
|
"""!deaf toggles the listener bot (merlin), not the calling bot."""
|
||||||
|
bot = _FakeBot(mumble=True)
|
||||||
|
listener = _make_listener()
|
||||||
|
bot.registry._bots = {"derp": bot, "merlin": listener}
|
||||||
|
listener._mumble.users.myself.get.return_value = False
|
||||||
|
msg = _Msg(text="!deaf")
|
||||||
|
asyncio.run(_mod.cmd_deaf(bot, msg))
|
||||||
|
listener._mumble.users.myself.deafen.assert_called_once()
|
||||||
|
assert any("merlin" in r and "deafened" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_deaf_toggle_off(self):
|
||||||
|
bot = _FakeBot(mumble=True)
|
||||||
|
listener = _make_listener()
|
||||||
|
bot.registry._bots = {"derp": bot, "merlin": listener}
|
||||||
|
listener._mumble.users.myself.get.return_value = True
|
||||||
|
msg = _Msg(text="!deaf")
|
||||||
|
asyncio.run(_mod.cmd_deaf(bot, msg))
|
||||||
|
listener._mumble.users.myself.undeafen.assert_called_once()
|
||||||
|
listener._mumble.users.myself.unmute.assert_called_once()
|
||||||
|
assert any("merlin" in r and "undeafened" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_deaf_non_mumble_silent(self):
|
||||||
|
bot = _FakeBot(mumble=False)
|
||||||
|
msg = _Msg(text="!deaf")
|
||||||
|
asyncio.run(_mod.cmd_deaf(bot, msg))
|
||||||
|
assert bot.replied == []
|
||||||
|
|
||||||
|
def test_deaf_fallback_no_listener(self):
|
||||||
|
"""Falls back to calling bot when no listener is registered."""
|
||||||
|
bot = _FakeBot(mumble=True)
|
||||||
|
bot._mumble.users.myself.get.return_value = False
|
||||||
|
msg = _Msg(text="!deaf")
|
||||||
|
asyncio.run(_mod.cmd_deaf(bot, msg))
|
||||||
|
bot._mumble.users.myself.deafen.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Help command tests ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_with_doc():
|
||||||
|
"""Manage widgets.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
!widget add <name>
|
||||||
|
!widget del <name>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
!widget add foo
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_no_doc():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fp_module(url="https://paste.example.com/abc/raw", capture=None):
|
||||||
|
"""Create a fake flaskpaste module that returns a fixed URL.
|
||||||
|
|
||||||
|
If capture is a list, appended paste content is stored there.
|
||||||
|
"""
|
||||||
|
mod = types.ModuleType("flaskpaste")
|
||||||
|
|
||||||
|
def _create(bot, text):
|
||||||
|
if capture is not None:
|
||||||
|
capture.append(text)
|
||||||
|
return url
|
||||||
|
|
||||||
|
mod.create_paste = _create
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelpCommand:
|
||||||
|
def test_help_cmd_with_paste(self):
|
||||||
|
"""!help <cmd> with docstring pastes detail, appends URL."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
handler = _FakeHandler(
|
||||||
|
name="widget", callback=_cmd_with_doc,
|
||||||
|
help="Manage widgets", plugin="widgets",
|
||||||
|
)
|
||||||
|
bot.registry.commands["widget"] = handler
|
||||||
|
bot.registry._modules["flaskpaste"] = _make_fp_module()
|
||||||
|
msg = _Msg(text="!help widget")
|
||||||
|
asyncio.run(_mod.cmd_help(bot, msg))
|
||||||
|
assert len(bot.replied) == 1
|
||||||
|
assert "!widget -- Manage widgets" in bot.replied[0]
|
||||||
|
assert "https://paste.example.com/abc/raw" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_help_cmd_no_docstring(self):
|
||||||
|
"""!help <cmd> without docstring skips paste."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
handler = _FakeHandler(
|
||||||
|
name="noop", callback=_cmd_no_doc,
|
||||||
|
help="Does nothing", plugin="misc",
|
||||||
|
)
|
||||||
|
bot.registry.commands["noop"] = handler
|
||||||
|
bot.registry._modules["flaskpaste"] = _make_fp_module()
|
||||||
|
msg = _Msg(text="!help noop")
|
||||||
|
asyncio.run(_mod.cmd_help(bot, msg))
|
||||||
|
assert len(bot.replied) == 1
|
||||||
|
assert "!noop -- Does nothing" in bot.replied[0]
|
||||||
|
assert "paste.example.com" not in bot.replied[0]
|
||||||
|
|
||||||
|
def test_help_plugin_with_paste(self):
|
||||||
|
"""!help <plugin> pastes detail for all plugin commands."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
mod = types.ModuleType("widgets")
|
||||||
|
mod.__doc__ = "Widget management plugin."
|
||||||
|
bot.registry._modules["widgets"] = mod
|
||||||
|
bot.registry._modules["flaskpaste"] = _make_fp_module()
|
||||||
|
bot.registry.commands["widget"] = _FakeHandler(
|
||||||
|
name="widget", callback=_cmd_with_doc,
|
||||||
|
help="Manage widgets", plugin="widgets",
|
||||||
|
)
|
||||||
|
bot.registry.commands["wstat"] = _FakeHandler(
|
||||||
|
name="wstat", callback=_cmd_no_doc,
|
||||||
|
help="Widget stats", plugin="widgets",
|
||||||
|
)
|
||||||
|
msg = _Msg(text="!help widgets")
|
||||||
|
asyncio.run(_mod.cmd_help(bot, msg))
|
||||||
|
assert len(bot.replied) == 1
|
||||||
|
reply = bot.replied[0]
|
||||||
|
assert "widgets -- Widget management plugin." in reply
|
||||||
|
assert "!widget, !wstat" in reply
|
||||||
|
# Only widget has a docstring, so paste should still happen
|
||||||
|
assert "https://paste.example.com/abc/raw" in reply
|
||||||
|
|
||||||
|
def test_help_list_with_paste(self):
|
||||||
|
"""!help (no args) pastes full reference."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.registry._modules["flaskpaste"] = _make_fp_module()
|
||||||
|
mod = types.ModuleType("core")
|
||||||
|
mod.__doc__ = "Core plugin."
|
||||||
|
bot.registry._modules["core"] = mod
|
||||||
|
bot.registry.commands["ping"] = _FakeHandler(
|
||||||
|
name="ping", callback=_cmd_with_doc,
|
||||||
|
help="Check alive", plugin="core",
|
||||||
|
)
|
||||||
|
bot.registry.commands["help"] = _FakeHandler(
|
||||||
|
name="help", callback=_cmd_no_doc,
|
||||||
|
help="Show help", plugin="core",
|
||||||
|
)
|
||||||
|
msg = _Msg(text="!help")
|
||||||
|
asyncio.run(_mod.cmd_help(bot, msg))
|
||||||
|
assert len(bot.replied) == 1
|
||||||
|
assert "help, ping" in bot.replied[0]
|
||||||
|
assert "https://paste.example.com/abc/raw" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_help_no_flaskpaste(self):
|
||||||
|
"""Without flaskpaste loaded, help still works (no URL)."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
handler = _FakeHandler(
|
||||||
|
name="widget", callback=_cmd_with_doc,
|
||||||
|
help="Manage widgets", plugin="widgets",
|
||||||
|
)
|
||||||
|
bot.registry.commands["widget"] = handler
|
||||||
|
# No flaskpaste in _modules
|
||||||
|
msg = _Msg(text="!help widget")
|
||||||
|
asyncio.run(_mod.cmd_help(bot, msg))
|
||||||
|
assert len(bot.replied) == 1
|
||||||
|
assert "!widget -- Manage widgets" in bot.replied[0]
|
||||||
|
assert "https://" not in bot.replied[0]
|
||||||
|
|
||||||
|
def test_help_cmd_paste_hierarchy(self):
|
||||||
|
"""Single-command paste: header at 0, docstring at 4."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
pastes: list[str] = []
|
||||||
|
bot.registry._modules["flaskpaste"] = _make_fp_module(capture=pastes)
|
||||||
|
bot.registry.commands["widget"] = _FakeHandler(
|
||||||
|
name="widget", callback=_cmd_with_doc,
|
||||||
|
help="Manage widgets", plugin="widgets",
|
||||||
|
)
|
||||||
|
msg = _Msg(text="!help widget")
|
||||||
|
asyncio.run(_mod.cmd_help(bot, msg))
|
||||||
|
assert len(pastes) == 1
|
||||||
|
lines = pastes[0].split("\n")
|
||||||
|
# Level 0: command header flush-left
|
||||||
|
assert lines[0] == "!widget -- Manage widgets"
|
||||||
|
# Level 1: docstring lines indented 4 spaces
|
||||||
|
for line in lines[1:]:
|
||||||
|
if line.strip():
|
||||||
|
assert line.startswith(" "), f"not indented: {line!r}"
|
||||||
|
|
||||||
|
def test_help_list_paste_hierarchy(self):
|
||||||
|
"""Full reference paste: plugin at 0, command at 4, doc at 8."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
pastes: list[str] = []
|
||||||
|
bot.registry._modules["flaskpaste"] = _make_fp_module(capture=pastes)
|
||||||
|
mod = types.ModuleType("core")
|
||||||
|
mod.__doc__ = "Core plugin."
|
||||||
|
bot.registry._modules["core"] = mod
|
||||||
|
bot.registry.commands["state"] = _FakeHandler(
|
||||||
|
name="state", callback=_cmd_with_doc,
|
||||||
|
help="Inspect state", plugin="core",
|
||||||
|
)
|
||||||
|
msg = _Msg(text="!help")
|
||||||
|
asyncio.run(_mod.cmd_help(bot, msg))
|
||||||
|
assert len(pastes) == 1
|
||||||
|
text = pastes[0]
|
||||||
|
lines = text.split("\n")
|
||||||
|
# Level 0: plugin header
|
||||||
|
assert lines[0] == "[core]"
|
||||||
|
# Level 1: plugin description
|
||||||
|
assert lines[1] == " Core plugin."
|
||||||
|
# Blank separator
|
||||||
|
assert lines[2] == ""
|
||||||
|
# Level 1: command header at indent 4
|
||||||
|
assert lines[3] == " !state -- Inspect state"
|
||||||
|
# Level 2: docstring at indent 8
|
||||||
|
for line in lines[4:]:
|
||||||
|
if line.strip():
|
||||||
|
assert line.startswith(" "), f"not at indent 8: {line!r}"
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:")
|
||||||
|
|||||||
@@ -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
916
tests/test_lastfm.py
Normal file
@@ -0,0 +1,916 @@
|
|||||||
|
"""Tests for the Last.fm music discovery plugin."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
# -- Load plugin module directly ---------------------------------------------
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location("lastfm", "plugins/lastfm.py")
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules["lastfm"] = _mod
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Fakes -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self._modules: dict = {}
|
||||||
|
self._bots: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBot:
|
||||||
|
def __init__(self, *, api_key: str = "test-key", name: str = "derp"):
|
||||||
|
self.replied: list[str] = []
|
||||||
|
self.config: dict = {"lastfm": {"api_key": api_key}} if api_key else {}
|
||||||
|
self._pstate: dict = {}
|
||||||
|
self._only_plugins: set[str] | None = None
|
||||||
|
self.registry = _FakeRegistry()
|
||||||
|
self._username = name
|
||||||
|
|
||||||
|
async def reply(self, message, text: str) -> None:
|
||||||
|
self.replied.append(text)
|
||||||
|
|
||||||
|
async def long_reply(self, message, lines: list[str], *,
|
||||||
|
label: str = "") -> None:
|
||||||
|
for line in lines:
|
||||||
|
self.replied.append(line)
|
||||||
|
|
||||||
|
|
||||||
|
class _Msg:
|
||||||
|
def __init__(self, text="!similar", nick="Alice", target="0",
|
||||||
|
is_channel=True):
|
||||||
|
self.text = text
|
||||||
|
self.nick = nick
|
||||||
|
self.target = target
|
||||||
|
self.is_channel = is_channel
|
||||||
|
self.prefix = nick
|
||||||
|
self.command = "PRIVMSG"
|
||||||
|
self.params = [target, text]
|
||||||
|
self.tags = {}
|
||||||
|
self.raw = {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _FakeTrack:
|
||||||
|
url: str = ""
|
||||||
|
title: str = ""
|
||||||
|
requester: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# -- API response fixtures ---------------------------------------------------
|
||||||
|
|
||||||
|
SIMILAR_ARTISTS_RESP = {
|
||||||
|
"similarartists": {
|
||||||
|
"artist": [
|
||||||
|
{"name": "Artist B", "match": "0.85"},
|
||||||
|
{"name": "Artist C", "match": "0.72"},
|
||||||
|
{"name": "Artist D", "match": "0.60"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
SIMILAR_TRACKS_RESP = {
|
||||||
|
"similartracks": {
|
||||||
|
"track": [
|
||||||
|
{"name": "Track X", "artist": {"name": "Artist B"}, "match": "0.9"},
|
||||||
|
{"name": "Track Y", "artist": {"name": "Artist C"}, "match": "0.7"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
TOP_TAGS_RESP = {
|
||||||
|
"toptags": {
|
||||||
|
"tag": [
|
||||||
|
{"name": "rock", "count": 100},
|
||||||
|
{"name": "alternative", "count": 80},
|
||||||
|
{"name": "indie", "count": 60},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
TRACK_SEARCH_RESP = {
|
||||||
|
"results": {
|
||||||
|
"trackmatches": {
|
||||||
|
"track": [
|
||||||
|
{"name": "Found Track", "artist": "Found Artist"},
|
||||||
|
{"name": "Another", "artist": "Someone"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetApiKey
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetApiKey:
|
||||||
|
def test_from_config(self):
|
||||||
|
bot = _FakeBot(api_key="cfg-key")
|
||||||
|
assert _mod._get_api_key(bot) == "cfg-key"
|
||||||
|
|
||||||
|
def test_from_env(self):
|
||||||
|
bot = _FakeBot(api_key="")
|
||||||
|
with patch.dict("os.environ", {"LASTFM_API_KEY": "env-key"}):
|
||||||
|
assert _mod._get_api_key(bot) == "env-key"
|
||||||
|
|
||||||
|
def test_env_takes_priority(self):
|
||||||
|
bot = _FakeBot(api_key="cfg-key")
|
||||||
|
with patch.dict("os.environ", {"LASTFM_API_KEY": "env-key"}):
|
||||||
|
assert _mod._get_api_key(bot) == "env-key"
|
||||||
|
|
||||||
|
def test_empty_when_unset(self):
|
||||||
|
bot = _FakeBot(api_key="")
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
assert _mod._get_api_key(bot) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestApiCall
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiCall:
|
||||||
|
def test_parses_json(self):
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.read.return_value = b'{"result": "ok"}'
|
||||||
|
with patch.object(_mod, "urlopen", create=True, return_value=resp):
|
||||||
|
# _api_call imports urlopen from derp.http at call time
|
||||||
|
with patch("derp.http.urlopen", return_value=resp):
|
||||||
|
data = _mod._api_call("key", "artist.getSimilar", artist="X")
|
||||||
|
assert data == {"result": "ok"}
|
||||||
|
|
||||||
|
def test_returns_empty_on_error(self):
|
||||||
|
with patch("derp.http.urlopen", side_effect=ConnectionError("fail")):
|
||||||
|
data = _mod._api_call("key", "artist.getSimilar", artist="X")
|
||||||
|
assert data == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetSimilarArtists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSimilarArtists:
|
||||||
|
def test_returns_list(self):
|
||||||
|
with patch.object(_mod, "_api_call", return_value=SIMILAR_ARTISTS_RESP):
|
||||||
|
result = _mod._get_similar_artists("key", "Artist A")
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0]["name"] == "Artist B"
|
||||||
|
|
||||||
|
def test_single_dict_wrapped(self):
|
||||||
|
"""Single artist result (dict instead of list) gets wrapped."""
|
||||||
|
data = {"similarartists": {"artist": {"name": "Solo", "match": "1.0"}}}
|
||||||
|
with patch.object(_mod, "_api_call", return_value=data):
|
||||||
|
result = _mod._get_similar_artists("key", "X")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["name"] == "Solo"
|
||||||
|
|
||||||
|
def test_empty_response(self):
|
||||||
|
with patch.object(_mod, "_api_call", return_value={}):
|
||||||
|
result = _mod._get_similar_artists("key", "X")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_missing_key(self):
|
||||||
|
with patch.object(_mod, "_api_call", return_value={"error": 6}):
|
||||||
|
result = _mod._get_similar_artists("key", "X")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetTopTags
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetTopTags:
|
||||||
|
def test_returns_list(self):
|
||||||
|
with patch.object(_mod, "_api_call", return_value=TOP_TAGS_RESP):
|
||||||
|
result = _mod._get_top_tags("key", "Artist A")
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0]["name"] == "rock"
|
||||||
|
|
||||||
|
def test_single_dict_wrapped(self):
|
||||||
|
data = {"toptags": {"tag": {"name": "electronic", "count": 50}}}
|
||||||
|
with patch.object(_mod, "_api_call", return_value=data):
|
||||||
|
result = _mod._get_top_tags("key", "X")
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_empty_response(self):
|
||||||
|
with patch.object(_mod, "_api_call", return_value={}):
|
||||||
|
result = _mod._get_top_tags("key", "X")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetSimilarTracks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSimilarTracks:
|
||||||
|
def test_returns_list(self):
|
||||||
|
with patch.object(_mod, "_api_call", return_value=SIMILAR_TRACKS_RESP):
|
||||||
|
result = _mod._get_similar_tracks("key", "A", "T")
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["name"] == "Track X"
|
||||||
|
|
||||||
|
def test_single_dict_wrapped(self):
|
||||||
|
data = {"similartracks": {"track": {"name": "Solo", "artist": {"name": "X"}}}}
|
||||||
|
with patch.object(_mod, "_api_call", return_value=data):
|
||||||
|
result = _mod._get_similar_tracks("key", "X", "T")
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_empty_response(self):
|
||||||
|
with patch.object(_mod, "_api_call", return_value={}):
|
||||||
|
result = _mod._get_similar_tracks("key", "X", "T")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestSearchTrack
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchTrack:
|
||||||
|
def test_returns_results(self):
|
||||||
|
with patch.object(_mod, "_api_call", return_value=TRACK_SEARCH_RESP):
|
||||||
|
result = _mod._search_track("key", "test")
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["name"] == "Found Track"
|
||||||
|
|
||||||
|
def test_single_dict_wrapped(self):
|
||||||
|
data = {"results": {"trackmatches": {
|
||||||
|
"track": {"name": "One", "artist": "X"},
|
||||||
|
}}}
|
||||||
|
with patch.object(_mod, "_api_call", return_value=data):
|
||||||
|
result = _mod._search_track("key", "test")
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_empty_response(self):
|
||||||
|
with patch.object(_mod, "_api_call", return_value={}):
|
||||||
|
result = _mod._search_track("key", "test")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCurrentMeta
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrentMeta:
|
||||||
|
def test_no_music_state(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
assert _mod._current_meta(bot) == ("", "")
|
||||||
|
|
||||||
|
def test_no_current_track(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {"current": None}
|
||||||
|
assert _mod._current_meta(bot) == ("", "")
|
||||||
|
|
||||||
|
def test_dash_separator(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {"current": _FakeTrack(title="Tool - Lateralus")}
|
||||||
|
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
|
||||||
|
|
||||||
|
def test_double_dash_separator(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {"current": _FakeTrack(title="Tool -- Lateralus")}
|
||||||
|
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
|
||||||
|
|
||||||
|
def test_pipe_separator(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {"current": _FakeTrack(title="Tool | Lateralus")}
|
||||||
|
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
|
||||||
|
|
||||||
|
def test_tilde_separator(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {"current": _FakeTrack(title="Tool ~ Lateralus")}
|
||||||
|
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
|
||||||
|
|
||||||
|
def test_no_separator(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {"current": _FakeTrack(title="Lateralus")}
|
||||||
|
assert _mod._current_meta(bot) == ("", "Lateralus")
|
||||||
|
|
||||||
|
def test_empty_title(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {"current": _FakeTrack(title="")}
|
||||||
|
assert _mod._current_meta(bot) == ("", "")
|
||||||
|
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {
|
||||||
|
"current": _FakeTrack(title=" Tool - Lateralus "),
|
||||||
|
}
|
||||||
|
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
|
||||||
|
|
||||||
|
def test_peer_bot_music_state(self):
|
||||||
|
"""Extra bot sees music state from peer bot via shared registry."""
|
||||||
|
music_bot = _FakeBot()
|
||||||
|
music_bot._pstate["music"] = {
|
||||||
|
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||||
|
}
|
||||||
|
extra_bot = _FakeBot()
|
||||||
|
# No music state on extra_bot
|
||||||
|
# Share registry with _bots index
|
||||||
|
shared_reg = _FakeRegistry()
|
||||||
|
shared_reg._bots = {"derp": music_bot, "merlin": extra_bot}
|
||||||
|
music_bot.registry = shared_reg
|
||||||
|
extra_bot.registry = shared_reg
|
||||||
|
assert _mod._current_meta(extra_bot) == ("Tool", "Lateralus")
|
||||||
|
|
||||||
|
def test_peer_bot_no_music(self):
|
||||||
|
"""Returns empty when no bot has music state."""
|
||||||
|
bot_a = _FakeBot()
|
||||||
|
bot_b = _FakeBot()
|
||||||
|
shared_reg = _FakeRegistry()
|
||||||
|
shared_reg._bots = {"a": bot_a, "b": bot_b}
|
||||||
|
bot_a.registry = shared_reg
|
||||||
|
bot_b.registry = shared_reg
|
||||||
|
assert _mod._current_meta(bot_a) == ("", "")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestFmtMatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFmtMatch:
|
||||||
|
def test_float_score(self):
|
||||||
|
assert _mod._fmt_match(0.85) == "85%"
|
||||||
|
|
||||||
|
def test_string_score(self):
|
||||||
|
assert _mod._fmt_match("0.72") == "72%"
|
||||||
|
|
||||||
|
def test_one(self):
|
||||||
|
assert _mod._fmt_match(1.0) == "100%"
|
||||||
|
|
||||||
|
def test_zero(self):
|
||||||
|
assert _mod._fmt_match(0.0) == "0%"
|
||||||
|
|
||||||
|
def test_invalid(self):
|
||||||
|
assert _mod._fmt_match("bad") == ""
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
assert _mod._fmt_match(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCmdSimilar
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchQueries:
|
||||||
|
def test_track_results(self):
|
||||||
|
similar = [
|
||||||
|
{"name": "Track X", "artist": {"name": "Band A"}, "match": "0.9"},
|
||||||
|
{"name": "Track Y", "artist": {"name": "Band B"}, "match": "0.7"},
|
||||||
|
]
|
||||||
|
result = _mod._search_queries(similar, [], [])
|
||||||
|
assert result == ["Band A Track X", "Band B Track Y"]
|
||||||
|
|
||||||
|
def test_artist_results(self):
|
||||||
|
artists = [{"name": "Deftones"}, {"name": "APC"}]
|
||||||
|
result = _mod._search_queries([], artists, [])
|
||||||
|
assert result == ["Deftones", "APC"]
|
||||||
|
|
||||||
|
def test_mb_results(self):
|
||||||
|
mb = [{"artist": "MB Band", "title": "MB Song"}]
|
||||||
|
result = _mod._search_queries([], [], mb)
|
||||||
|
assert result == ["MB Band MB Song"]
|
||||||
|
|
||||||
|
def test_mixed_sources(self):
|
||||||
|
"""Track results come first, then artist, then MB."""
|
||||||
|
similar = [{"name": "T1", "artist": {"name": "A1"}}]
|
||||||
|
artists = [{"name": "A2"}]
|
||||||
|
mb = [{"artist": "MB", "title": "S1"}]
|
||||||
|
result = _mod._search_queries(similar, artists, mb)
|
||||||
|
assert result == ["A1 T1", "A2", "MB S1"]
|
||||||
|
|
||||||
|
def test_limit(self):
|
||||||
|
artists = [{"name": f"Band {i}"} for i in range(20)]
|
||||||
|
result = _mod._search_queries([], artists, [], limit=5)
|
||||||
|
assert len(result) == 5
|
||||||
|
|
||||||
|
def test_skips_empty(self):
|
||||||
|
similar = [{"name": "", "artist": {"name": ""}}]
|
||||||
|
artists = [{"name": ""}]
|
||||||
|
mb = [{"artist": "", "title": ""}]
|
||||||
|
result = _mod._search_queries(similar, artists, mb)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_empty_inputs(self):
|
||||||
|
assert _mod._search_queries([], [], []) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCmdSimilar:
|
||||||
|
def test_no_api_key_mb_list_fallback(self):
|
||||||
|
"""No API key + list mode falls back to MusicBrainz for results."""
|
||||||
|
bot = _FakeBot(api_key="")
|
||||||
|
msg = _Msg(text="!similar list Tool")
|
||||||
|
mb_picks = [{"artist": "MB Artist", "title": "MB Song"}]
|
||||||
|
with patch.dict("os.environ", {}, clear=True), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist",
|
||||||
|
return_value="mbid-123"), \
|
||||||
|
patch("plugins._musicbrainz.mb_artist_tags",
|
||||||
|
return_value=["rock", "metal"]), \
|
||||||
|
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||||
|
return_value=mb_picks):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
assert any("Similar to Tool" in r for r in bot.replied)
|
||||||
|
assert any("MB Artist" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_no_api_key_mb_no_results(self):
|
||||||
|
"""No API key + MusicBrainz returns nothing shows 'no similar'."""
|
||||||
|
bot = _FakeBot(api_key="")
|
||||||
|
msg = _Msg(text="!similar Tool")
|
||||||
|
with patch.dict("os.environ", {}, clear=True), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist",
|
||||||
|
return_value=None):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
assert any("No similar artists" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_no_artist_nothing_playing(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!similar")
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
assert any("Nothing playing" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_list_artist_shows_similar(self):
|
||||||
|
"""!similar list <artist> shows similar artists (display only)."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!similar list Tool")
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||||
|
with patch.object(_mod, "_get_similar_artists",
|
||||||
|
return_value=SIMILAR_ARTISTS_RESP["similarartists"]["artist"]):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
assert any("Similar to Tool" in r for r in bot.replied)
|
||||||
|
assert any("Artist B" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_list_track_level(self):
|
||||||
|
"""!similar list with track results shows track similarity."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {
|
||||||
|
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||||
|
}
|
||||||
|
msg = _Msg(text="!similar list")
|
||||||
|
tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"]
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=tracks):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
assert any("Similar to Tool - Lateralus" in r for r in bot.replied)
|
||||||
|
assert any("Track X" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_list_falls_back_to_artist(self):
|
||||||
|
"""!similar list falls back to artist similarity when no track results."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {
|
||||||
|
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||||
|
}
|
||||||
|
msg = _Msg(text="!similar list")
|
||||||
|
artists = SIMILAR_ARTISTS_RESP["similarartists"]["artist"]
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||||
|
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
assert any("Similar to Tool" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_no_similar_found(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!similar Obscure Band")
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||||
|
with patch.object(_mod, "_get_similar_artists", return_value=[]):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
assert any("No similar artists" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_list_match_score_displayed(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!similar list Tool")
|
||||||
|
artists = [{"name": "Deftones", "match": "0.85"}]
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||||
|
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
assert any("85%" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_list_current_track_no_separator(self):
|
||||||
|
"""Title without separator uses whole title as search artist."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {
|
||||||
|
"current": _FakeTrack(title="Lateralus"),
|
||||||
|
}
|
||||||
|
msg = _Msg(text="!similar list")
|
||||||
|
artists = [{"name": "APC", "match": "0.7"}]
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||||
|
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
assert any("Similar to Lateralus" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_builds_playlist(self):
|
||||||
|
"""Default !similar builds playlist and starts playback."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!similar Tool")
|
||||||
|
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||||
|
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
|
||||||
|
|
||||||
|
music_mod = MagicMock()
|
||||||
|
music_mod._ps.return_value = {
|
||||||
|
"queue": [], "current": None, "task": None,
|
||||||
|
}
|
||||||
|
music_mod._fade_and_cancel = AsyncMock()
|
||||||
|
music_mod._ensure_loop = MagicMock()
|
||||||
|
bot.registry._modules["music"] = music_mod
|
||||||
|
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||||
|
patch.object(_mod, "_get_similar_artists", return_value=artists), \
|
||||||
|
patch.object(_mod, "_resolve_playlist",
|
||||||
|
return_value=fake_tracks):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
|
||||||
|
music_mod._fade_and_cancel.assert_called_once()
|
||||||
|
music_mod._ensure_loop.assert_called_once()
|
||||||
|
ps = music_mod._ps(bot)
|
||||||
|
assert ps["queue"] == fake_tracks
|
||||||
|
assert any("Playing 1 similar" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_builds_playlist_from_current_track(self):
|
||||||
|
"""!similar with no args discovers from currently playing track."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {
|
||||||
|
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||||
|
}
|
||||||
|
msg = _Msg(text="!similar")
|
||||||
|
tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"]
|
||||||
|
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
|
||||||
|
|
||||||
|
music_mod = MagicMock()
|
||||||
|
music_mod._ps.return_value = {
|
||||||
|
"queue": [], "current": None, "task": None,
|
||||||
|
}
|
||||||
|
music_mod._fade_and_cancel = AsyncMock()
|
||||||
|
music_mod._ensure_loop = MagicMock()
|
||||||
|
bot.registry._modules["music"] = music_mod
|
||||||
|
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \
|
||||||
|
patch.object(_mod, "_resolve_playlist",
|
||||||
|
return_value=fake_tracks):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
|
||||||
|
assert any("Playing 1 similar" in r for r in bot.replied)
|
||||||
|
assert any("Tool" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_no_music_mod_falls_back_to_display(self):
|
||||||
|
"""Without music plugin, !similar falls back to display mode."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!similar Tool")
|
||||||
|
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||||
|
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
# Falls back to display since no music module registered
|
||||||
|
assert any("Similar to Tool" in r for r in bot.replied)
|
||||||
|
assert any("Deftones" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_no_playable_tracks_resolved(self):
|
||||||
|
"""Shows error when resolution returns empty."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!similar Tool")
|
||||||
|
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||||
|
|
||||||
|
music_mod = MagicMock()
|
||||||
|
music_mod._ps.return_value = {"queue": [], "current": None}
|
||||||
|
bot.registry._modules["music"] = music_mod
|
||||||
|
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||||
|
patch.object(_mod, "_get_similar_artists", return_value=artists), \
|
||||||
|
patch.object(_mod, "_resolve_playlist",
|
||||||
|
return_value=[]):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
assert any("No playable tracks" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_mb_builds_playlist(self):
|
||||||
|
"""MB fallback results build playlist in play mode."""
|
||||||
|
bot = _FakeBot(api_key="")
|
||||||
|
msg = _Msg(text="!similar Tool")
|
||||||
|
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
|
||||||
|
fake_tracks = [_FakeTrack(url="http://yt/1", title="MB Track")]
|
||||||
|
|
||||||
|
music_mod = MagicMock()
|
||||||
|
music_mod._ps.return_value = {
|
||||||
|
"queue": [], "current": None, "task": None,
|
||||||
|
}
|
||||||
|
music_mod._fade_and_cancel = AsyncMock()
|
||||||
|
music_mod._ensure_loop = MagicMock()
|
||||||
|
bot.registry._modules["music"] = music_mod
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {}, clear=True), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist",
|
||||||
|
return_value="mbid-123"), \
|
||||||
|
patch("plugins._musicbrainz.mb_artist_tags",
|
||||||
|
return_value=["rock"]), \
|
||||||
|
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||||
|
return_value=mb_picks), \
|
||||||
|
patch.object(_mod, "_resolve_playlist",
|
||||||
|
return_value=fake_tracks):
|
||||||
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
|
assert any("Playing 1 similar" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_cross_bot_delegates_to_music_bot(self):
|
||||||
|
"""When merlin runs !similar, playback starts on derp (music bot)."""
|
||||||
|
# derp = music bot (has active music state)
|
||||||
|
derp = _FakeBot(api_key="test-key", name="derp")
|
||||||
|
derp._only_plugins = {"music", "voice"}
|
||||||
|
derp._pstate["music"] = {
|
||||||
|
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||||
|
"queue": [],
|
||||||
|
}
|
||||||
|
# merlin = calling bot (no music plugin)
|
||||||
|
merlin = _FakeBot(api_key="test-key", name="merlin")
|
||||||
|
shared_reg = _FakeRegistry()
|
||||||
|
shared_reg._bots = {"derp": derp, "merlin": merlin}
|
||||||
|
derp.registry = shared_reg
|
||||||
|
merlin.registry = shared_reg
|
||||||
|
|
||||||
|
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||||
|
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
|
||||||
|
|
||||||
|
music_mod = MagicMock()
|
||||||
|
music_mod._ps.return_value = {
|
||||||
|
"queue": [], "current": None, "task": None,
|
||||||
|
}
|
||||||
|
music_mod._fade_and_cancel = AsyncMock()
|
||||||
|
music_mod._ensure_loop = MagicMock()
|
||||||
|
shared_reg._modules["music"] = music_mod
|
||||||
|
|
||||||
|
msg = _Msg(text="!similar Tool")
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||||
|
patch.object(_mod, "_get_similar_artists", return_value=artists), \
|
||||||
|
patch.object(_mod, "_resolve_playlist",
|
||||||
|
return_value=fake_tracks):
|
||||||
|
asyncio.run(_mod.cmd_similar(merlin, msg))
|
||||||
|
|
||||||
|
# Music ops must target derp, not merlin
|
||||||
|
music_mod._fade_and_cancel.assert_called_once_with(derp, duration=3.0)
|
||||||
|
music_mod._ensure_loop.assert_called_once_with(derp, fade_in=True)
|
||||||
|
music_mod._ps.assert_called_with(derp)
|
||||||
|
# Reply still goes through merlin (the bot the user talked to)
|
||||||
|
assert any("Playing 1 similar" in r for r in merlin.replied)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCmdTags
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCmdTags:
|
||||||
|
def test_no_api_key_mb_fallback(self):
|
||||||
|
"""No API key falls back to MusicBrainz for tags."""
|
||||||
|
bot = _FakeBot(api_key="")
|
||||||
|
msg = _Msg(text="!tags Tool")
|
||||||
|
with patch.dict("os.environ", {}, clear=True), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist",
|
||||||
|
return_value="mbid-123"), \
|
||||||
|
patch("plugins._musicbrainz.mb_artist_tags",
|
||||||
|
return_value=["rock", "progressive metal", "art rock"]):
|
||||||
|
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||||
|
assert any("Tool:" in r for r in bot.replied)
|
||||||
|
assert any("rock" in r for r in bot.replied)
|
||||||
|
assert any("progressive metal" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_no_api_key_mb_no_results(self):
|
||||||
|
"""No API key + MusicBrainz returns nothing shows 'no tags'."""
|
||||||
|
bot = _FakeBot(api_key="")
|
||||||
|
msg = _Msg(text="!tags Obscure")
|
||||||
|
with patch.dict("os.environ", {}, clear=True), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist",
|
||||||
|
return_value=None):
|
||||||
|
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||||
|
assert any("No tags found" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_no_artist_nothing_playing(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!tags")
|
||||||
|
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||||
|
assert any("Nothing playing" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_shows_tags(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!tags Tool")
|
||||||
|
tags = TOP_TAGS_RESP["toptags"]["tag"]
|
||||||
|
with patch.object(_mod, "_get_top_tags", return_value=tags):
|
||||||
|
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||||
|
assert any("rock" in r for r in bot.replied)
|
||||||
|
assert any("alternative" in r for r in bot.replied)
|
||||||
|
assert any("Tool:" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_no_tags_found(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!tags Obscure")
|
||||||
|
with patch.object(_mod, "_get_top_tags", return_value=[]):
|
||||||
|
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||||
|
assert any("No tags found" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_from_current_track(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {
|
||||||
|
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||||
|
}
|
||||||
|
msg = _Msg(text="!tags")
|
||||||
|
tags = [{"name": "prog metal", "count": 100}]
|
||||||
|
with patch.object(_mod, "_get_top_tags", return_value=tags):
|
||||||
|
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||||
|
assert any("Tool:" in r for r in bot.replied)
|
||||||
|
assert any("prog metal" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_from_current_no_separator(self):
|
||||||
|
"""Uses full title as artist when no separator."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {
|
||||||
|
"current": _FakeTrack(title="Lateralus"),
|
||||||
|
}
|
||||||
|
msg = _Msg(text="!tags")
|
||||||
|
tags = [{"name": "rock", "count": 50}]
|
||||||
|
with patch.object(_mod, "_get_top_tags", return_value=tags):
|
||||||
|
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||||
|
assert any("Lateralus:" in r for r in bot.replied)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestMusicBot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMusicBot:
|
||||||
|
def test_returns_self_when_active(self):
|
||||||
|
"""Returns calling bot when it has active music state."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._pstate["music"] = {"current": _FakeTrack(title="X"), "queue": []}
|
||||||
|
assert _mod._music_bot(bot) is bot
|
||||||
|
|
||||||
|
def test_returns_peer_with_active_state(self):
|
||||||
|
"""Returns peer bot that has music playing."""
|
||||||
|
derp = _FakeBot(name="derp")
|
||||||
|
derp._pstate["music"] = {"current": _FakeTrack(title="X"), "queue": []}
|
||||||
|
merlin = _FakeBot(name="merlin")
|
||||||
|
reg = _FakeRegistry()
|
||||||
|
reg._bots = {"derp": derp, "merlin": merlin}
|
||||||
|
derp.registry = reg
|
||||||
|
merlin.registry = reg
|
||||||
|
assert _mod._music_bot(merlin) is derp
|
||||||
|
|
||||||
|
def test_falls_back_to_only_plugins(self):
|
||||||
|
"""Returns bot with only_plugins containing 'music' when no active state."""
|
||||||
|
derp = _FakeBot(name="derp")
|
||||||
|
derp._only_plugins = {"music", "voice"}
|
||||||
|
merlin = _FakeBot(name="merlin")
|
||||||
|
reg = _FakeRegistry()
|
||||||
|
reg._bots = {"derp": derp, "merlin": merlin}
|
||||||
|
derp.registry = reg
|
||||||
|
merlin.registry = reg
|
||||||
|
assert _mod._music_bot(merlin) is derp
|
||||||
|
|
||||||
|
def test_returns_self_as_last_resort(self):
|
||||||
|
"""Returns calling bot when no peer has music state or filters."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
assert _mod._music_bot(bot) is bot
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestParseTitle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseTitle:
|
||||||
|
def test_dash_separator(self):
|
||||||
|
assert _mod._parse_title("Tool - Lateralus") == ("Tool", "Lateralus")
|
||||||
|
|
||||||
|
def test_double_dash(self):
|
||||||
|
assert _mod._parse_title("Tool -- Lateralus") == ("Tool", "Lateralus")
|
||||||
|
|
||||||
|
def test_pipe_separator(self):
|
||||||
|
assert _mod._parse_title("Tool | Lateralus") == ("Tool", "Lateralus")
|
||||||
|
|
||||||
|
def test_tilde_separator(self):
|
||||||
|
assert _mod._parse_title("Tool ~ Lateralus") == ("Tool", "Lateralus")
|
||||||
|
|
||||||
|
def test_no_separator(self):
|
||||||
|
assert _mod._parse_title("Lateralus") == ("", "Lateralus")
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert _mod._parse_title("") == ("", "")
|
||||||
|
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
assert _mod._parse_title(" Tool - Lateralus ") == ("Tool", "Lateralus")
|
||||||
|
|
||||||
|
def test_first_separator_wins(self):
|
||||||
|
"""Only the first matching separator is used."""
|
||||||
|
assert _mod._parse_title("A - B - C") == ("A", "B - C")
|
||||||
|
|
||||||
|
def test_dash_priority_over_pipe(self):
|
||||||
|
"""Dash separator is tried before pipe."""
|
||||||
|
assert _mod._parse_title("A - B | C") == ("A", "B | C")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestDiscoverSimilar
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoverSimilar:
|
||||||
|
def test_lastfm_path(self):
|
||||||
|
"""Returns Last.fm result when API key + results available."""
|
||||||
|
bot = _FakeBot(api_key="test-key")
|
||||||
|
tracks = [{"name": "Found", "artist": {"name": "Band"}, "match": "0.9"}]
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=tracks):
|
||||||
|
result = asyncio.run(
|
||||||
|
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||||
|
)
|
||||||
|
assert result == ("Band", "Found")
|
||||||
|
|
||||||
|
def test_lastfm_empty_falls_to_musicbrainz(self):
|
||||||
|
"""Falls back to MusicBrainz when Last.fm returns nothing."""
|
||||||
|
bot = _FakeBot(api_key="test-key")
|
||||||
|
mb_picks = [{"artist": "MB Artist", "title": "MB Song"}]
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||||
|
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||||
|
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||||
|
return_value=mb_picks):
|
||||||
|
result = asyncio.run(
|
||||||
|
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||||
|
)
|
||||||
|
assert result == ("MB Artist", "MB Song")
|
||||||
|
|
||||||
|
def test_no_api_key_uses_musicbrainz(self):
|
||||||
|
"""Skips Last.fm when no API key, goes straight to MusicBrainz."""
|
||||||
|
bot = _FakeBot(api_key="")
|
||||||
|
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
|
||||||
|
with patch.dict("os.environ", {}, clear=True), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||||
|
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||||
|
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||||
|
return_value=mb_picks):
|
||||||
|
result = asyncio.run(
|
||||||
|
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||||
|
)
|
||||||
|
assert result == ("MB Band", "MB Track")
|
||||||
|
|
||||||
|
def test_both_fail_returns_none(self):
|
||||||
|
"""Returns None when both Last.fm and MusicBrainz fail."""
|
||||||
|
bot = _FakeBot(api_key="test-key")
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist", return_value=None):
|
||||||
|
result = asyncio.run(
|
||||||
|
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_no_artist_returns_none(self):
|
||||||
|
"""Returns None when title has no artist component."""
|
||||||
|
bot = _FakeBot(api_key="test-key")
|
||||||
|
result = asyncio.run(
|
||||||
|
_mod.discover_similar(bot, "Lateralus"),
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_musicbrainz_import_error_handled(self):
|
||||||
|
"""Gracefully handles import error for _musicbrainz module."""
|
||||||
|
bot = _FakeBot(api_key="test-key")
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||||
|
patch.dict("sys.modules", {"plugins._musicbrainz": None}):
|
||||||
|
result = asyncio.run(
|
||||||
|
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_lastfm_exception_falls_to_musicbrainz(self):
|
||||||
|
"""Last.fm exception triggers MusicBrainz fallback."""
|
||||||
|
bot = _FakeBot(api_key="test-key")
|
||||||
|
mb_picks = [{"artist": "Fallback", "title": "Song"}]
|
||||||
|
with patch.object(_mod, "_get_similar_tracks",
|
||||||
|
side_effect=Exception("API down")), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||||
|
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||||
|
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||||
|
return_value=mb_picks):
|
||||||
|
result = asyncio.run(
|
||||||
|
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||||
|
)
|
||||||
|
assert result == ("Fallback", "Song")
|
||||||
|
|
||||||
|
def test_lastfm_pick_missing_name_falls_to_musicbrainz(self):
|
||||||
|
"""Falls to MB when Last.fm result has empty artist/title."""
|
||||||
|
bot = _FakeBot(api_key="test-key")
|
||||||
|
tracks = [{"name": "", "artist": {"name": ""}, "match": "0.9"}]
|
||||||
|
mb_picks = [{"artist": "MB", "title": "Track"}]
|
||||||
|
with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
|
||||||
|
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
|
||||||
|
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||||
|
return_value=mb_picks):
|
||||||
|
result = asyncio.run(
|
||||||
|
_mod.discover_similar(bot, "Tool - Lateralus"),
|
||||||
|
)
|
||||||
|
assert result == ("MB", "Track")
|
||||||
536
tests/test_llm.py
Normal file
536
tests/test_llm.py
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
"""Tests for the OpenRouter LLM chat plugin."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from derp.irc import Message
|
||||||
|
|
||||||
|
# plugins/ is not a Python package -- load the module from file path
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"plugins.llm", Path(__file__).resolve().parent.parent / "plugins" / "llm.py",
|
||||||
|
)
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules[_spec.name] = _mod
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
|
||||||
|
from plugins.llm import ( # noqa: E402
|
||||||
|
_COOLDOWN,
|
||||||
|
_MAX_HISTORY,
|
||||||
|
_MAX_REPLY_LEN,
|
||||||
|
_check_cooldown,
|
||||||
|
_extract_reply,
|
||||||
|
_get_api_key,
|
||||||
|
_get_model,
|
||||||
|
_ps,
|
||||||
|
_set_cooldown,
|
||||||
|
_truncate,
|
||||||
|
cmd_ask,
|
||||||
|
cmd_chat,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
class _FakeState:
|
||||||
|
"""In-memory stand-in for bot.state."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._store: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
def get(self, plugin: str, key: str, default: str | None = None) -> str | None:
|
||||||
|
return self._store.get(plugin, {}).get(key, default)
|
||||||
|
|
||||||
|
def set(self, plugin: str, key: str, value: str) -> None:
|
||||||
|
self._store.setdefault(plugin, {})[key] = value
|
||||||
|
|
||||||
|
def delete(self, plugin: str, key: str) -> bool:
|
||||||
|
try:
|
||||||
|
del self._store[plugin][key]
|
||||||
|
return True
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def keys(self, plugin: str) -> list[str]:
|
||||||
|
return sorted(self._store.get(plugin, {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRegistry:
|
||||||
|
"""Minimal registry stand-in."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._modules: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBot:
|
||||||
|
"""Minimal bot stand-in that captures sent/replied messages."""
|
||||||
|
|
||||||
|
def __init__(self, *, admin: bool = False, config: dict | None = None):
|
||||||
|
self.sent: list[tuple[str, str]] = []
|
||||||
|
self.actions: list[tuple[str, str]] = []
|
||||||
|
self.replied: list[str] = []
|
||||||
|
self.state = _FakeState()
|
||||||
|
self._pstate: dict = {}
|
||||||
|
self.registry = _FakeRegistry()
|
||||||
|
self._admin = admin
|
||||||
|
self.config = config or {}
|
||||||
|
|
||||||
|
async def send(self, target: str, text: str) -> None:
|
||||||
|
self.sent.append((target, text))
|
||||||
|
|
||||||
|
async def action(self, target: str, text: str) -> None:
|
||||||
|
self.actions.append((target, text))
|
||||||
|
|
||||||
|
async def reply(self, message, text: str) -> None:
|
||||||
|
self.replied.append(text)
|
||||||
|
|
||||||
|
async def long_reply(self, message, lines, *, label: str = "") -> None:
|
||||||
|
for line in lines:
|
||||||
|
self.replied.append(line)
|
||||||
|
|
||||||
|
def _is_admin(self, message) -> bool:
|
||||||
|
return self._admin
|
||||||
|
|
||||||
|
|
||||||
|
def _msg(text: str, nick: str = "alice", target: str = "#test") -> Message:
|
||||||
|
"""Create a channel PRIVMSG."""
|
||||||
|
return Message(
|
||||||
|
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
||||||
|
command="PRIVMSG", params=[target, text], tags={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pm(text: str, nick: str = "alice") -> Message:
|
||||||
|
"""Create a private PRIVMSG."""
|
||||||
|
return Message(
|
||||||
|
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
||||||
|
command="PRIVMSG", params=["botname", text], tags={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _api_response(content: str = "Hello!", reasoning: str = "") -> dict:
|
||||||
|
"""Build a mock API response."""
|
||||||
|
msg = {"role": "assistant", "content": content}
|
||||||
|
if reasoning:
|
||||||
|
msg["reasoning"] = reasoning
|
||||||
|
return {"choices": [{"message": msg}]}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
"""Mock HTTP response."""
|
||||||
|
|
||||||
|
def __init__(self, data: dict):
|
||||||
|
self._data = json.dumps(data).encode()
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _clear(bot=None) -> None:
|
||||||
|
"""Reset per-bot plugin state between tests."""
|
||||||
|
if bot is None:
|
||||||
|
return
|
||||||
|
ps = _ps(bot)
|
||||||
|
ps["histories"].clear()
|
||||||
|
ps["cooldowns"].clear()
|
||||||
|
ps["model"] = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestTruncate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTruncate:
|
||||||
|
def test_short_text_unchanged(self):
|
||||||
|
assert _truncate("hello") == "hello"
|
||||||
|
|
||||||
|
def test_exact_length_unchanged(self):
|
||||||
|
text = "a" * _MAX_REPLY_LEN
|
||||||
|
assert _truncate(text) == text
|
||||||
|
|
||||||
|
def test_long_text_truncated(self):
|
||||||
|
text = "a" * 600
|
||||||
|
result = _truncate(text)
|
||||||
|
assert len(result) == _MAX_REPLY_LEN
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
def test_custom_max(self):
|
||||||
|
result = _truncate("abcdefghij", 7)
|
||||||
|
assert result == "abcd..."
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestExtractReply
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExtractReply:
|
||||||
|
def test_normal_content(self):
|
||||||
|
data = _api_response(content="Hello world")
|
||||||
|
assert _extract_reply(data) == "Hello world"
|
||||||
|
|
||||||
|
def test_empty_content_falls_back_to_reasoning(self):
|
||||||
|
data = _api_response(content="", reasoning="Thinking about it")
|
||||||
|
assert _extract_reply(data) == "Thinking about it"
|
||||||
|
|
||||||
|
def test_content_preferred_over_reasoning(self):
|
||||||
|
data = _api_response(content="Answer", reasoning="Reasoning")
|
||||||
|
assert _extract_reply(data) == "Answer"
|
||||||
|
|
||||||
|
def test_empty_choices(self):
|
||||||
|
assert _extract_reply({"choices": []}) == ""
|
||||||
|
|
||||||
|
def test_no_choices(self):
|
||||||
|
assert _extract_reply({}) == ""
|
||||||
|
|
||||||
|
def test_whitespace_content_falls_back(self):
|
||||||
|
data = _api_response(content=" ", reasoning="Fallback")
|
||||||
|
assert _extract_reply(data) == "Fallback"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetApiKey
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGetApiKey:
|
||||||
|
def test_from_env(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "env-key"}):
|
||||||
|
assert _get_api_key(bot) == "env-key"
|
||||||
|
|
||||||
|
def test_from_config(self):
|
||||||
|
bot = _FakeBot(config={"openrouter": {"api_key": "cfg-key"}})
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
import os
|
||||||
|
os.environ.pop("OPENROUTER_API_KEY", None)
|
||||||
|
assert _get_api_key(bot) == "cfg-key"
|
||||||
|
|
||||||
|
def test_env_takes_precedence(self):
|
||||||
|
bot = _FakeBot(config={"openrouter": {"api_key": "cfg-key"}})
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "env-key"}):
|
||||||
|
assert _get_api_key(bot) == "env-key"
|
||||||
|
|
||||||
|
def test_missing_returns_empty(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
import os
|
||||||
|
os.environ.pop("OPENROUTER_API_KEY", None)
|
||||||
|
assert _get_api_key(bot) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetModel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGetModel:
|
||||||
|
def test_default_model(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
assert _get_model(bot) == "openrouter/auto"
|
||||||
|
|
||||||
|
def test_from_config(self):
|
||||||
|
bot = _FakeBot(config={"openrouter": {"model": "some/model"}})
|
||||||
|
assert _get_model(bot) == "some/model"
|
||||||
|
|
||||||
|
def test_runtime_override(self):
|
||||||
|
bot = _FakeBot(config={"openrouter": {"model": "some/model"}})
|
||||||
|
_ps(bot)["model"] = "override/model"
|
||||||
|
assert _get_model(bot) == "override/model"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCooldown
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCooldown:
|
||||||
|
def test_first_request_not_limited(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
assert _check_cooldown(bot, "alice") is False
|
||||||
|
|
||||||
|
def test_second_request_within_cooldown(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
_set_cooldown(bot, "alice")
|
||||||
|
assert _check_cooldown(bot, "alice") is True
|
||||||
|
|
||||||
|
def test_different_users_independent(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
_set_cooldown(bot, "alice")
|
||||||
|
assert _check_cooldown(bot, "bob") is False
|
||||||
|
|
||||||
|
def test_after_cooldown_passes(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
_set_cooldown(bot, "alice")
|
||||||
|
# Simulate time passing
|
||||||
|
_ps(bot)["cooldowns"]["alice"] = time.monotonic() - _COOLDOWN - 1
|
||||||
|
assert _check_cooldown(bot, "alice") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCmdAsk
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCmdAsk:
|
||||||
|
def test_no_args(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask")))
|
||||||
|
assert "Usage:" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_empty_args(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask ")))
|
||||||
|
assert "Usage:" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_no_api_key(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
import os
|
||||||
|
os.environ.pop("OPENROUTER_API_KEY", None)
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask what is python")))
|
||||||
|
assert "not configured" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_success(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
resp = _FakeResp(_api_response(content="Python is a programming language."))
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask what is python")))
|
||||||
|
|
||||||
|
assert len(bot.replied) == 1
|
||||||
|
assert "Python is a programming language" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_api_error_429(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
err = urllib.error.HTTPError(
|
||||||
|
"url", 429, "Too Many Requests", {}, None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", side_effect=err):
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||||
|
|
||||||
|
assert "Rate limited" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_api_error_500(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
err = urllib.error.HTTPError(
|
||||||
|
"url", 500, "Internal Server Error", {}, None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", side_effect=err):
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||||
|
|
||||||
|
assert "API error" in bot.replied[0]
|
||||||
|
assert "500" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_connection_error(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", side_effect=ConnectionError("fail")):
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||||
|
|
||||||
|
assert "Request failed" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_empty_response(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
resp = _FakeResp({"choices": []})
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||||
|
|
||||||
|
assert "No response" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_cooldown(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
resp = _FakeResp(_api_response(content="Hello!"))
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask first")))
|
||||||
|
bot.replied.clear()
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask second")))
|
||||||
|
|
||||||
|
assert "Cooldown" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_response_truncation(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
long_text = "a" * 600
|
||||||
|
resp = _FakeResp(_api_response(content=long_text))
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||||
|
|
||||||
|
assert len(bot.replied[0]) == _MAX_REPLY_LEN
|
||||||
|
assert bot.replied[0].endswith("...")
|
||||||
|
|
||||||
|
def test_reasoning_model_fallback(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
resp = _FakeResp(_api_response(content="", reasoning="Deep thought"))
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask meaning of life")))
|
||||||
|
|
||||||
|
assert "Deep thought" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_multiline_uses_long_reply(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
resp = _FakeResp(_api_response(content="Line one\nLine two\nLine three"))
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||||
|
asyncio.run(cmd_ask(bot, _msg("!ask hello")))
|
||||||
|
|
||||||
|
assert len(bot.replied) == 3
|
||||||
|
assert bot.replied[0] == "Line one"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCmdChat
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCmdChat:
|
||||||
|
def test_no_args(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat")))
|
||||||
|
assert "Usage:" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_chat_with_history(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
resp1 = _FakeResp(_api_response(content="I am an assistant."))
|
||||||
|
resp2 = _FakeResp(_api_response(content="You asked who I am."))
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp1):
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat who are you")))
|
||||||
|
# Clear cooldown for second request
|
||||||
|
_ps(bot)["cooldowns"].clear()
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp2) as mock_url:
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat what did I ask")))
|
||||||
|
# Verify the history was sent with the second request
|
||||||
|
call_args = mock_url.call_args
|
||||||
|
req = call_args[0][0]
|
||||||
|
body = json.loads(req.data)
|
||||||
|
# System + user1 + assistant1 + user2 = 4 messages
|
||||||
|
assert len(body["messages"]) == 4
|
||||||
|
assert body["messages"][1]["content"] == "who are you"
|
||||||
|
assert body["messages"][2]["content"] == "I am an assistant."
|
||||||
|
assert body["messages"][3]["content"] == "what did I ask"
|
||||||
|
|
||||||
|
assert "I am an assistant" in bot.replied[0]
|
||||||
|
assert "You asked who I am" in bot.replied[1]
|
||||||
|
|
||||||
|
def test_chat_clear(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
# Pre-populate history
|
||||||
|
_ps(bot)["histories"]["alice"] = [
|
||||||
|
{"role": "user", "content": "hello"},
|
||||||
|
{"role": "assistant", "content": "hi"},
|
||||||
|
]
|
||||||
|
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat clear")))
|
||||||
|
assert "cleared" in bot.replied[0].lower()
|
||||||
|
assert "alice" not in _ps(bot)["histories"]
|
||||||
|
|
||||||
|
def test_chat_cooldown(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
resp = _FakeResp(_api_response(content="Hello!"))
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat first")))
|
||||||
|
bot.replied.clear()
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat second")))
|
||||||
|
|
||||||
|
assert "Cooldown" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_chat_model_show(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat model")))
|
||||||
|
assert "openrouter/auto" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_chat_model_switch(self):
|
||||||
|
bot = _FakeBot(admin=True)
|
||||||
|
_clear(bot)
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat model meta-llama/llama-3.3-70b-instruct:free")))
|
||||||
|
assert "Model set to" in bot.replied[0]
|
||||||
|
assert _ps(bot)["model"] == "meta-llama/llama-3.3-70b-instruct:free"
|
||||||
|
|
||||||
|
def test_chat_models_list(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat models")))
|
||||||
|
assert len(bot.replied) >= 3
|
||||||
|
assert any("openrouter/auto" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_chat_no_api_key(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
import os
|
||||||
|
os.environ.pop("OPENROUTER_API_KEY", None)
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat hello")))
|
||||||
|
assert "not configured" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_history_cap(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
# Pre-populate with MAX_HISTORY messages
|
||||||
|
ps = _ps(bot)
|
||||||
|
ps["histories"]["alice"] = [
|
||||||
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg{i}"}
|
||||||
|
for i in range(_MAX_HISTORY)
|
||||||
|
]
|
||||||
|
|
||||||
|
resp = _FakeResp(_api_response(content="Latest reply"))
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat overflow")))
|
||||||
|
|
||||||
|
history = ps["histories"]["alice"]
|
||||||
|
# History should be capped at MAX_HISTORY
|
||||||
|
assert len(history) <= _MAX_HISTORY
|
||||||
|
|
||||||
|
def test_chat_api_error_removes_user_msg(self):
|
||||||
|
"""On API failure, the user message should be removed from history."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
_clear(bot)
|
||||||
|
err = urllib.error.HTTPError(
|
||||||
|
"url", 500, "Internal Server Error", {}, None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}):
|
||||||
|
with patch.object(_mod, "_urlopen", side_effect=err):
|
||||||
|
asyncio.run(cmd_chat(bot, _msg("!chat hello")))
|
||||||
|
|
||||||
|
ps = _ps(bot)
|
||||||
|
# History should be empty -- user msg was removed on failure
|
||||||
|
assert len(ps["histories"].get("alice", [])) == 0
|
||||||
739
tests/test_mumble.py
Normal file
739
tests/test_mumble.py
Normal 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("& < > "") == '& < > "'
|
||||||
|
|
||||||
|
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')") == "<script>alert('xss')"
|
||||||
|
|
||||||
|
def test_escape_html_ampersand(self):
|
||||||
|
assert _escape_html("a & b") == "a & 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 "<script>" 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><script></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
498
tests/test_mumble_admin.py
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
"""Tests for the mumble_admin plugin."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# -- Load plugin module directly ---------------------------------------------
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"mumble_admin", "plugins/mumble_admin.py",
|
||||||
|
)
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
|
||||||
|
cmd_mu = _mod.cmd_mu
|
||||||
|
_find_user = _mod._find_user
|
||||||
|
_find_channel = _mod._find_channel
|
||||||
|
_channel_name = _mod._channel_name
|
||||||
|
|
||||||
|
|
||||||
|
# -- Fakes -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeMessage:
|
||||||
|
text: str = ""
|
||||||
|
nick: str = "admin"
|
||||||
|
prefix: str = "admin"
|
||||||
|
target: str = "0"
|
||||||
|
is_channel: bool = True
|
||||||
|
params: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRegistry:
|
||||||
|
_bots: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._bots = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBot:
|
||||||
|
def __init__(self, users=None, channels=None):
|
||||||
|
self.registry = _FakeRegistry()
|
||||||
|
self._mumble = MagicMock()
|
||||||
|
if users is not None:
|
||||||
|
self._mumble.users = users
|
||||||
|
else:
|
||||||
|
self._mumble.users = {}
|
||||||
|
if channels is not None:
|
||||||
|
self._mumble.channels = channels
|
||||||
|
self._replies: list[str] = []
|
||||||
|
|
||||||
|
async def reply(self, message, text):
|
||||||
|
self._replies.append(text)
|
||||||
|
|
||||||
|
async def send(self, target, text):
|
||||||
|
self._replies.append(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user(name, channel_id=0, mute=False, deaf=False,
|
||||||
|
self_mute=False, self_deaf=False):
|
||||||
|
"""Create a fake pymumble user (dict with methods)."""
|
||||||
|
u = MagicMock()
|
||||||
|
u.__getitem__ = lambda s, k: {
|
||||||
|
"name": name,
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"mute": mute,
|
||||||
|
"deaf": deaf,
|
||||||
|
"self_mute": self_mute,
|
||||||
|
"self_deaf": self_deaf,
|
||||||
|
}[k]
|
||||||
|
u.get = lambda k, d=None: {
|
||||||
|
"name": name,
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"mute": mute,
|
||||||
|
"deaf": deaf,
|
||||||
|
"self_mute": self_mute,
|
||||||
|
"self_deaf": self_deaf,
|
||||||
|
}.get(k, d)
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def _make_channel(name, channel_id=0, parent=0):
|
||||||
|
"""Create a fake pymumble channel (dict with methods)."""
|
||||||
|
c = MagicMock()
|
||||||
|
c.__getitem__ = lambda s, k: {
|
||||||
|
"name": name,
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"parent": parent,
|
||||||
|
}[k]
|
||||||
|
c.get = lambda k, d=None: {
|
||||||
|
"name": name,
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"parent": parent,
|
||||||
|
}.get(k, d)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestFindUser ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindUser:
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
alice = _make_user("Alice")
|
||||||
|
bot = _FakeBot(users={1: alice})
|
||||||
|
assert _find_user(bot, "alice") is alice
|
||||||
|
assert _find_user(bot, "ALICE") is alice
|
||||||
|
assert _find_user(bot, "Alice") is alice
|
||||||
|
|
||||||
|
def test_not_found(self):
|
||||||
|
bot = _FakeBot(users={1: _make_user("Alice")})
|
||||||
|
assert _find_user(bot, "Bob") is None
|
||||||
|
|
||||||
|
def test_no_mumble(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._mumble = None
|
||||||
|
assert _find_user(bot, "anyone") is None
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestFindChannel ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindChannel:
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
lobby = _make_channel("Lobby", channel_id=0)
|
||||||
|
bot = _FakeBot(channels={0: lobby})
|
||||||
|
assert _find_channel(bot, "lobby") is lobby
|
||||||
|
assert _find_channel(bot, "LOBBY") is lobby
|
||||||
|
|
||||||
|
def test_not_found(self):
|
||||||
|
bot = _FakeBot(channels={0: _make_channel("Lobby")})
|
||||||
|
assert _find_channel(bot, "AFK") is None
|
||||||
|
|
||||||
|
def test_no_mumble(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._mumble = None
|
||||||
|
assert _find_channel(bot, "any") is None
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestChannelName ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelName:
|
||||||
|
def test_resolves(self):
|
||||||
|
lobby = _make_channel("Lobby", channel_id=0)
|
||||||
|
bot = _FakeBot(channels={0: lobby})
|
||||||
|
assert _channel_name(bot, 0) == "Lobby"
|
||||||
|
|
||||||
|
def test_missing_returns_id(self):
|
||||||
|
bot = _FakeBot(channels={})
|
||||||
|
assert _channel_name(bot, 42) == "42"
|
||||||
|
|
||||||
|
def test_no_mumble(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot._mumble = None
|
||||||
|
assert _channel_name(bot, 5) == "5"
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestDispatch ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDispatch:
|
||||||
|
def test_no_args_shows_usage(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _FakeMessage(text="!mu")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert len(bot._replies) == 1
|
||||||
|
assert "Usage" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_unknown_sub_shows_usage(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _FakeMessage(text="!mu bogus")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "Usage" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_valid_sub_routes(self):
|
||||||
|
alice = _make_user("Alice")
|
||||||
|
bot = _FakeBot(users={1: alice})
|
||||||
|
msg = _FakeMessage(text="!mu kick Alice")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
alice.kick.assert_called_once_with("")
|
||||||
|
assert "Kicked" in bot._replies[0]
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestKick ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestKick:
|
||||||
|
def test_kick_user(self):
|
||||||
|
alice = _make_user("Alice")
|
||||||
|
bot = _FakeBot(users={1: alice})
|
||||||
|
msg = _FakeMessage(text="!mu kick Alice")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
alice.kick.assert_called_once_with("")
|
||||||
|
assert "Kicked Alice" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_kick_with_reason(self):
|
||||||
|
alice = _make_user("Alice")
|
||||||
|
bot = _FakeBot(users={1: alice})
|
||||||
|
msg = _FakeMessage(text="!mu kick Alice being rude")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
alice.kick.assert_called_once_with("being rude")
|
||||||
|
|
||||||
|
def test_kick_user_not_found(self):
|
||||||
|
bot = _FakeBot(users={})
|
||||||
|
msg = _FakeMessage(text="!mu kick Ghost")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "not found" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
def test_kick_no_args(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _FakeMessage(text="!mu kick")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "Usage" in bot._replies[0]
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestBan -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBan:
|
||||||
|
def test_ban_user(self):
|
||||||
|
bob = _make_user("Bob")
|
||||||
|
bot = _FakeBot(users={1: bob})
|
||||||
|
msg = _FakeMessage(text="!mu ban Bob")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
bob.ban.assert_called_once_with("")
|
||||||
|
assert "Banned Bob" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_ban_with_reason(self):
|
||||||
|
bob = _make_user("Bob")
|
||||||
|
bot = _FakeBot(users={1: bob})
|
||||||
|
msg = _FakeMessage(text="!mu ban Bob spamming")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
bob.ban.assert_called_once_with("spamming")
|
||||||
|
|
||||||
|
def test_ban_user_not_found(self):
|
||||||
|
bot = _FakeBot(users={})
|
||||||
|
msg = _FakeMessage(text="!mu ban Ghost")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "not found" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestMuteUnmute ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMuteUnmute:
|
||||||
|
def test_mute(self):
|
||||||
|
alice = _make_user("Alice")
|
||||||
|
bot = _FakeBot(users={1: alice})
|
||||||
|
msg = _FakeMessage(text="!mu mute Alice")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
alice.mute.assert_called_once()
|
||||||
|
assert "Muted" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_unmute(self):
|
||||||
|
alice = _make_user("Alice", mute=True)
|
||||||
|
bot = _FakeBot(users={1: alice})
|
||||||
|
msg = _FakeMessage(text="!mu unmute Alice")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
alice.unmute.assert_called_once()
|
||||||
|
assert "Unmuted" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_mute_not_found(self):
|
||||||
|
bot = _FakeBot(users={})
|
||||||
|
msg = _FakeMessage(text="!mu mute Nobody")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "not found" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
def test_unmute_not_found(self):
|
||||||
|
bot = _FakeBot(users={})
|
||||||
|
msg = _FakeMessage(text="!mu unmute Nobody")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "not found" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestDeafenUndeafen ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeafenUndeafen:
|
||||||
|
def test_deafen(self):
|
||||||
|
alice = _make_user("Alice")
|
||||||
|
bot = _FakeBot(users={1: alice})
|
||||||
|
msg = _FakeMessage(text="!mu deafen Alice")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
alice.deafen.assert_called_once()
|
||||||
|
assert "Deafened" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_undeafen(self):
|
||||||
|
alice = _make_user("Alice", deaf=True)
|
||||||
|
bot = _FakeBot(users={1: alice})
|
||||||
|
msg = _FakeMessage(text="!mu undeafen Alice")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
alice.undeafen.assert_called_once()
|
||||||
|
assert "Undeafened" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_deafen_not_found(self):
|
||||||
|
bot = _FakeBot(users={})
|
||||||
|
msg = _FakeMessage(text="!mu deafen Nobody")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "not found" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
def test_undeafen_not_found(self):
|
||||||
|
bot = _FakeBot(users={})
|
||||||
|
msg = _FakeMessage(text="!mu undeafen Nobody")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "not found" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestMove ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMove:
|
||||||
|
def test_move_user(self):
|
||||||
|
alice = _make_user("Alice", channel_id=0)
|
||||||
|
afk = _make_channel("AFK", channel_id=5)
|
||||||
|
bot = _FakeBot(users={1: alice}, channels={0: _make_channel("Root"), 5: afk})
|
||||||
|
msg = _FakeMessage(text="!mu move Alice AFK")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
alice.move_in.assert_called_once_with(5)
|
||||||
|
assert "Moved Alice to AFK" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_move_user_not_found(self):
|
||||||
|
bot = _FakeBot(users={}, channels={5: _make_channel("AFK", channel_id=5)})
|
||||||
|
msg = _FakeMessage(text="!mu move Ghost AFK")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "user not found" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
def test_move_channel_not_found(self):
|
||||||
|
alice = _make_user("Alice")
|
||||||
|
bot = _FakeBot(users={1: alice}, channels={})
|
||||||
|
msg = _FakeMessage(text="!mu move Alice Nowhere")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "channel not found" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
def test_move_missing_args(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _FakeMessage(text="!mu move Alice")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "Usage" in bot._replies[0]
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestUsers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestUsers:
|
||||||
|
def test_list_users(self):
|
||||||
|
alice = _make_user("Alice", channel_id=0)
|
||||||
|
bob = _make_user("Bob", channel_id=0, self_mute=True)
|
||||||
|
lobby = _make_channel("Lobby", channel_id=0)
|
||||||
|
bot = _FakeBot(users={1: alice, 2: bob}, channels={0: lobby})
|
||||||
|
msg = _FakeMessage(text="!mu users")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
reply = bot._replies[0]
|
||||||
|
assert "2 user(s)" in reply
|
||||||
|
assert "Alice" in reply
|
||||||
|
assert "Bob" in reply
|
||||||
|
assert "muted" in reply
|
||||||
|
|
||||||
|
def test_list_with_bots(self):
|
||||||
|
alice = _make_user("Alice", channel_id=0)
|
||||||
|
derp = _make_user("derp", channel_id=0)
|
||||||
|
lobby = _make_channel("Lobby", channel_id=0)
|
||||||
|
bot = _FakeBot(users={1: alice, 2: derp}, channels={0: lobby})
|
||||||
|
bot.registry._bots = {"derp": MagicMock()}
|
||||||
|
msg = _FakeMessage(text="!mu users")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
reply = bot._replies[0]
|
||||||
|
assert "bot" in reply
|
||||||
|
assert "2 user(s)" in reply
|
||||||
|
|
||||||
|
def test_deaf_flag(self):
|
||||||
|
alice = _make_user("Alice", channel_id=0, self_deaf=True)
|
||||||
|
lobby = _make_channel("Lobby", channel_id=0)
|
||||||
|
bot = _FakeBot(users={1: alice}, channels={0: lobby})
|
||||||
|
msg = _FakeMessage(text="!mu users")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "deaf" in bot._replies[0]
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestChannels ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannels:
|
||||||
|
def test_list_channels(self):
|
||||||
|
lobby = _make_channel("Lobby", channel_id=0)
|
||||||
|
afk = _make_channel("AFK", channel_id=1)
|
||||||
|
alice = _make_user("Alice", channel_id=0)
|
||||||
|
bot = _FakeBot(users={1: alice}, channels={0: lobby, 1: afk})
|
||||||
|
msg = _FakeMessage(text="!mu channels")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
reply = bot._replies[0]
|
||||||
|
assert "Lobby (1)" in reply
|
||||||
|
assert "AFK (0)" in reply
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestMkchan --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMkchan:
|
||||||
|
def test_create_channel(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _FakeMessage(text="!mu mkchan Gaming")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
bot._mumble.channels.new_channel.assert_called_once_with(
|
||||||
|
0, "Gaming", temporary=False,
|
||||||
|
)
|
||||||
|
assert "Created" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_create_temp_channel(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _FakeMessage(text="!mu mkchan Gaming temp")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
bot._mumble.channels.new_channel.assert_called_once_with(
|
||||||
|
0, "Gaming", temporary=True,
|
||||||
|
)
|
||||||
|
assert "temporary" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_missing_name(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _FakeMessage(text="!mu mkchan")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "Usage" in bot._replies[0]
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestRmchan --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRmchan:
|
||||||
|
def test_remove_channel(self):
|
||||||
|
afk = _make_channel("AFK", channel_id=5)
|
||||||
|
bot = _FakeBot(channels={5: afk})
|
||||||
|
msg = _FakeMessage(text="!mu rmchan AFK")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
afk.remove.assert_called_once()
|
||||||
|
assert "Removed" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_channel_not_found(self):
|
||||||
|
bot = _FakeBot(channels={})
|
||||||
|
msg = _FakeMessage(text="!mu rmchan Nowhere")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "not found" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
def test_missing_args(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _FakeMessage(text="!mu rmchan")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "Usage" in bot._replies[0]
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestRename --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRename:
|
||||||
|
def test_rename_channel(self):
|
||||||
|
afk = _make_channel("AFK", channel_id=5)
|
||||||
|
bot = _FakeBot(channels={5: afk})
|
||||||
|
msg = _FakeMessage(text="!mu rename AFK Chill")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
afk.rename_channel.assert_called_once_with("Chill")
|
||||||
|
assert "Renamed" in bot._replies[0]
|
||||||
|
|
||||||
|
def test_channel_not_found(self):
|
||||||
|
bot = _FakeBot(channels={})
|
||||||
|
msg = _FakeMessage(text="!mu rename Nowhere New")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "not found" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
def test_missing_args(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _FakeMessage(text="!mu rename AFK")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "Usage" in bot._replies[0]
|
||||||
|
|
||||||
|
|
||||||
|
# -- TestDesc ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDesc:
|
||||||
|
def test_set_description(self):
|
||||||
|
afk = _make_channel("AFK", channel_id=5)
|
||||||
|
bot = _FakeBot(channels={5: afk})
|
||||||
|
msg = _FakeMessage(text="!mu desc AFK Away from keyboard")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
afk.set_channel_description.assert_called_once_with("Away from keyboard")
|
||||||
|
assert "description" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
def test_channel_not_found(self):
|
||||||
|
bot = _FakeBot(channels={})
|
||||||
|
msg = _FakeMessage(text="!mu desc Nowhere some text")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "not found" in bot._replies[0].lower()
|
||||||
|
|
||||||
|
def test_missing_args(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _FakeMessage(text="!mu desc AFK")
|
||||||
|
asyncio.run(cmd_mu(bot, msg))
|
||||||
|
assert "Usage" in bot._replies[0]
|
||||||
2606
tests/test_music.py
Normal file
2606
tests/test_music.py
Normal file
File diff suppressed because it is too large
Load Diff
310
tests/test_musicbrainz.py
Normal file
310
tests/test_musicbrainz.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
"""Tests for the MusicBrainz API helper module."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# -- Load module directly ----------------------------------------------------
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"_musicbrainz", "plugins/_musicbrainz.py",
|
||||||
|
)
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules["_musicbrainz"] = _mod
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_resp(data: dict) -> MagicMock:
|
||||||
|
"""Create a fake HTTP response with JSON body."""
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.read.return_value = json.dumps(data).encode()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestMbRequest
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMbRequest:
|
||||||
|
def setup_method(self):
|
||||||
|
_mod._last_request = 0.0
|
||||||
|
|
||||||
|
def test_returns_parsed_json(self):
|
||||||
|
resp = _make_resp({"status": "ok"})
|
||||||
|
with patch("derp.http.urlopen", return_value=resp):
|
||||||
|
result = _mod._mb_request("artist", {"query": "Tool"})
|
||||||
|
assert result == {"status": "ok"}
|
||||||
|
|
||||||
|
def test_rate_delay_enforced(self):
|
||||||
|
"""Second call within rate interval triggers sleep."""
|
||||||
|
_mod._last_request = time.monotonic()
|
||||||
|
resp = _make_resp({})
|
||||||
|
slept = []
|
||||||
|
with patch("derp.http.urlopen", return_value=resp), \
|
||||||
|
patch.object(_mod.time, "sleep", side_effect=slept.append), \
|
||||||
|
patch.object(_mod.time, "monotonic", return_value=_mod._last_request + 0.2):
|
||||||
|
_mod._mb_request("artist", {"query": "X"})
|
||||||
|
assert len(slept) == 1
|
||||||
|
assert slept[0] > 0
|
||||||
|
|
||||||
|
def test_no_delay_when_interval_elapsed(self):
|
||||||
|
"""No sleep when enough time has passed since last request."""
|
||||||
|
_mod._last_request = time.monotonic() - 5.0
|
||||||
|
resp = _make_resp({})
|
||||||
|
with patch("derp.http.urlopen", return_value=resp), \
|
||||||
|
patch.object(_mod.time, "sleep") as mock_sleep:
|
||||||
|
_mod._mb_request("artist", {"query": "X"})
|
||||||
|
mock_sleep.assert_not_called()
|
||||||
|
|
||||||
|
def test_returns_empty_on_error(self):
|
||||||
|
with patch("derp.http.urlopen", side_effect=ConnectionError("fail")):
|
||||||
|
result = _mod._mb_request("artist", {"query": "X"})
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_updates_last_request_on_success(self):
|
||||||
|
_mod._last_request = 0.0
|
||||||
|
resp = _make_resp({})
|
||||||
|
with patch("derp.http.urlopen", return_value=resp):
|
||||||
|
_mod._mb_request("test")
|
||||||
|
assert _mod._last_request > 0
|
||||||
|
|
||||||
|
def test_updates_last_request_on_error(self):
|
||||||
|
_mod._last_request = 0.0
|
||||||
|
with patch("derp.http.urlopen", side_effect=Exception("boom")):
|
||||||
|
_mod._mb_request("test")
|
||||||
|
assert _mod._last_request > 0
|
||||||
|
|
||||||
|
def test_none_params(self):
|
||||||
|
"""Handles None params without error."""
|
||||||
|
resp = _make_resp({"ok": True})
|
||||||
|
with patch("derp.http.urlopen", return_value=resp):
|
||||||
|
result = _mod._mb_request("test", None)
|
||||||
|
assert result == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestMbSearchArtist
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMbSearchArtist:
|
||||||
|
def setup_method(self):
|
||||||
|
_mod._last_request = 0.0
|
||||||
|
|
||||||
|
def test_returns_mbid(self):
|
||||||
|
data = {"artists": [{"id": "abc-123", "name": "Tool", "score": 100}]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
result = _mod.mb_search_artist("Tool")
|
||||||
|
assert result == "abc-123"
|
||||||
|
|
||||||
|
def test_returns_none_no_results(self):
|
||||||
|
with patch.object(_mod, "_mb_request", return_value={"artists": []}):
|
||||||
|
assert _mod.mb_search_artist("Unknown") is None
|
||||||
|
|
||||||
|
def test_returns_none_on_empty_response(self):
|
||||||
|
with patch.object(_mod, "_mb_request", return_value={}):
|
||||||
|
assert _mod.mb_search_artist("X") is None
|
||||||
|
|
||||||
|
def test_returns_none_low_score(self):
|
||||||
|
"""Rejects matches with score below 50."""
|
||||||
|
data = {"artists": [{"id": "low", "name": "Mismatch", "score": 30}]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
assert _mod.mb_search_artist("Tool") is None
|
||||||
|
|
||||||
|
def test_returns_none_on_error(self):
|
||||||
|
with patch.object(_mod, "_mb_request", return_value={}):
|
||||||
|
assert _mod.mb_search_artist("Error") is None
|
||||||
|
|
||||||
|
def test_accepts_high_score(self):
|
||||||
|
data = {"artists": [{"id": "abc", "name": "Tool", "score": 85}]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
assert _mod.mb_search_artist("Tool") == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestMbArtistTags
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMbArtistTags:
|
||||||
|
def setup_method(self):
|
||||||
|
_mod._last_request = 0.0
|
||||||
|
|
||||||
|
def test_returns_sorted_top_5(self):
|
||||||
|
data = {"tags": [
|
||||||
|
{"name": "rock", "count": 50},
|
||||||
|
{"name": "metal", "count": 100},
|
||||||
|
{"name": "prog", "count": 80},
|
||||||
|
{"name": "alternative", "count": 60},
|
||||||
|
{"name": "hard rock", "count": 40},
|
||||||
|
{"name": "grunge", "count": 30},
|
||||||
|
]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
result = _mod.mb_artist_tags("mbid-123")
|
||||||
|
assert len(result) == 5
|
||||||
|
assert result[0] == "metal"
|
||||||
|
assert result[1] == "prog"
|
||||||
|
assert result[2] == "alternative"
|
||||||
|
assert result[3] == "rock"
|
||||||
|
assert result[4] == "hard rock"
|
||||||
|
|
||||||
|
def test_empty_tags(self):
|
||||||
|
with patch.object(_mod, "_mb_request", return_value={"tags": []}):
|
||||||
|
assert _mod.mb_artist_tags("mbid") == []
|
||||||
|
|
||||||
|
def test_no_tags_key(self):
|
||||||
|
with patch.object(_mod, "_mb_request", return_value={}):
|
||||||
|
assert _mod.mb_artist_tags("mbid") == []
|
||||||
|
|
||||||
|
def test_skips_nameless_tags(self):
|
||||||
|
data = {"tags": [
|
||||||
|
{"name": "rock", "count": 50},
|
||||||
|
{"count": 100}, # no name
|
||||||
|
{"name": "", "count": 80}, # empty name
|
||||||
|
]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
result = _mod.mb_artist_tags("mbid")
|
||||||
|
assert result == ["rock"]
|
||||||
|
|
||||||
|
def test_fewer_than_5_tags(self):
|
||||||
|
data = {"tags": [{"name": "jazz", "count": 10}]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
result = _mod.mb_artist_tags("mbid")
|
||||||
|
assert result == ["jazz"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestMbFindSimilarRecordings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMbFindSimilarRecordings:
|
||||||
|
def setup_method(self):
|
||||||
|
_mod._last_request = 0.0
|
||||||
|
|
||||||
|
def test_returns_dicts(self):
|
||||||
|
data = {"recordings": [
|
||||||
|
{
|
||||||
|
"title": "Song A",
|
||||||
|
"artist-credit": [{"name": "Other Artist"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Song B",
|
||||||
|
"artist-credit": [{"name": "Another Band"}],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
result = _mod.mb_find_similar_recordings(
|
||||||
|
"Tool", ["rock", "metal"],
|
||||||
|
)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0] == {"artist": "Other Artist", "title": "Song A"}
|
||||||
|
assert result[1] == {"artist": "Another Band", "title": "Song B"}
|
||||||
|
|
||||||
|
def test_excludes_original_artist(self):
|
||||||
|
data = {"recordings": [
|
||||||
|
{
|
||||||
|
"title": "Own Song",
|
||||||
|
"artist-credit": [{"name": "Tool"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Other Song",
|
||||||
|
"artist-credit": [{"name": "Deftones"}],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
result = _mod.mb_find_similar_recordings(
|
||||||
|
"Tool", ["rock"],
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["artist"] == "Deftones"
|
||||||
|
|
||||||
|
def test_excludes_original_artist_case_insensitive(self):
|
||||||
|
data = {"recordings": [
|
||||||
|
{
|
||||||
|
"title": "Song",
|
||||||
|
"artist-credit": [{"name": "TOOL"}],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
result = _mod.mb_find_similar_recordings(
|
||||||
|
"Tool", ["rock"],
|
||||||
|
)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_deduplicates(self):
|
||||||
|
data = {"recordings": [
|
||||||
|
{
|
||||||
|
"title": "Song A",
|
||||||
|
"artist-credit": [{"name": "Band X"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Song A",
|
||||||
|
"artist-credit": [{"name": "Band X"}],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
result = _mod.mb_find_similar_recordings(
|
||||||
|
"Other", ["rock"],
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_empty_tags(self):
|
||||||
|
result = _mod.mb_find_similar_recordings("Tool", [])
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_no_recordings(self):
|
||||||
|
with patch.object(_mod, "_mb_request", return_value={"recordings": []}):
|
||||||
|
result = _mod.mb_find_similar_recordings(
|
||||||
|
"Tool", ["rock"],
|
||||||
|
)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_empty_response(self):
|
||||||
|
with patch.object(_mod, "_mb_request", return_value={}):
|
||||||
|
result = _mod.mb_find_similar_recordings(
|
||||||
|
"Tool", ["rock"],
|
||||||
|
)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_skips_missing_title(self):
|
||||||
|
data = {"recordings": [
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"artist-credit": [{"name": "Band"}],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
result = _mod.mb_find_similar_recordings(
|
||||||
|
"Other", ["rock"],
|
||||||
|
)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_skips_missing_artist_credit(self):
|
||||||
|
data = {"recordings": [
|
||||||
|
{"title": "Song", "artist-credit": []},
|
||||||
|
{"title": "Song2"},
|
||||||
|
]}
|
||||||
|
with patch.object(_mod, "_mb_request", return_value=data):
|
||||||
|
result = _mod.mb_find_similar_recordings(
|
||||||
|
"Other", ["rock"],
|
||||||
|
)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_uses_top_two_tags(self):
|
||||||
|
"""Query should use at most 2 tags."""
|
||||||
|
with patch.object(_mod, "_mb_request", return_value={}) as mock_req:
|
||||||
|
_mod.mb_find_similar_recordings(
|
||||||
|
"Tool", ["rock", "metal", "prog"],
|
||||||
|
)
|
||||||
|
call_args = mock_req.call_args
|
||||||
|
args = call_args[1] or {}
|
||||||
|
query = args.get("query") or call_args[0][1].get("query", "")
|
||||||
|
# Verify the query contains both tag references
|
||||||
|
assert "rock" in query or "metal" in query
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
759
tests/test_teams.py
Normal 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
786
tests/test_telegram.py
Normal 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
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
797
tests/test_voice.py
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
"""Tests for the voice STT/TTS plugin."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
# -- Load plugin module directly ---------------------------------------------
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location("voice", "plugins/voice.py")
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules["voice"] = _mod
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Fakes -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeState:
|
||||||
|
def __init__(self):
|
||||||
|
self._store: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
def get(self, ns: str, key: str) -> str | None:
|
||||||
|
return self._store.get(ns, {}).get(key)
|
||||||
|
|
||||||
|
def set(self, ns: str, key: str, value: str) -> None:
|
||||||
|
self._store.setdefault(ns, {})[key] = value
|
||||||
|
|
||||||
|
def delete(self, ns: str, key: str) -> None:
|
||||||
|
self._store.get(ns, {}).pop(key, None)
|
||||||
|
|
||||||
|
def keys(self, ns: str) -> list[str]:
|
||||||
|
return list(self._store.get(ns, {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBot:
|
||||||
|
"""Minimal bot for voice plugin testing."""
|
||||||
|
|
||||||
|
def __init__(self, *, mumble: bool = True):
|
||||||
|
self.sent: list[tuple[str, str]] = []
|
||||||
|
self.replied: list[str] = []
|
||||||
|
self.actions: list[tuple[str, str]] = []
|
||||||
|
self.state = _FakeState()
|
||||||
|
self.config: dict = {}
|
||||||
|
self._pstate: dict = {}
|
||||||
|
self._tasks: set[asyncio.Task] = set()
|
||||||
|
self.nick = "derp"
|
||||||
|
self._sound_listeners: list = []
|
||||||
|
if mumble:
|
||||||
|
self.stream_audio = AsyncMock()
|
||||||
|
|
||||||
|
async def send(self, target: str, text: str) -> None:
|
||||||
|
self.sent.append((target, text))
|
||||||
|
|
||||||
|
async def reply(self, message, text: str) -> None:
|
||||||
|
self.replied.append(text)
|
||||||
|
|
||||||
|
async def action(self, target: str, text: str) -> None:
|
||||||
|
self.actions.append((target, text))
|
||||||
|
|
||||||
|
def _spawn(self, coro, *, name=None):
|
||||||
|
task = asyncio.ensure_future(coro)
|
||||||
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
class _Msg:
|
||||||
|
"""Minimal message object."""
|
||||||
|
|
||||||
|
def __init__(self, text="!listen", nick="Alice", target="0",
|
||||||
|
is_channel=True):
|
||||||
|
self.text = text
|
||||||
|
self.nick = nick
|
||||||
|
self.target = target
|
||||||
|
self.is_channel = is_channel
|
||||||
|
self.prefix = nick
|
||||||
|
self.command = "PRIVMSG"
|
||||||
|
self.params = [target, text]
|
||||||
|
self.tags = {}
|
||||||
|
self.raw = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSoundChunk:
|
||||||
|
"""Minimal sound chunk with PCM data."""
|
||||||
|
|
||||||
|
def __init__(self, pcm: bytes = b"\x00\x00" * 960):
|
||||||
|
self.pcm = pcm
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestMumbleGuard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMumbleGuard:
|
||||||
|
def test_is_mumble_true(self):
|
||||||
|
bot = _FakeBot(mumble=True)
|
||||||
|
assert _mod._is_mumble(bot) is True
|
||||||
|
|
||||||
|
def test_is_mumble_false(self):
|
||||||
|
bot = _FakeBot(mumble=False)
|
||||||
|
assert _mod._is_mumble(bot) is False
|
||||||
|
|
||||||
|
def test_listen_non_mumble(self):
|
||||||
|
bot = _FakeBot(mumble=False)
|
||||||
|
msg = _Msg(text="!listen on")
|
||||||
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||||
|
assert any("Mumble-only" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_say_non_mumble(self):
|
||||||
|
bot = _FakeBot(mumble=False)
|
||||||
|
msg = _Msg(text="!say hello")
|
||||||
|
asyncio.run(_mod.cmd_say(bot, msg))
|
||||||
|
assert any("Mumble-only" in r for r in bot.replied)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestListenCommand
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestListenCommand:
|
||||||
|
def test_listen_status(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!listen")
|
||||||
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||||
|
assert any("off" in r.lower() for r in bot.replied)
|
||||||
|
|
||||||
|
def test_listen_on(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!listen on")
|
||||||
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
assert ps["listen"] is True
|
||||||
|
assert any("Listening" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_listen_off(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
ps["buffers"]["Alice"] = bytearray(b"\x00" * 100)
|
||||||
|
ps["last_ts"]["Alice"] = time.monotonic()
|
||||||
|
msg = _Msg(text="!listen off")
|
||||||
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||||
|
assert ps["listen"] is False
|
||||||
|
assert ps["buffers"] == {}
|
||||||
|
assert ps["last_ts"] == {}
|
||||||
|
assert any("Stopped" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_listen_invalid(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!listen maybe")
|
||||||
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||||
|
assert any("Usage" in r for r in bot.replied)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestSayCommand
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSayCommand:
|
||||||
|
def test_say_no_text(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!say")
|
||||||
|
asyncio.run(_mod.cmd_say(bot, msg))
|
||||||
|
assert any("Usage" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_say_too_long(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
text = "x" * 501
|
||||||
|
msg = _Msg(text=f"!say {text}")
|
||||||
|
asyncio.run(_mod.cmd_say(bot, msg))
|
||||||
|
assert any("too long" in r.lower() for r in bot.replied)
|
||||||
|
|
||||||
|
def test_say_spawns_task(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!say hello world")
|
||||||
|
|
||||||
|
spawned = []
|
||||||
|
|
||||||
|
def track_spawn(coro, *, name=None):
|
||||||
|
spawned.append(name)
|
||||||
|
coro.close()
|
||||||
|
task = MagicMock()
|
||||||
|
task.done.return_value = False
|
||||||
|
return task
|
||||||
|
|
||||||
|
bot._spawn = track_spawn
|
||||||
|
asyncio.run(_mod.cmd_say(bot, msg))
|
||||||
|
assert "voice-tts" in spawned
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestAudioBuffering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioBuffering:
|
||||||
|
def test_accumulates_pcm(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
user = {"name": "Alice"}
|
||||||
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||||
|
_mod._on_voice(bot, user, chunk)
|
||||||
|
assert "Alice" in ps["buffers"]
|
||||||
|
assert len(ps["buffers"]["Alice"]) == 960
|
||||||
|
|
||||||
|
def test_ignores_own_nick(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
user = {"name": "derp"}
|
||||||
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||||
|
_mod._on_voice(bot, user, chunk)
|
||||||
|
assert "derp" not in ps["buffers"]
|
||||||
|
|
||||||
|
def test_respects_listen_false(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = False
|
||||||
|
user = {"name": "Alice"}
|
||||||
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||||
|
_mod._on_voice(bot, user, chunk)
|
||||||
|
assert ps["buffers"] == {}
|
||||||
|
|
||||||
|
def test_caps_at_max_bytes(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
user = {"name": "Alice"}
|
||||||
|
# Fill beyond max
|
||||||
|
big_chunk = _FakeSoundChunk(b"\x00\x01" * (_mod._MAX_BYTES // 2 + 100))
|
||||||
|
_mod._on_voice(bot, user, big_chunk)
|
||||||
|
assert len(ps["buffers"]["Alice"]) <= _mod._MAX_BYTES
|
||||||
|
|
||||||
|
def test_empty_pcm_ignored(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
user = {"name": "Alice"}
|
||||||
|
chunk = _FakeSoundChunk(b"")
|
||||||
|
_mod._on_voice(bot, user, chunk)
|
||||||
|
assert "Alice" not in ps["buffers"]
|
||||||
|
|
||||||
|
def test_none_user_ignored(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||||
|
_mod._on_voice(bot, "not_a_dict", chunk)
|
||||||
|
assert ps["buffers"] == {}
|
||||||
|
|
||||||
|
def test_updates_timestamp(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
user = {"name": "Alice"}
|
||||||
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||||
|
_mod._on_voice(bot, user, chunk)
|
||||||
|
assert "Alice" in ps["last_ts"]
|
||||||
|
ts1 = ps["last_ts"]["Alice"]
|
||||||
|
_mod._on_voice(bot, user, chunk)
|
||||||
|
assert ps["last_ts"]["Alice"] >= ts1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestFlushLogic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFlushLogic:
|
||||||
|
def test_silence_gap_triggers_flush(self):
|
||||||
|
"""Buffer is flushed and transcribed after silence gap."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
ps["silence_gap"] = 0.1 # very short for testing
|
||||||
|
|
||||||
|
# Pre-populate buffer with enough PCM (> _MIN_BYTES)
|
||||||
|
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
||||||
|
with ps["lock"]:
|
||||||
|
ps["buffers"]["Alice"] = bytearray(pcm)
|
||||||
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0 # already silent
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
with patch.object(_mod, "_transcribe", return_value="hello"):
|
||||||
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
ps["listen"] = False # stop the monitor
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(task, timeout=2)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
assert any("hello" in a[1] for a in bot.actions)
|
||||||
|
|
||||||
|
asyncio.run(_check())
|
||||||
|
|
||||||
|
def test_min_duration_filter(self):
|
||||||
|
"""Short utterances (< _MIN_BYTES) are discarded."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
ps["silence_gap"] = 0.1
|
||||||
|
|
||||||
|
# Buffer too small
|
||||||
|
with ps["lock"]:
|
||||||
|
ps["buffers"]["Alice"] = bytearray(b"\x00\x01" * 10)
|
||||||
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
with patch.object(_mod, "_transcribe", return_value="x") as mock_t:
|
||||||
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
ps["listen"] = False
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(task, timeout=2)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
mock_t.assert_not_called()
|
||||||
|
|
||||||
|
asyncio.run(_check())
|
||||||
|
|
||||||
|
def test_buffer_cleared_after_flush(self):
|
||||||
|
"""Buffer and timestamp are removed after flushing."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
ps["silence_gap"] = 0.1
|
||||||
|
|
||||||
|
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
||||||
|
with ps["lock"]:
|
||||||
|
ps["buffers"]["Alice"] = bytearray(pcm)
|
||||||
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
with patch.object(_mod, "_transcribe", return_value="test"):
|
||||||
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
ps["listen"] = False
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(task, timeout=2)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
assert "Alice" not in ps["buffers"]
|
||||||
|
assert "Alice" not in ps["last_ts"]
|
||||||
|
|
||||||
|
asyncio.run(_check())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestPcmToWav
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPcmToWav:
|
||||||
|
def test_valid_wav(self):
|
||||||
|
pcm = b"\x00\x00" * 48000 # 1 second of silence
|
||||||
|
wav_data = _mod._pcm_to_wav(pcm)
|
||||||
|
# Should start with RIFF header
|
||||||
|
assert wav_data[:4] == b"RIFF"
|
||||||
|
# Parse it back
|
||||||
|
buf = io.BytesIO(wav_data)
|
||||||
|
with wave.open(buf, "rb") as wf:
|
||||||
|
assert wf.getnchannels() == 1
|
||||||
|
assert wf.getsampwidth() == 2
|
||||||
|
assert wf.getframerate() == 48000
|
||||||
|
assert wf.getnframes() == 48000
|
||||||
|
|
||||||
|
def test_empty_pcm(self):
|
||||||
|
wav_data = _mod._pcm_to_wav(b"")
|
||||||
|
buf = io.BytesIO(wav_data)
|
||||||
|
with wave.open(buf, "rb") as wf:
|
||||||
|
assert wf.getnframes() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestTranscribe
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranscribe:
|
||||||
|
def test_parse_json_response(self):
|
||||||
|
ps = {"whisper_url": "http://localhost:8080/inference"}
|
||||||
|
pcm = b"\x00\x00" * 4800 # 0.1s
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.read.return_value = b'{"text": "hello world"}'
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||||
|
text = _mod._transcribe(ps, pcm)
|
||||||
|
assert text == "hello world"
|
||||||
|
|
||||||
|
def test_empty_text(self):
|
||||||
|
ps = {"whisper_url": "http://localhost:8080/inference"}
|
||||||
|
pcm = b"\x00\x00" * 4800
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.read.return_value = b'{"text": ""}'
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||||
|
text = _mod._transcribe(ps, pcm)
|
||||||
|
assert text == ""
|
||||||
|
|
||||||
|
def test_missing_text_key(self):
|
||||||
|
ps = {"whisper_url": "http://localhost:8080/inference"}
|
||||||
|
pcm = b"\x00\x00" * 4800
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.read.return_value = b'{"result": "something"}'
|
||||||
|
with patch.object(_mod, "_urlopen", return_value=resp):
|
||||||
|
text = _mod._transcribe(ps, pcm)
|
||||||
|
assert text == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestPerBotState
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerBotState:
|
||||||
|
def test_ps_initializes(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
assert ps["listen"] is False
|
||||||
|
assert ps["buffers"] == {}
|
||||||
|
assert ps["last_ts"] == {}
|
||||||
|
|
||||||
|
def test_ps_stable_reference(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps1 = _mod._ps(bot)
|
||||||
|
ps2 = _mod._ps(bot)
|
||||||
|
assert ps1 is ps2
|
||||||
|
|
||||||
|
def test_ps_isolated_per_bot(self):
|
||||||
|
bot1 = _FakeBot()
|
||||||
|
bot2 = _FakeBot()
|
||||||
|
_mod._ps(bot1)["listen"] = True
|
||||||
|
assert _mod._ps(bot2)["listen"] is False
|
||||||
|
|
||||||
|
def test_ps_config_override(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.config = {"voice": {"silence_gap": 3.0}}
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
assert ps["silence_gap"] == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestEnsureListener
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureListener:
|
||||||
|
def test_registers_callback(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_mod._ps(bot) # init state
|
||||||
|
_mod._ensure_listener(bot)
|
||||||
|
assert len(bot._sound_listeners) == 1
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
assert ps["_listener_registered"] is True
|
||||||
|
|
||||||
|
def test_idempotent(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_mod._ps(bot)
|
||||||
|
_mod._ensure_listener(bot)
|
||||||
|
_mod._ensure_listener(bot)
|
||||||
|
assert len(bot._sound_listeners) == 1
|
||||||
|
|
||||||
|
def test_no_listener_without_attr(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
del bot._sound_listeners
|
||||||
|
_mod._ps(bot)
|
||||||
|
_mod._ensure_listener(bot)
|
||||||
|
# Should not raise, just skip
|
||||||
|
|
||||||
|
def test_callback_calls_on_voice(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
_mod._ensure_listener(bot)
|
||||||
|
user = {"name": "Alice"}
|
||||||
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||||
|
bot._sound_listeners[0](user, chunk)
|
||||||
|
assert "Alice" in ps["buffers"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestOnConnected
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnConnected:
|
||||||
|
def test_reregisters_when_listening(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["listen"] = True
|
||||||
|
|
||||||
|
spawned = []
|
||||||
|
|
||||||
|
def fake_spawn(coro, *, name=None):
|
||||||
|
task = MagicMock()
|
||||||
|
task.done.return_value = False
|
||||||
|
spawned.append(name)
|
||||||
|
coro.close()
|
||||||
|
return task
|
||||||
|
|
||||||
|
bot._spawn = fake_spawn
|
||||||
|
asyncio.run(_mod.on_connected(bot))
|
||||||
|
assert ps["_listener_registered"] is True
|
||||||
|
assert "voice-flush-monitor" in spawned
|
||||||
|
|
||||||
|
def test_noop_when_not_listening(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_mod._ps(bot) # init but listen=False
|
||||||
|
|
||||||
|
spawned = []
|
||||||
|
|
||||||
|
def fake_spawn(coro, *, name=None):
|
||||||
|
spawned.append(name)
|
||||||
|
coro.close()
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
bot._spawn = fake_spawn
|
||||||
|
asyncio.run(_mod.on_connected(bot))
|
||||||
|
assert "voice-flush-monitor" not in spawned
|
||||||
|
|
||||||
|
def test_noop_non_mumble(self):
|
||||||
|
bot = _FakeBot(mumble=False)
|
||||||
|
asyncio.run(_mod.on_connected(bot))
|
||||||
|
# Should not raise or register anything
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestTriggerMode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTriggerMode:
|
||||||
|
def test_trigger_config(self):
|
||||||
|
"""_ps() reads trigger from config."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.config = {"voice": {"trigger": "claude"}}
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
assert ps["trigger"] == "claude"
|
||||||
|
|
||||||
|
def test_trigger_default_empty(self):
|
||||||
|
"""trigger defaults to empty string (disabled)."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
assert ps["trigger"] == ""
|
||||||
|
|
||||||
|
def test_trigger_buffers_without_listen(self):
|
||||||
|
"""_on_voice buffers when trigger is set, even with listen=False."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.config = {"voice": {"trigger": "claude"}}
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
assert ps["listen"] is False
|
||||||
|
user = {"name": "Alice"}
|
||||||
|
chunk = _FakeSoundChunk(b"\x01\x02" * 480)
|
||||||
|
_mod._on_voice(bot, user, chunk)
|
||||||
|
assert "Alice" in ps["buffers"]
|
||||||
|
assert len(ps["buffers"]["Alice"]) == 960
|
||||||
|
|
||||||
|
def test_trigger_detected_spawns_tts(self):
|
||||||
|
"""Flush monitor detects trigger word and spawns TTS."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.config = {"voice": {"trigger": "claude"}}
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["silence_gap"] = 0.1
|
||||||
|
|
||||||
|
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
||||||
|
with ps["lock"]:
|
||||||
|
ps["buffers"]["Alice"] = bytearray(pcm)
|
||||||
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
||||||
|
|
||||||
|
spawned = []
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
tts_hit = asyncio.Event()
|
||||||
|
|
||||||
|
def track_spawn(coro, *, name=None):
|
||||||
|
spawned.append(name)
|
||||||
|
if name == "voice-tts":
|
||||||
|
tts_hit.set()
|
||||||
|
coro.close()
|
||||||
|
task = MagicMock()
|
||||||
|
task.done.return_value = False
|
||||||
|
return task
|
||||||
|
|
||||||
|
bot._spawn = track_spawn
|
||||||
|
|
||||||
|
with patch.object(_mod, "_transcribe",
|
||||||
|
return_value="claude hello world"):
|
||||||
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||||
|
await asyncio.wait_for(tts_hit.wait(), timeout=5)
|
||||||
|
ps["trigger"] = ""
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(task, timeout=2)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
assert "voice-tts" in spawned
|
||||||
|
|
||||||
|
asyncio.run(_check())
|
||||||
|
|
||||||
|
def test_trigger_strips_word(self):
|
||||||
|
"""Trigger word is stripped; only remainder goes to TTS."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.config = {"voice": {"trigger": "claude"}}
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["silence_gap"] = 0.1
|
||||||
|
|
||||||
|
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
||||||
|
with ps["lock"]:
|
||||||
|
ps["buffers"]["Alice"] = bytearray(pcm)
|
||||||
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
||||||
|
|
||||||
|
tts_texts = []
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
tts_hit = asyncio.Event()
|
||||||
|
|
||||||
|
async def _noop():
|
||||||
|
pass
|
||||||
|
|
||||||
|
def capturing_tts(bot_, text):
|
||||||
|
tts_texts.append(text)
|
||||||
|
return _noop()
|
||||||
|
|
||||||
|
def track_spawn(coro, *, name=None):
|
||||||
|
if name == "voice-tts":
|
||||||
|
tts_hit.set()
|
||||||
|
coro.close()
|
||||||
|
task = MagicMock()
|
||||||
|
task.done.return_value = False
|
||||||
|
return task
|
||||||
|
|
||||||
|
bot._spawn = track_spawn
|
||||||
|
original_tts = _mod._tts_play
|
||||||
|
_mod._tts_play = capturing_tts
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(_mod, "_transcribe",
|
||||||
|
return_value="Claude hello world"):
|
||||||
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||||
|
await asyncio.wait_for(tts_hit.wait(), timeout=5)
|
||||||
|
ps["trigger"] = ""
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(task, timeout=2)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_mod._tts_play = original_tts
|
||||||
|
assert tts_texts == ["hello world"]
|
||||||
|
|
||||||
|
asyncio.run(_check())
|
||||||
|
|
||||||
|
def test_no_trigger_discards(self):
|
||||||
|
"""Non-triggered speech is silently discarded when only trigger active."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.config = {"voice": {"trigger": "claude"}}
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["silence_gap"] = 0.1
|
||||||
|
|
||||||
|
pcm = b"\x00\x01" * (_mod._MIN_BYTES // 2 + 100)
|
||||||
|
with ps["lock"]:
|
||||||
|
ps["buffers"]["Alice"] = bytearray(pcm)
|
||||||
|
ps["last_ts"]["Alice"] = time.monotonic() - 1.0
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
transcribed = asyncio.Event()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def mock_transcribe(ps_, pcm_):
|
||||||
|
loop.call_soon_threadsafe(transcribed.set)
|
||||||
|
return "hello world"
|
||||||
|
|
||||||
|
with patch.object(_mod, "_transcribe",
|
||||||
|
side_effect=mock_transcribe):
|
||||||
|
task = asyncio.create_task(_mod._flush_monitor(bot))
|
||||||
|
await asyncio.wait_for(transcribed.wait(), timeout=5)
|
||||||
|
# Give the monitor a moment to process the result
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
ps["trigger"] = ""
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(task, timeout=2)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
assert bot.actions == []
|
||||||
|
|
||||||
|
asyncio.run(_check())
|
||||||
|
|
||||||
|
def test_on_connected_starts_with_trigger(self):
|
||||||
|
"""Listener and flush task start on connect when trigger is set."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.config = {"voice": {"trigger": "claude"}}
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
|
||||||
|
spawned = []
|
||||||
|
|
||||||
|
def fake_spawn(coro, *, name=None):
|
||||||
|
task = MagicMock()
|
||||||
|
task.done.return_value = False
|
||||||
|
spawned.append(name)
|
||||||
|
coro.close()
|
||||||
|
return task
|
||||||
|
|
||||||
|
bot._spawn = fake_spawn
|
||||||
|
asyncio.run(_mod.on_connected(bot))
|
||||||
|
assert ps["_listener_registered"] is True
|
||||||
|
assert "voice-flush-monitor" in spawned
|
||||||
|
|
||||||
|
def test_listen_status_shows_trigger(self):
|
||||||
|
"""!listen status includes trigger info when set."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.config = {"voice": {"trigger": "claude"}}
|
||||||
|
_mod._ps(bot)
|
||||||
|
msg = _Msg(text="!listen")
|
||||||
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||||
|
assert any("Trigger: claude" in r for r in bot.replied)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGreeting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGreeting:
|
||||||
|
def test_greet_on_first_connect(self):
|
||||||
|
"""TTS greeting fires on first connect when configured."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.config = {"mumble": {"greet": "Hello there."}}
|
||||||
|
bot._is_audio_ready = lambda: True
|
||||||
|
|
||||||
|
spawned = []
|
||||||
|
|
||||||
|
def fake_spawn(coro, *, name=None):
|
||||||
|
spawned.append(name)
|
||||||
|
coro.close()
|
||||||
|
task = MagicMock()
|
||||||
|
task.done.return_value = False
|
||||||
|
return task
|
||||||
|
|
||||||
|
bot._spawn = fake_spawn
|
||||||
|
asyncio.run(_mod.on_connected(bot))
|
||||||
|
assert "voice-greet" in spawned
|
||||||
|
|
||||||
|
def test_greet_only_once(self):
|
||||||
|
"""Greeting fires only on first connect, not on reconnect."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.config = {"mumble": {"greet": "Hello there."}}
|
||||||
|
bot._is_audio_ready = lambda: True
|
||||||
|
|
||||||
|
spawned = []
|
||||||
|
|
||||||
|
def fake_spawn(coro, *, name=None):
|
||||||
|
spawned.append(name)
|
||||||
|
coro.close()
|
||||||
|
task = MagicMock()
|
||||||
|
task.done.return_value = False
|
||||||
|
return task
|
||||||
|
|
||||||
|
bot._spawn = fake_spawn
|
||||||
|
asyncio.run(_mod.on_connected(bot))
|
||||||
|
assert spawned.count("voice-greet") == 1
|
||||||
|
asyncio.run(_mod.on_connected(bot))
|
||||||
|
assert spawned.count("voice-greet") == 1
|
||||||
|
|
||||||
|
def test_no_greet_without_config(self):
|
||||||
|
"""No greeting when mumble.greet is not set."""
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.config = {}
|
||||||
|
|
||||||
|
spawned = []
|
||||||
|
|
||||||
|
def fake_spawn(coro, *, name=None):
|
||||||
|
spawned.append(name)
|
||||||
|
coro.close()
|
||||||
|
task = MagicMock()
|
||||||
|
task.done.return_value = False
|
||||||
|
return task
|
||||||
|
|
||||||
|
bot._spawn = fake_spawn
|
||||||
|
asyncio.run(_mod.on_connected(bot))
|
||||||
|
assert "voice-greet" not in spawned
|
||||||
|
|
||||||
|
def test_no_greet_non_mumble(self):
|
||||||
|
"""Greeting skipped for non-Mumble bots."""
|
||||||
|
bot = _FakeBot(mumble=False)
|
||||||
|
bot.config = {"mumble": {"greet": "Hello there."}}
|
||||||
|
asyncio.run(_mod.on_connected(bot))
|
||||||
|
# Should not raise or try to greet
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
38
tools/_common.sh
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers for derp container tools.
|
||||||
|
# Sourced, not executed.
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[1]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# Compose command detection
|
||||||
|
if podman compose version &>/dev/null; then
|
||||||
|
COMPOSE="podman compose"
|
||||||
|
elif command -v podman-compose &>/dev/null; then
|
||||||
|
COMPOSE="podman-compose"
|
||||||
|
else
|
||||||
|
echo "error: podman compose or podman-compose required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONTAINER_NAME="derp"
|
||||||
|
# podman-compose names images <project>_<service>
|
||||||
|
IMAGE_NAME="derp_derp"
|
||||||
|
|
||||||
|
# Colors (suppressed if NO_COLOR is set or stdout isn't a tty)
|
||||||
|
if [[ -z "${NO_COLOR:-}" ]] && [[ -t 1 ]]; then
|
||||||
|
GRN='\e[38;5;108m'
|
||||||
|
RED='\e[38;5;131m'
|
||||||
|
BLU='\e[38;5;110m'
|
||||||
|
DIM='\e[2m'
|
||||||
|
RST='\e[0m'
|
||||||
|
else
|
||||||
|
GRN='' RED='' BLU='' DIM='' RST=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
info() { printf "${GRN}%s${RST} %s\n" "✓" "$*"; }
|
||||||
|
err() { printf "${RED}%s${RST} %s\n" "✗" "$*" >&2; }
|
||||||
|
dim() { printf "${DIM} %s${RST}\n" "$*"; }
|
||||||
21
tools/build
Executable file
21
tools/build
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build or rebuild the derp container image.
|
||||||
|
# Usage: tools/build [--no-cache]
|
||||||
|
|
||||||
|
# shellcheck source=tools/_common.sh
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||||
|
cd "$PROJECT_DIR" || exit 1
|
||||||
|
|
||||||
|
args=()
|
||||||
|
[[ "${1:-}" == "--no-cache" ]] && args+=(--no-cache)
|
||||||
|
|
||||||
|
dim "Building image..."
|
||||||
|
$COMPOSE build "${args[@]}"
|
||||||
|
|
||||||
|
size=$(podman image inspect "$IMAGE_NAME" --format '{{.Size}}' 2>/dev/null || true)
|
||||||
|
if [[ -n "$size" ]]; then
|
||||||
|
human=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size} bytes")
|
||||||
|
info "Image built ($human)"
|
||||||
|
else
|
||||||
|
info "Image built"
|
||||||
|
fi
|
||||||
9
tools/logs
Executable file
9
tools/logs
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tail container logs.
|
||||||
|
# Usage: tools/logs [N]
|
||||||
|
|
||||||
|
# shellcheck source=tools/_common.sh
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||||
|
|
||||||
|
tail_n="${1:-30}"
|
||||||
|
podman logs -f --tail "$tail_n" "$CONTAINER_NAME"
|
||||||
26
tools/nuke
Executable file
26
tools/nuke
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Full teardown: stop container and remove image.
|
||||||
|
# Usage: tools/nuke
|
||||||
|
|
||||||
|
# shellcheck source=tools/_common.sh
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||||
|
cd "$PROJECT_DIR" || exit 1
|
||||||
|
|
||||||
|
dim "Stopping container..."
|
||||||
|
$COMPOSE down 2>/dev/null || true
|
||||||
|
|
||||||
|
before=$(podman system df --format '{{.Size}}' 2>/dev/null | head -1 || true)
|
||||||
|
|
||||||
|
dim "Removing image..."
|
||||||
|
podman rmi "$IMAGE_NAME" 2>/dev/null || true
|
||||||
|
# Also remove any dangling derp images
|
||||||
|
podman images --filter "reference=*derp*" --format '{{.ID}}' 2>/dev/null | \
|
||||||
|
xargs -r podman rmi 2>/dev/null || true
|
||||||
|
|
||||||
|
after=$(podman system df --format '{{.Size}}' 2>/dev/null | head -1 || true)
|
||||||
|
|
||||||
|
if [[ -n "$before" && -n "$after" ]]; then
|
||||||
|
info "Teardown complete (images: $before -> $after)"
|
||||||
|
else
|
||||||
|
info "Teardown complete"
|
||||||
|
fi
|
||||||
97
tools/profile
Executable file
97
tools/profile
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Analyze cProfile data from the bot process.
|
||||||
|
# Usage: tools/profile [OPTIONS] [FILE]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# -n NUM Show top NUM entries (default: 30)
|
||||||
|
# -s SORT Sort by: cumtime, tottime, calls, name (default: cumtime)
|
||||||
|
# -f PATTERN Filter to entries matching PATTERN
|
||||||
|
# -c Callers view (who calls the hot functions)
|
||||||
|
# -h Show this help
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# tools/profile # top 30 by cumulative time
|
||||||
|
# tools/profile -s tottime -n 20 # top 20 by total time
|
||||||
|
# tools/profile -f mumble # only mumble-related functions
|
||||||
|
# tools/profile -c -f stream_audio # who calls stream_audio
|
||||||
|
# tools/profile data/old.prof # analyze a specific file
|
||||||
|
|
||||||
|
# shellcheck source=tools/_common.sh
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||||
|
|
||||||
|
DEFAULT_PROF="$PROJECT_DIR/data/derp.prof"
|
||||||
|
TOP=30
|
||||||
|
SORT="cumtime"
|
||||||
|
PATTERN=""
|
||||||
|
CALLERS=false
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,/^$/s/^# \?//p' "$0"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
while getopts ":n:s:f:ch" opt; do
|
||||||
|
case $opt in
|
||||||
|
n) TOP="$OPTARG" ;;
|
||||||
|
s) SORT="$OPTARG" ;;
|
||||||
|
f) PATTERN="$OPTARG" ;;
|
||||||
|
c) CALLERS=true ;;
|
||||||
|
h) usage ;;
|
||||||
|
:) err "option -$OPTARG requires an argument"; exit 2 ;;
|
||||||
|
*) err "unknown option -$OPTARG"; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
shift $((OPTIND - 1))
|
||||||
|
|
||||||
|
PROF="${1:-$DEFAULT_PROF}"
|
||||||
|
|
||||||
|
if [[ ! -f "$PROF" ]]; then
|
||||||
|
err "profile not found: $PROF"
|
||||||
|
dim "run the bot with --cprofile and stop it gracefully"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate sort key
|
||||||
|
case "$SORT" in
|
||||||
|
cumtime|tottime|calls|name) ;;
|
||||||
|
*) err "invalid sort key: $SORT (use cumtime, tottime, calls, name)"; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Profile metadata
|
||||||
|
size=$(stat -c %s "$PROF" 2>/dev/null || stat -f %z "$PROF" 2>/dev/null)
|
||||||
|
human=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
|
||||||
|
modified=$(stat -c %y "$PROF" 2>/dev/null | cut -d. -f1)
|
||||||
|
|
||||||
|
printf '%b%s%b\n' "$BLU" "Profile" "$RST"
|
||||||
|
dim "$PROF ($human, $modified)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Build pstats script
|
||||||
|
read -r -d '' PYSCRIPT << 'PYEOF' || true
|
||||||
|
import pstats
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
|
||||||
|
prof_path = sys.argv[1]
|
||||||
|
sort_key = sys.argv[2]
|
||||||
|
top_n = int(sys.argv[3])
|
||||||
|
pattern = sys.argv[4]
|
||||||
|
callers = sys.argv[5] == "1"
|
||||||
|
|
||||||
|
p = pstats.Stats(prof_path, stream=sys.stdout)
|
||||||
|
p.strip_dirs()
|
||||||
|
p.sort_stats(sort_key)
|
||||||
|
|
||||||
|
if pattern:
|
||||||
|
if callers:
|
||||||
|
p.print_callers(pattern, top_n)
|
||||||
|
else:
|
||||||
|
p.print_stats(pattern, top_n)
|
||||||
|
else:
|
||||||
|
if callers:
|
||||||
|
p.print_callers(top_n)
|
||||||
|
else:
|
||||||
|
p.print_stats(top_n)
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
exec python3 -c "$PYSCRIPT" "$PROF" "$SORT" "$TOP" "$PATTERN" "$( $CALLERS && echo 1 || echo 0 )"
|
||||||
15
tools/restart
Executable file
15
tools/restart
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stop, rebuild, and start the derp container.
|
||||||
|
# Usage: tools/restart [--no-cache]
|
||||||
|
|
||||||
|
# shellcheck source=tools/_common.sh
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||||
|
|
||||||
|
args=()
|
||||||
|
[[ "${1:-}" == "--no-cache" ]] && args+=("--no-cache")
|
||||||
|
|
||||||
|
"$SCRIPT_DIR/stop"
|
||||||
|
echo
|
||||||
|
"$SCRIPT_DIR/build" "${args[@]}"
|
||||||
|
echo
|
||||||
|
"$SCRIPT_DIR/start"
|
||||||
23
tools/start
Executable file
23
tools/start
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the derp container.
|
||||||
|
# Usage: tools/start
|
||||||
|
|
||||||
|
# shellcheck source=tools/_common.sh
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||||
|
cd "$PROJECT_DIR" || exit 1
|
||||||
|
|
||||||
|
# Build first if no image exists
|
||||||
|
if ! podman image exists "$IMAGE_NAME" 2>/dev/null; then
|
||||||
|
dim "No image found, building..."
|
||||||
|
"$SCRIPT_DIR/build"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
dim "Starting container..."
|
||||||
|
$COMPOSE up -d
|
||||||
|
|
||||||
|
sleep 3
|
||||||
|
dim "Recent logs:"
|
||||||
|
podman logs --tail 15 "$CONTAINER_NAME" 2>&1 || true
|
||||||
|
echo
|
||||||
|
info "Container started"
|
||||||
46
tools/status
Executable file
46
tools/status
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Show container and image state.
|
||||||
|
# Usage: tools/status
|
||||||
|
|
||||||
|
# shellcheck source=tools/_common.sh
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||||
|
|
||||||
|
# -- Container ----------------------------------------------------------------
|
||||||
|
printf '%b%s%b\n' "$BLU" "Container" "$RST"
|
||||||
|
state=$(podman inspect "$CONTAINER_NAME" --format '{{.State.Status}}' 2>/dev/null || true)
|
||||||
|
if [[ -z "$state" ]]; then
|
||||||
|
dim "absent"
|
||||||
|
elif [[ "$state" == "running" ]]; then
|
||||||
|
uptime=$(podman inspect "$CONTAINER_NAME" --format '{{.State.StartedAt}}' 2>/dev/null || true)
|
||||||
|
info "running (since ${uptime%.*})"
|
||||||
|
else
|
||||||
|
info "$state"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
|
||||||
|
# -- Image --------------------------------------------------------------------
|
||||||
|
printf '%b%s%b\n' "$BLU" "Image" "$RST"
|
||||||
|
if podman image exists "$IMAGE_NAME" 2>/dev/null; then
|
||||||
|
img_info=$(podman image inspect "$IMAGE_NAME" --format '{{.Created}} {{.Size}}' 2>/dev/null || true)
|
||||||
|
created="${img_info%% *}"
|
||||||
|
size="${img_info##* }"
|
||||||
|
human=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
|
||||||
|
info "$IMAGE_NAME ($human, ${created%T*})"
|
||||||
|
else
|
||||||
|
dim "no image"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
|
||||||
|
# -- Volumes ------------------------------------------------------------------
|
||||||
|
printf '%b%s%b\n' "$BLU" "Mounts" "$RST"
|
||||||
|
mounts=(src plugins config/derp.toml data secrets)
|
||||||
|
for m in "${mounts[@]}"; do
|
||||||
|
path="$PROJECT_DIR/$m"
|
||||||
|
if [[ -e "$path" ]]; then
|
||||||
|
info "$m"
|
||||||
|
else
|
||||||
|
err "$m (missing)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
11
tools/stop
Executable file
11
tools/stop
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stop and remove the derp container.
|
||||||
|
# Usage: tools/stop
|
||||||
|
|
||||||
|
# shellcheck source=tools/_common.sh
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||||
|
cd "$PROJECT_DIR" || exit 1
|
||||||
|
|
||||||
|
dim "Stopping container..."
|
||||||
|
$COMPOSE down
|
||||||
|
info "Container stopped"
|
||||||
Reference in New Issue
Block a user