Compare commits

...

20 Commits

Author SHA1 Message Date
user
6b7d733650 feat: smooth volume ramping over 200ms in audio streaming
Some checks failed
CI / test (3.11) (push) Failing after 22s
CI / test (3.12) (push) Failing after 22s
CI / test (3.13) (push) Failing after 22s
Volume changes now ramp linearly per-sample via _scale_pcm_ramp instead
of jumping abruptly. Each frame steps _cur_vol toward target by at most
0.1, giving ~200ms for a full 0-to-1 sweep. Fast path unchanged when
volume is stable.
2026-02-21 23:32:22 +01:00
user
c5c61e63cc feat: expand YouTube playlists into individual queue tracks
_resolve_title replaced with _resolve_tracks using --flat-playlist to
enumerate playlist entries. cmd_play enqueues each track individually,
with truncation when the queue is nearly full. Single-video behavior
unchanged.
2026-02-21 23:32:16 +01:00
user
67b2dc827d fix: make !volume apply immediately during playback
stream_audio now accepts a callable for volume, re-read on each PCM
frame instead of capturing a static float at track start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:20:17 +01:00
user
eae36aa1f9 docs: update Mumble docs for pymumble transport
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:16:56 +01:00
user
d884d2bb55 refactor: switch Mumble voice to pymumble transport
asyncio's SSL memory-BIO transport silently drops voice packets even
though text works fine. pymumble uses blocking ssl.SSLSocket.send()
which reliably delivers voice data.

- Rewrite MumbleBot to use pymumble for connection, SSL, ping, and
  voice encoding/sending
- Bridge pymumble thread callbacks to asyncio via
  run_coroutine_threadsafe for text dispatch
- Voice via sound_output.add_sound(pcm) -- pymumble handles Opus
  encoding, packetization, and timing
- Remove custom protobuf codec, voice varint, and opus ctypes wrapper
- Add container patches for pymumble ssl.wrap_socket (Python 3.13) and
  opuslib find_library (musl/Alpine)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:15:42 +01:00
user
d756e7c020 fix: add opus, ffmpeg, yt-dlp to container image
Music playback requires system libraries that were missing from the
Alpine-based container.
2026-02-21 21:47:49 +01:00
user
7206b27fb0 docs: add music playback documentation
USAGE.md music section under Mumble, CHEATSHEET.md music commands,
TASKS.md sprint update for v2.3.0.
2026-02-21 21:42:33 +01:00
user
47b13c3f1f feat: add Mumble music playback with Opus streaming
ctypes libopus encoder (src/derp/opus.py), voice varint/packet builder
and stream_audio method on MumbleBot (src/derp/mumble.py), music plugin
with play/stop/skip/queue/np/volume commands (plugins/music.py).
Audio pipeline: yt-dlp|ffmpeg subprocess -> PCM -> Opus -> UDPTunnel.
67 new tests (1561 total).
2026-02-21 21:42:28 +01:00
user
b074356ec6 fix: always pass server_hostname for Mumble TLS on pre-connected socket
asyncio.open_connection(sock=..., ssl=...) requires server_hostname
even when check_hostname is disabled. Pass self._host unconditionally.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:22:47 +01:00
user
144193e3bb docs: update docs for multi-server support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:04:23 +01:00
user
073659607e feat: add multi-server support
Connect to multiple IRC servers concurrently from a single config file.
Plugins are loaded once and shared; per-server state is isolated via
separate SQLite databases and per-bot runtime state (bot._pstate).

- Add build_server_configs() for [servers.*] config layout
- Bot.__init__ gains name parameter, _pstate dict for plugin isolation
- cli.py runs multiple bots via asyncio.gather
- 9 stateful plugins migrated from module-level dicts to _ps(bot) pattern
- Backward compatible: legacy [server] config works unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:04:20 +01:00
51 changed files with 7111 additions and 769 deletions

View File

@@ -1,10 +1,17 @@
FROM python:3.13-alpine
RUN apk add --no-cache opus ffmpeg yt-dlp && \
ln -s /usr/lib/libopus.so.0 /usr/lib/libopus.so
WORKDIR /app
COPY 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 PYTHONUNBUFFERED=1
ENTRYPOINT ["python", "-m", "derp"]

View File

@@ -24,9 +24,12 @@ make down # Stop
## Features
- 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
- 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)
- TOML configuration with sensible defaults
- Rate limiting, CTCP responses, auto reconnect
@@ -104,6 +107,7 @@ async def on_join(bot, message):
## Documentation
- [Plugin API Reference](docs/API.md)
- [Installation](docs/INSTALL.md)
- [Usage Guide](docs/USAGE.md)
- [Cheatsheet](docs/CHEATSHEET.md)

View File

@@ -110,8 +110,8 @@
## v2.0.0 -- Multi-Server + Integrations
- [ ] Multi-server support (per-server config, shared plugins)
- [ ] Stable plugin API (versioned, breaking change policy)
- [x] Multi-server support (per-server config, shared plugins)
- [x] Stable plugin API (versioned, breaking change policy)
- [x] Paste overflow (auto-paste long output to FlaskPaste, return link)
- [x] URL shortener integration (shorten URLs in subscription announcements)
- [x] Webhook listener (HTTP endpoint for push events to channels)
@@ -128,3 +128,34 @@
- [x] `cron` plugin (scheduled bot commands on a timer)
- [x] Plugin command unit tests (encode, hash, dns, cidr, defang)
- [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

View File

@@ -1,6 +1,91 @@
# derp - Tasks
## Current Sprint -- v2.0.0 ACL + Webhook (2026-02-21)
## Current 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 |
|-----|--------|------|

82
TODO.md
View File

@@ -2,8 +2,8 @@
## Core
- [ ] Multi-server support (per-server config, shared plugins)
- [ ] Stable plugin API (versioned, breaking change policy)
- [x] Multi-server support (per-server config, shared plugins)
- [x] Stable plugin API (versioned, breaking change policy)
- [x] Paste overflow (auto-paste long output to FlaskPaste)
- [x] URL shortener integration (shorten URLs in subscription announcements)
- [x] Webhook listener (HTTP endpoint for push events to channels)
@@ -82,6 +82,84 @@ is preserved in git history for reference.
- [x] `shorten` -- manual URL shortening
- [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
## 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)
## 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
- [x] Plugin command unit tests (encode, hash, dns, cidr, defang)

515
docs/API.md Normal file
View File

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

View File

@@ -482,6 +482,86 @@ curl -X POST http://127.0.0.1:8080/ \
POST JSON: `{"channel":"#chan","text":"msg"}`. Optional `"action":true`.
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
!stop # Stop playback, clear queue
!skip # Skip current track
!queue # Show queue
!queue <url> # Add to queue (alias for !play)
!np # Now playing
!volume # Show current volume
!volume 75 # Set volume (0-100, default 50)
```
Requires: `yt-dlp`, `ffmpeg`, `libopus` on the host.
Max 50 tracks in queue. Playlists auto-expand; excess truncated at limit.
Volume ramps smoothly over ~200ms (no abrupt jumps mid-playback).
Mumble-only: `!play` replies with error on other adapters, others silently no-op.
## Plugin Template
```python
@@ -510,6 +590,35 @@ msg.params # All params list
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
```

View File

@@ -23,13 +23,16 @@ derp --config /path/to/derp.toml --verbose
## Configuration
All settings in `config/derp.toml`:
All settings in `config/derp.toml`.
### Single-Server (Legacy)
```toml
[server]
host = "irc.libera.chat" # IRC server hostname
port = 6697 # Port (6697 = TLS, 6667 = plain)
tls = true # Enable TLS encryption
proxy = false # Route through SOCKS5 proxy (default: false)
nick = "derp" # Bot nickname
user = "derp" # Username (ident)
realname = "derp IRC bot" # Real name field
@@ -67,6 +70,55 @@ port = 8080 # Bind port
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
| Command | Description |
@@ -358,8 +410,8 @@ keys = bot.state.keys("myplugin")
bot.state.clear("myplugin")
```
Data is stored in `data/state.db` (SQLite). Each plugin gets its own
namespace so keys never collide.
Data is stored in `data/state-<name>.db` (SQLite, one per server). Each
plugin gets its own namespace so keys never collide.
### Inspection Commands (admin)
@@ -1250,3 +1302,287 @@ timeout = 10 # HTTP fetch timeout
max_urls = 3 # max URLs to preview per message
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)
!stop Stop playback, clear queue
!skip Skip current track
!queue Show queue
!queue <url> Add to queue (alias for !play)
!np Now playing
!volume [0-100] Get/set volume
!testtone Play 3-second 440Hz test tone
```
- Queue holds up to 50 tracks
- Playlists are expanded into individual tracks; excess tracks are
truncated at the queue limit
- Volume changes ramp smoothly over ~200ms (no abrupt jumps)
- Default volume: 50%
- Titles resolved via `yt-dlp --flat-playlist` before playback
- Audio pipeline: `yt-dlp | ffmpeg` subprocess, 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

View File

@@ -0,0 +1,45 @@
"""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
"""
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")

View File

@@ -77,12 +77,19 @@ _DEVTO_API = "https://dev.to/api/articles"
_MEDIUM_FEED_URL = "https://medium.com/feed/tag"
_HUGGINGFACE_API = "https://huggingface.co/api/models"
# -- Module-level tracking ---------------------------------------------------
# -- Per-bot plugin runtime state --------------------------------------------
_pollers: dict[str, asyncio.Task] = {}
_subscriptions: dict[str, dict] = {}
_errors: dict[str, dict[str, int]] = {}
_poll_count: dict[str, int] = {}
def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("alert", {
"pollers": {},
"subs": {},
"errors": {},
"poll_count": {},
"db_conn": None,
"db_path": "data/alert_history.db",
})
# -- Concurrent fetch helper -------------------------------------------------
@@ -121,18 +128,16 @@ def _fetch_many(targets, *, build_req, timeout, parse):
# -- History database --------------------------------------------------------
_DB_PATH = Path("data/alert_history.db")
_conn: sqlite3.Connection | None = None
def _db() -> sqlite3.Connection:
def _db(bot) -> sqlite3.Connection:
"""Lazy-init the history database connection and schema."""
global _conn
if _conn is not None:
return _conn
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
_conn = sqlite3.connect(str(_DB_PATH))
_conn.execute("""
ps = _ps(bot)
if ps["db_conn"] is not None:
return ps["db_conn"]
db_path = Path(ps.get("db_path", "data/alert_history.db"))
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.execute("""
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL,
@@ -152,34 +157,35 @@ def _db() -> sqlite3.Connection:
("extra", "''"),
]:
try:
_conn.execute(
conn.execute(
f"ALTER TABLE results ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}"
)
except sqlite3.OperationalError:
pass # column already exists
_conn.execute(
conn.execute(
"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)"
)
# 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 = ''"
).fetchall():
_conn.execute(
conn.execute(
"UPDATE results SET short_id = ? WHERE id = ?",
(_make_short_id(backend, item_id), row_id),
)
_conn.commit()
return _conn
conn.commit()
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:
"""Persist a matched result to the history database. Returns short_id."""
short_id = _make_short_id(backend, item.get("id", ""))
db = _db()
db = _db(bot)
db.execute(
"INSERT INTO results"
" (channel, alert, backend, item_id, title, url, date, found_at,"
@@ -1814,19 +1820,20 @@ def _delete(bot, key: str) -> None:
async def _poll_once(bot, key: str, announce: bool = True) -> None:
"""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:
data = _load(bot, key)
if data is None:
return
_subscriptions[key] = data
ps["subs"][key] = data
keyword = data["keyword"]
now = datetime.now(timezone.utc).isoformat()
data["last_poll"] = now
cycle = _poll_count[key] = _poll_count.get(key, 0) + 1
tag_errors = _errors.setdefault(key, {})
cycle = ps["poll_count"][key] = ps["poll_count"].get(key, 0) + 1
tag_errors = ps["errors"].setdefault(key, {})
loop = asyncio.get_running_loop()
for tag, backend in _BACKENDS.items():
@@ -1917,7 +1924,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
except Exception:
pass
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)"
extra = item.get("extra", "")
@@ -1938,7 +1945,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
seen_list = seen_list[-_MAX_SEEN:]
data.setdefault("seen", {})[tag] = seen_list
_subscriptions[key] = data
ps["subs"][key] = data
_save(bot, key, data)
@@ -1946,7 +1953,7 @@ async def _poll_loop(bot, key: str) -> None:
"""Infinite poll loop for one alert subscription."""
try:
while True:
data = _subscriptions.get(key) or _load(bot, key)
data = _ps(bot)["subs"].get(key) or _load(bot, key)
if data is None:
return
interval = data.get("interval", _DEFAULT_INTERVAL)
@@ -1958,35 +1965,38 @@ async def _poll_loop(bot, key: str) -> None:
def _start_poller(bot, key: str) -> None:
"""Create and track a poller task."""
existing = _pollers.get(key)
ps = _ps(bot)
existing = ps["pollers"].get(key)
if existing and not existing.done():
return
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."""
task = _pollers.pop(key, None)
ps = _ps(bot)
task = ps["pollers"].pop(key, None)
if task and not task.done():
task.cancel()
_subscriptions.pop(key, None)
_errors.pop(key, None)
_poll_count.pop(key, None)
ps["subs"].pop(key, None)
ps["errors"].pop(key, None)
ps["poll_count"].pop(key, None)
# -- Restore on connect -----------------------------------------------------
def _restore(bot) -> None:
"""Rebuild pollers from persisted state."""
ps = _ps(bot)
for key in bot.state.keys("alert"):
existing = _pollers.get(key)
existing = ps["pollers"].get(key)
if existing and not existing.done():
continue
data = _load(bot, key)
if data is None:
continue
_subscriptions[key] = data
ps["subs"][key] = data
_start_poller(bot, key)
@@ -2056,9 +2066,9 @@ async def cmd_alert(bot, message):
if data is None:
await bot.reply(message, f"No alert '{name}' in this channel")
return
_subscriptions[key] = data
_ps(bot)["subs"][key] = data
await _poll_once(bot, key, announce=True)
data = _subscriptions.get(key, data)
data = _ps(bot)["subs"].get(key, data)
errs = data.get("last_errors", {})
if errs:
tags = ", ".join(sorted(errs))
@@ -2087,7 +2097,7 @@ async def cmd_alert(bot, message):
limit = max(1, min(int(parts[3]), 20))
except ValueError:
limit = 5
db = _db()
db = _db(bot)
rows = db.execute(
"SELECT id, backend, title, url, date, found_at, short_id,"
" short_url, extra FROM results"
@@ -2141,7 +2151,7 @@ async def cmd_alert(bot, message):
return
short_id = parts[2].lower()
channel = message.target
db = _db()
db = _db(bot)
row = db.execute(
"SELECT alert, backend, title, url, date, found_at, short_id,"
" extra"
@@ -2216,7 +2226,7 @@ async def cmd_alert(bot, message):
"seen": {},
}
_save(bot, key, data)
_subscriptions[key] = data
_ps(bot)["subs"][key] = data
# Seed seen IDs in background (silent poll), then start the poller
async def _seed():
@@ -2251,7 +2261,7 @@ async def cmd_alert(bot, message):
await bot.reply(message, f"No alert '{name}' in this channel")
return
_stop_poller(key)
_stop_poller(bot, key)
_delete(bot, key)
await bot.reply(message, f"Removed '{name}'")
return

View File

@@ -58,7 +58,7 @@ async def cmd_help(bot, message):
@command("version", help="Show bot version")
async def cmd_version(bot, message):
"""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")

View File

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

330
plugins/music.py Normal file
View File

@@ -0,0 +1,330 @@
"""Plugin: music playback for Mumble voice channels."""
from __future__ import annotations
import asyncio
import logging
import subprocess
from dataclasses import dataclass
from derp.plugin import command
log = logging.getLogger(__name__)
_MAX_QUEUE = 50
_MAX_TITLE_LEN = 80
@dataclass(slots=True)
class _Track:
url: str
title: str
requester: str
# -- Per-bot runtime state ---------------------------------------------------
def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("music", {
"queue": [],
"current": None,
"volume": 50,
"task": None,
"done_event": None,
})
# -- Helpers -----------------------------------------------------------------
def _is_mumble(bot) -> bool:
"""Check if bot supports voice streaming."""
return hasattr(bot, "stream_audio")
def _truncate(text: str, max_len: int = _MAX_TITLE_LEN) -> str:
"""Truncate text with ellipsis if needed."""
if len(text) <= max_len:
return text
return text[: max_len - 3].rstrip() + "..."
def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, str]]:
"""Resolve URL into (url, title) pairs via yt-dlp. Blocking, run in executor.
Handles both single videos and playlists. For playlists, returns up to
``max_tracks`` individual entries. Falls back to ``[(url, url)]`` on error.
"""
try:
result = subprocess.run(
[
"yt-dlp", "--flat-playlist", "--print", "url",
"--print", "title", "--no-warnings",
f"--playlist-end={max_tracks}", url,
],
capture_output=True, text=True, timeout=30,
)
lines = result.stdout.strip().splitlines()
if len(lines) < 2:
return [(url, url)]
tracks = []
for i in range(0, len(lines) - 1, 2):
track_url = lines[i].strip()
track_title = lines[i + 1].strip()
if track_url:
tracks.append((track_url, track_title or track_url))
return tracks if tracks else [(url, url)]
except Exception:
return [(url, url)]
# -- Play loop ---------------------------------------------------------------
async def _play_loop(bot) -> None:
"""Pop tracks from queue and stream them sequentially."""
ps = _ps(bot)
try:
while ps["queue"]:
track = ps["queue"].pop(0)
ps["current"] = track
done = asyncio.Event()
ps["done_event"] = done
try:
await bot.stream_audio(
track.url,
volume=lambda: ps["volume"] / 100.0,
on_done=done,
)
except asyncio.CancelledError:
raise
except Exception:
log.exception("music: stream error for %s", track.url)
await done.wait()
except asyncio.CancelledError:
pass
finally:
ps["current"] = None
ps["done_event"] = None
ps["task"] = None
def _ensure_loop(bot) -> None:
"""Start the play loop if not already running."""
ps = _ps(bot)
task = ps.get("task")
if task and not task.done():
return
ps["task"] = bot._spawn(_play_loop(bot), name="music-play-loop")
# -- Commands ----------------------------------------------------------------
@command("play", help="Music: !play <url|playlist>")
async def cmd_play(bot, message):
"""Play a URL or add to queue if already playing.
Usage:
!play <url> Play audio from URL (YouTube, SoundCloud, etc.)
Playlists are expanded into individual tracks. If the queue is nearly
full, only as many tracks as will fit are enqueued.
"""
if not _is_mumble(bot):
await bot.reply(message, "Music playback is Mumble-only")
return
parts = message.text.split(None, 1)
if len(parts) < 2:
await bot.reply(message, "Usage: !play <url>")
return
url = parts[1].strip()
ps = _ps(bot)
if len(ps["queue"]) >= _MAX_QUEUE:
await bot.reply(message, f"Queue full ({_MAX_QUEUE} tracks)")
return
remaining = _MAX_QUEUE - len(ps["queue"])
loop = asyncio.get_running_loop()
resolved = await loop.run_in_executor(None, _resolve_tracks, url, remaining)
was_idle = ps["current"] is None
requester = message.nick or "?"
added = 0
for track_url, track_title in resolved[:remaining]:
ps["queue"].append(_Track(url=track_url, title=track_title,
requester=requester))
added += 1
total_resolved = len(resolved)
if added == 1:
title = _truncate(resolved[0][1])
if was_idle:
await bot.reply(message, f"Playing: {title}")
else:
pos = len(ps["queue"])
await bot.reply(message, f"Queued #{pos}: {title}")
elif added < total_resolved:
await bot.reply(
message,
f"Queued {added} of {total_resolved} tracks (queue full)",
)
else:
await bot.reply(message, f"Queued {added} tracks")
if was_idle:
_ensure_loop(bot)
@command("stop", help="Music: !stop")
async def cmd_stop(bot, message):
"""Stop playback and clear queue."""
if not _is_mumble(bot):
return
ps = _ps(bot)
ps["queue"].clear()
task = ps.get("task")
if task and not task.done():
task.cancel()
ps["current"] = None
ps["task"] = None
ps["done_event"] = None
await bot.reply(message, "Stopped")
@command("skip", help="Music: !skip")
async def cmd_skip(bot, message):
"""Skip current track, advance to next in queue."""
if not _is_mumble(bot):
return
ps = _ps(bot)
if ps["current"] is None:
await bot.reply(message, "Nothing playing")
return
task = ps.get("task")
if task and not task.done():
task.cancel()
skipped = ps["current"]
ps["current"] = None
ps["task"] = None
if ps["queue"]:
_ensure_loop(bot)
await bot.reply(
message,
f"Skipped: {_truncate(skipped.title)}",
)
else:
await bot.reply(message, "Skipped, queue empty")
@command("queue", help="Music: !queue [url]")
async def cmd_queue(bot, message):
"""Show queue or add a URL.
Usage:
!queue Show current queue
!queue <url> Add URL to queue (alias for !play)
"""
if not _is_mumble(bot):
return
parts = message.text.split(None, 1)
if len(parts) >= 2:
# Alias for !play
await cmd_play(bot, message)
return
ps = _ps(bot)
lines = []
if ps["current"]:
lines.append(
f"Now: {_truncate(ps['current'].title)}"
f" [{ps['current'].requester}]"
)
if ps["queue"]:
for i, track in enumerate(ps["queue"], 1):
lines.append(
f" {i}. {_truncate(track.title)} [{track.requester}]"
)
else:
if not ps["current"]:
lines.append("Queue empty")
for line in lines:
await bot.reply(message, line)
@command("np", help="Music: !np")
async def cmd_np(bot, message):
"""Show now-playing track."""
if not _is_mumble(bot):
return
ps = _ps(bot)
if ps["current"] is None:
await bot.reply(message, "Nothing playing")
return
track = ps["current"]
await bot.reply(
message,
f"Now playing: {_truncate(track.title)} [{track.requester}]",
)
@command("testtone", help="Music: !testtone -- debug sine wave")
async def cmd_testtone(bot, message):
"""Send a 3-second test tone for voice debugging."""
if not _is_mumble(bot):
await bot.reply(message, "Mumble-only feature")
return
await bot.reply(message, "Sending 440Hz test tone (3s)...")
await bot.test_tone(3.0)
await bot.reply(message, "Test tone complete")
@command("volume", help="Music: !volume [0-100]")
async def cmd_volume(bot, message):
"""Get or set playback volume.
Usage:
!volume Show current volume
!volume <0-100> Set volume (takes effect immediately)
"""
if not _is_mumble(bot):
return
ps = _ps(bot)
parts = message.text.split(None, 1)
if len(parts) < 2:
await bot.reply(message, f"Volume: {ps['volume']}%")
return
try:
val = int(parts[1])
except ValueError:
await bot.reply(message, "Usage: !volume <0-100>")
return
if val < 0 or val > 100:
await bot.reply(message, "Volume must be 0-100")
return
ps["volume"] = val
await bot.reply(message, f"Volume set to {val}%")

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,9 +38,14 @@ _SKIP_EXTS = frozenset({
# Trailing punctuation to strip, but preserve balanced parens
_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 -------------------------------------------------------------
@@ -202,21 +207,22 @@ def _fetch_title(url: str) -> tuple[str, str]:
# -- 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."""
seen = _ps(bot)["seen"]
now = time.monotonic()
last = _seen.get(url)
last = seen.get(url)
if last is not None and (now - last) < cooldown:
return True
# Prune if cache is too large
if len(_seen) >= _CACHE_MAX:
if len(seen) >= _CACHE_MAX:
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:
del _seen[k]
del seen[k]
_seen[url] = now
seen[url] = now
return False
@@ -261,7 +267,7 @@ async def on_privmsg(bot, message):
for url in urls:
if _is_ignored_url(url, ignore_hosts):
continue
if _check_cooldown(url, cooldown):
if _check_cooldown(bot, url, cooldown):
continue
title, desc = await loop.run_in_executor(None, _fetch_title, url)

View File

@@ -14,9 +14,15 @@ from derp.plugin import command, event
log = logging.getLogger(__name__)
_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:
@@ -47,7 +53,7 @@ async def _handle_request(reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
bot, secret: str) -> None:
"""Parse one HTTP request and dispatch to IRC."""
global _request_count
ps = _ps(bot)
try:
# Read request line
@@ -117,7 +123,7 @@ async def _handle_request(reader: asyncio.StreamReader,
else:
await bot.send(channel, text)
_request_count += 1
ps["request_count"] += 1
writer.write(_http_response(200, "OK", "sent"))
log.info("webhook: relayed to %s (%d bytes)", channel, len(text))
@@ -140,9 +146,9 @@ async def _handle_request(reader: asyncio.StreamReader,
@event("001")
async def on_connect(bot, message):
"""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
cfg = bot.config.get("webhook", {})
@@ -157,9 +163,9 @@ async def on_connect(bot, message):
await _handle_request(reader, writer, bot, secret)
try:
_server = await asyncio.start_server(handler, host, port)
_started = time.monotonic()
_request_count = 0
ps["server"] = await asyncio.start_server(handler, host, port)
ps["started"] = time.monotonic()
ps["request_count"] = 0
log.info("webhook: listening on %s:%d", host, port)
except OSError as 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)
async def cmd_webhook(bot, message):
"""Display webhook server status."""
if _server is None:
ps = _ps(bot)
if ps["server"] is None:
await bot.reply(message, "Webhook: not running")
return
socks = _server.sockets
socks = ps["server"].sockets
if socks:
addr = socks[0].getsockname()
address = f"{addr[0]}:{addr[1]}"
else:
address = "unknown"
elapsed = int(time.monotonic() - _started)
elapsed = int(time.monotonic() - ps["started"])
hours, rem = divmod(elapsed, 3600)
minutes, secs = divmod(rem, 60)
parts = []
@@ -192,5 +200,5 @@ async def cmd_webhook(bot, message):
await bot.reply(
message,
f"Webhook: {address} | {_request_count} requests | up {uptime}",
f"Webhook: {address} | {ps['request_count']} requests | up {uptime}",
)

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "derp"
version = "0.1.0"
version = "2.0.0"
description = "Asyncio IRC bot with plugin system"
requires-python = ">=3.11"
license = "MIT"

View File

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

View File

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

View File

@@ -77,14 +77,17 @@ class _TokenBucket:
class Bot:
"""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.registry = registry
self._pstate: dict = {} # per-bot plugin runtime state
self.conn = IRCConnection(
host=config["server"]["host"],
port=config["server"]["port"],
tls=config["server"]["tls"],
tls_verify=config["server"].get("tls_verify", True),
proxy=config["server"].get("proxy", False),
)
self.nick: str = config["server"]["nick"]
self.prefix: str = config["bot"]["prefix"]
@@ -98,7 +101,7 @@ class Bot:
self._opers: set[str] = set() # hostmasks of known IRC operators
self._caps: set[str] = set() # negotiated IRCv3 caps
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_cfg = config.get("bot", {})
self._bucket = _TokenBucket(

View File

@@ -9,7 +9,7 @@ import sys
from derp import __version__
from derp.bot import Bot
from derp.config import resolve_config
from derp.config import build_server_configs, resolve_config
from derp.log import JsonFormatter
from derp.plugin import PluginRegistry
@@ -56,14 +56,14 @@ def build_parser() -> argparse.ArgumentParser:
return p
def _run(bot: Bot) -> None:
"""Run the bot event loop with graceful SIGTERM handling."""
def _run(bots: list) -> None:
"""Run all bots concurrently with graceful SIGTERM handling."""
import signal
async def _start_with_signal():
loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGTERM, _shutdown, bot)
await bot.start()
loop.add_signal_handler(signal.SIGTERM, _shutdown, bots)
await asyncio.gather(*(bot.start() for bot in bots))
try:
asyncio.run(_start_with_signal())
@@ -71,11 +71,13 @@ def _run(bot: Bot) -> None:
logging.getLogger("derp").info("interrupted, shutting down")
def _shutdown(bot: Bot) -> None:
"""Signal handler: stop the bot loop so cProfile can flush."""
def _shutdown(bots: list) -> None:
"""Signal handler: stop all bot loops so cProfile can flush."""
logging.getLogger("derp").info("SIGTERM received, shutting down")
bot._running = False
asyncio.get_running_loop().create_task(bot.conn.close())
for bot in bots:
bot._running = False
if hasattr(bot, "conn"):
asyncio.get_running_loop().create_task(bot.conn.close())
def _dump_tracemalloc(log: logging.Logger, path: str, limit: int = 25) -> None:
@@ -121,9 +123,40 @@ def main(argv: list[str] | None = None) -> int:
log = logging.getLogger("derp")
log.info("derp %s starting", __version__)
server_configs = build_server_configs(config)
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)
names = ", ".join(b.name for b in bots)
log.info("servers: %s", names)
if args.tracemalloc:
import tracemalloc
@@ -135,10 +168,10 @@ def main(argv: list[str] | None = None) -> int:
import cProfile
log.info("cProfile enabled, output: %s", args.cprofile)
cProfile.runctx("_run(bot)", globals(), {"bot": bot, "_run": _run}, args.cprofile)
cProfile.runctx("_run(bots)", globals(), {"bots": bots, "_run": _run}, args.cprofile)
log.info("profile saved to %s", args.cprofile)
else:
_run(bot)
_run(bots)
if args.tracemalloc:
_dump_tracemalloc(log, "data/derp.malloc")

View File

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

View File

@@ -1,4 +1,4 @@
"""Proxy-aware HTTP/TCP helpers -- routes outbound traffic through SOCKS5."""
"""HTTP/TCP helpers -- optional SOCKS5 proxy routing for outbound traffic."""
import asyncio
import logging
@@ -85,15 +85,20 @@ class _ProxyHandler(SocksiPyHandler, urllib.request.HTTPSHandler):
# -- Public HTTP interface ---------------------------------------------------
def urlopen(req, *, timeout=None, context=None, retries=None):
"""Proxy-aware drop-in for urllib.request.urlopen.
def urlopen(req, *, timeout=None, context=None, retries=None, proxy=True):
"""HTTP urlopen with optional SOCKS5 proxy.
Uses connection pooling via urllib3 for default requests.
Uses connection pooling via urllib3 for proxied requests.
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.
"""
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)
if context is not None:
return _urlopen_legacy(req, timeout=timeout, context=context, retries=max_retries)
@@ -140,6 +145,26 @@ def urlopen(req, *, timeout=None, context=None, retries=None):
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):
"""Open URL through legacy opener (custom SSL context)."""
max_retries = retries if retries is not None else _MAX_RETRIES
@@ -159,27 +184,32 @@ def _urlopen_legacy(req, *, timeout=None, context=None, retries=None):
time.sleep(delay)
def build_opener(*handlers, context=None):
"""Proxy-aware drop-in for urllib.request.build_opener."""
def build_opener(*handlers, context=None, proxy=True):
"""Build a URL opener, optionally with SOCKS5 proxy."""
if not proxy:
return urllib.request.build_opener(*handlers)
if not handlers and context is None:
return _get_opener()
proxy = _ProxyHandler(context=context)
return urllib.request.build_opener(proxy, *handlers)
proxy_handler = _ProxyHandler(context=context)
return urllib.request.build_opener(proxy_handler, *handlers)
# -- Raw TCP helpers (unchanged) ---------------------------------------------
def create_connection(address, *, timeout=None):
"""SOCKS5-proxied drop-in for socket.create_connection.
def create_connection(address, *, timeout=None, proxy=True):
"""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.
"""
host, port = address
for attempt in range(_MAX_RETRIES):
try:
sock = socks.socksocket()
sock.set_proxy(SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True)
if proxy:
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:
sock.settimeout(timeout)
sock.connect((host, port))
@@ -193,12 +223,27 @@ def create_connection(address, *, timeout=None):
time.sleep(delay)
async def open_connection(host, port, *, timeout=None):
"""SOCKS5-proxied drop-in for asyncio.open_connection.
async def open_connection(host, port, *, timeout=None, proxy=True):
"""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.
"""
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()
def _connect():

View File

@@ -137,11 +137,12 @@ class IRCConnection:
"""Async TCP/TLS connection to an IRC server."""
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.port = port
self.tls = tls
self.tls_verify = tls_verify
self.proxy = proxy
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
@@ -154,10 +155,26 @@ class IRCConnection:
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
log.info("connecting to %s:%d (tls=%s)", self.host, self.port, self.tls)
self._reader, self._writer = await asyncio.open_connection(
self.host, self.port, ssl=ssl_ctx
)
log.info("connecting to %s:%d (tls=%s, proxy=%s)",
self.host, self.port, self.tls, self.proxy)
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")
async def send(self, line: str) -> None:

615
src/derp/mumble.py Normal file
View File

@@ -0,0 +1,615 @@
"""Mumble adapter: pymumble transport with asyncio plugin dispatch."""
from __future__ import annotations
import array
import asyncio
import html
import logging
import re
import struct
import time
from dataclasses import dataclass, field
from pathlib import Path
import pymumble_py3 as pymumble
from pymumble_py3.constants import (
PYMUMBLE_CLBK_CONNECTED,
PYMUMBLE_CLBK_DISCONNECTED,
PYMUMBLE_CLBK_TEXTMESSAGERECEIVED,
)
from derp.bot import _TokenBucket
from derp.plugin import TIERS, PluginRegistry
from derp.state import StateStore
log = logging.getLogger(__name__)
_AMBIGUOUS = object() # sentinel for ambiguous prefix matches
# PCM constants for audio streaming
_SAMPLE_RATE = 48000
_FRAME_SIZE = 960 # 20ms at 48kHz
_FRAME_BYTES = 1920 # 960 samples * 2 bytes (s16le)
# -- HTML helpers ------------------------------------------------------------
_TAG_RE = re.compile(r"<[^>]+>")
def _strip_html(text: str) -> str:
"""Strip HTML tags and unescape entities."""
return html.unescape(_TAG_RE.sub("", text))
def _escape_html(text: str) -> str:
"""Escape text for Mumble HTML messages."""
return html.escape(text, quote=False)
def _shell_quote(s: str) -> str:
"""Quote a string for safe shell interpolation."""
return "'" + s.replace("'", "'\\''") + "'"
def _scale_pcm(data: bytes, volume: float) -> bytes:
"""Scale s16le PCM samples by a volume factor, clamped to [-32768, 32767]."""
samples = array.array("h")
samples.frombytes(data)
for i in range(len(samples)):
val = int(samples[i] * volume)
if val > 32767:
val = 32767
elif val < -32768:
val = -32768
samples[i] = val
return samples.tobytes()
def _scale_pcm_ramp(data: bytes, vol_start: float, vol_end: float) -> bytes:
"""Scale s16le PCM with linear volume interpolation across the frame.
Each sample is scaled by a linearly interpolated volume between
``vol_start`` and ``vol_end``. Degenerates to flat scaling when
both values are equal.
"""
samples = array.array("h")
samples.frombytes(data)
n = len(samples)
if n == 0:
return data
for i in range(n):
vol = vol_start + (vol_end - vol_start) * (i / n)
val = int(samples[i] * vol)
if val > 32767:
val = 32767
elif val < -32768:
val = -32768
samples[i] = val
return samples.tobytes()
# -- MumbleMessage -----------------------------------------------------------
@dataclass(slots=True)
class MumbleMessage:
"""Parsed Mumble TextMessage, 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 message data
nick: str | None # sender username (from session lookup)
prefix: str | None # sender username (for ACL)
text: str | None # message text (HTML stripped)
target: str | None # channel_id as string (or "dm" for DMs)
is_channel: bool = True # True for channel msgs, False for DMs
command: str = "PRIVMSG" # compat shim
params: list[str] = field(default_factory=list)
tags: dict[str, str] = field(default_factory=dict)
# -- MumbleBot --------------------------------------------------------------
class MumbleBot:
"""Mumble bot adapter using pymumble for connection and voice.
Exposes the same public API as :class:`derp.bot.Bot` so that
protocol-agnostic plugins work without modification.
Voice uses pymumble's sound_output (blocking SSL socket, proven
reliable for audio delivery).
"""
def __init__(self, name: str, config: dict, registry: PluginRegistry) -> None:
self.name = name
self.config = config
self.registry = registry
self._pstate: dict = {}
mu_cfg = config.get("mumble", {})
self._host: str = mu_cfg.get("host", "127.0.0.1")
self._port: int = mu_cfg.get("port", 64738)
self._username: str = mu_cfg.get("username", "derp")
self._password: str = mu_cfg.get("password", "")
self.nick: str = self._username
self.prefix: str = (
mu_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 mu_cfg.get("admins", [])]
self._operators: list[str] = [str(x) for x in mu_cfg.get("operators", [])]
self._trusted: list[str] = [str(x) for x in mu_cfg.get("trusted", [])]
self.state = StateStore(f"data/state-{name}.db")
# pymumble state
self._mumble: pymumble.Mumble | None = None
self._loop: asyncio.AbstractEventLoop | None = None
rate_cfg = config.get("bot", {})
self._bucket = _TokenBucket(
rate=rate_cfg.get("rate_limit", 2.0),
burst=rate_cfg.get("rate_burst", 5),
)
# -- Connection ----------------------------------------------------------
def _connect_sync(self) -> None:
"""Create and start pymumble connection (blocking, run in executor)."""
self._mumble = pymumble.Mumble(
self._host, self._username,
port=self._port, password=self._password,
reconnect=True,
)
self._mumble.callbacks.set_callback(
PYMUMBLE_CLBK_TEXTMESSAGERECEIVED,
self._on_text_message,
)
self._mumble.callbacks.set_callback(
PYMUMBLE_CLBK_CONNECTED,
self._on_connected,
)
self._mumble.callbacks.set_callback(
PYMUMBLE_CLBK_DISCONNECTED,
self._on_disconnected,
)
self._mumble.set_receive_sound(False)
self._mumble.start()
self._mumble.is_ready()
def _on_connected(self) -> None:
"""Callback from pymumble thread: connection established."""
session = getattr(self._mumble.users, "myself_session", "?")
log.info("mumble: connected as %s on %s:%d (session=%s)",
self._username, self._host, self._port, session)
def _on_disconnected(self) -> None:
"""Callback from pymumble thread: connection lost."""
log.warning("mumble: disconnected")
def _on_text_message(self, message) -> None:
"""Callback from pymumble thread: text message received.
Bridges to the asyncio event loop for command dispatch.
"""
if self._loop is None:
return
asyncio.run_coroutine_threadsafe(
self._handle_text(message), self._loop,
)
async def _handle_text(self, pb_msg) -> None:
"""Process a text message from pymumble (runs on asyncio loop)."""
text = _strip_html(pb_msg.message)
actor = pb_msg.actor
# Look up sender username
nick = None
try:
nick = self._mumble.users[actor]["name"]
except (KeyError, TypeError):
pass
# Determine target: channel or DM
if pb_msg.channel_id:
target = str(pb_msg.channel_id[0])
is_channel = True
elif pb_msg.session:
target = "dm"
is_channel = False
else:
target = None
is_channel = True
msg = MumbleMessage(
raw={},
nick=nick,
prefix=nick,
text=text,
target=target,
is_channel=is_channel,
params=[target or "", text],
)
await self._dispatch_command(msg)
# -- Lifecycle -----------------------------------------------------------
async def start(self) -> None:
"""Connect via pymumble and run until stopped."""
self._running = True
self._loop = asyncio.get_running_loop()
await self._loop.run_in_executor(None, self._connect_sync)
try:
while self._running:
await asyncio.sleep(1)
finally:
if self._mumble:
self._mumble.stop()
self._mumble = None
# -- Command dispatch ----------------------------------------------------
async def _dispatch_command(self, msg: MumbleMessage) -> None:
"""Parse and dispatch a command from a Mumble text 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("mumble: error in command handler '%s'", cmd_name)
def _resolve_command(self, name: str):
"""Resolve command name with unambiguous prefix matching."""
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 Mumble."""
return True
# -- Permission tiers ----------------------------------------------------
def _get_tier(self, msg: MumbleMessage) -> str:
"""Determine permission tier from username."""
if not msg.prefix:
return "user"
for name in self._admins:
if msg.prefix == name:
return "admin"
for name in self._operators:
if msg.prefix == name:
return "oper"
for name in self._trusted:
if msg.prefix == name:
return "trusted"
return "user"
def _is_admin(self, msg: MumbleMessage) -> bool:
"""Check if the message sender is a bot admin."""
return self._get_tier(msg) == "admin"
# -- Public API for plugins ----------------------------------------------
def _send_text_sync(self, channel_id: int, html_text: str) -> None:
"""Send a text message via pymumble (blocking, thread-safe)."""
try:
channel = self._mumble.channels[channel_id]
channel.send_text_message(html_text)
except Exception:
log.exception("mumble: failed to send text to channel %d",
channel_id)
async def _send_html(self, target: str, html_text: str) -> None:
"""Send a TextMessage with pre-formatted HTML (no escaping)."""
if self._mumble is None:
return
await self._bucket.acquire()
try:
channel_id = int(target)
except (ValueError, TypeError):
channel_id = 0
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None, self._send_text_sync, channel_id, html_text,
)
async def send(self, target: str, text: str) -> None:
"""Send a TextMessage to a channel (HTML-escaped, rate-limited)."""
await self._send_html(target, _escape_html(text))
async def reply(self, msg, text: str) -> None:
"""Reply to the source channel."""
if msg.target and msg.target != "dm":
await self.send(msg.target, text)
elif msg.target == "dm":
await self.send("0", text)
async def long_reply(
self, msg, lines: list[str], *,
label: str = "",
) -> None:
"""Reply with a list of lines; paste overflow to FlaskPaste."""
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
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 HTML text."""
await self._send_html(target, f"<i>{_escape_html(text)}</i>")
# -- Voice streaming -----------------------------------------------------
async def test_tone(self, duration: float = 3.0) -> None:
"""Send a 440Hz sine test tone for debugging voice output."""
import math
if self._mumble is None:
return
log.info("test_tone: sending %.1fs of 440Hz sine", duration)
total_frames = int(duration / 0.02)
for i in range(total_frames):
samples = []
for j in range(_FRAME_SIZE):
t = (i * _FRAME_SIZE + j) / _SAMPLE_RATE
samples.append(int(16000 * math.sin(2 * math.pi * 440 * t)))
pcm = struct.pack(f"<{_FRAME_SIZE}h", *samples)
self._mumble.sound_output.add_sound(pcm)
# Keep buffer shallow so we can cancel promptly
while self._mumble.sound_output.get_buffer_size() > 0.5:
await asyncio.sleep(0.05)
# Wait for buffer to drain
while self._mumble.sound_output.get_buffer_size() > 0:
await asyncio.sleep(0.1)
log.info("test_tone: done")
async def stream_audio(
self,
url: str,
*,
volume=0.5,
on_done=None,
) -> None:
"""Stream audio from URL through yt-dlp|ffmpeg to voice channel.
Pipeline:
yt-dlp -o - -f bestaudio <url>
| ffmpeg -i pipe:0 -f s16le -ar 48000 -ac 1 pipe:1
Feeds raw PCM to pymumble's sound_output which handles Opus
encoding, packetization, and timing.
``volume`` may be a float (static) or a callable returning float
(dynamic, re-read each frame).
"""
if self._mumble is None:
return
_get_vol = volume if callable(volume) else lambda: volume
log.info("stream_audio: starting pipeline for %s (vol=%.0f%%)",
url, _get_vol() * 100)
proc = await asyncio.create_subprocess_exec(
"sh", "-c",
f"yt-dlp -o - -f bestaudio --no-warnings {_shell_quote(url)}"
f" | ffmpeg -i pipe:0 -f s16le -ar 48000 -ac 1"
f" -loglevel error pipe:1",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_max_step = 0.1 # max volume change per frame (~200ms full ramp)
_cur_vol = _get_vol()
frames = 0
try:
while True:
pcm = await proc.stdout.read(_FRAME_BYTES)
if not pcm:
break
if len(pcm) < _FRAME_BYTES:
pcm += b"\x00" * (_FRAME_BYTES - len(pcm))
target = _get_vol()
if _cur_vol == target:
# Fast path: flat scaling
if target != 1.0:
pcm = _scale_pcm(pcm, target)
else:
# Ramp toward target, clamped to _max_step per frame
diff = target - _cur_vol
if abs(diff) <= _max_step:
next_vol = target
elif diff > 0:
next_vol = _cur_vol + _max_step
else:
next_vol = _cur_vol - _max_step
pcm = _scale_pcm_ramp(pcm, _cur_vol, next_vol)
_cur_vol = next_vol
self._mumble.sound_output.add_sound(pcm)
frames += 1
if frames == 1:
log.info("stream_audio: first frame fed to pymumble")
# Keep buffer at most 1 second ahead
while self._mumble.sound_output.get_buffer_size() > 1.0:
await asyncio.sleep(0.05)
# Wait for buffer to drain
while self._mumble.sound_output.get_buffer_size() > 0:
await asyncio.sleep(0.1)
log.info("stream_audio: finished, %d frames", frames)
except asyncio.CancelledError:
self._mumble.sound_output.clear_buffer()
log.info("stream_audio: cancelled at frame %d", frames)
except Exception:
log.exception("stream_audio: error at frame %d", frames)
finally:
try:
proc.kill()
except ProcessLookupError:
pass
stderr_out = await proc.stderr.read()
await proc.wait()
if stderr_out:
log.warning("stream_audio: subprocess stderr: %s",
stderr_out.decode(errors="replace")[:500])
if on_done is not None:
on_done.set()
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."""
async def part(self, channel: str, reason: str = "") -> None:
"""No-op: IRC-only concept."""
async def quit(self, reason: str = "bye") -> None:
"""Stop the Mumble adapter."""
self._running = False
async def kick(self, channel: str, nick: str, reason: str = "") -> None:
"""No-op: IRC-only concept."""
async def mode(self, target: str, mode_str: str, *args: str) -> None:
"""No-op: IRC-only concept."""
async def set_topic(self, channel: str, topic: str) -> None:
"""No-op: IRC-only concept."""
# -- 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

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

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

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

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

View File

@@ -63,7 +63,7 @@ class _Harness:
},
}
self.registry = PluginRegistry()
self.bot = Bot(config, self.registry)
self.bot = Bot("test", config, self.registry)
self.conn = _MockConnection()
self.bot.conn = self.conn # type: ignore[assignment]
self.registry.load_plugin(Path("plugins/core.py"))

View File

@@ -21,11 +21,10 @@ from plugins.alert import ( # noqa: E402
_MAX_SEEN,
_compact_num,
_delete,
_errors,
_extract_videos,
_load,
_poll_once,
_pollers,
_ps,
_restore,
_save,
_save_result,
@@ -35,7 +34,6 @@ from plugins.alert import ( # noqa: E402
_start_poller,
_state_key,
_stop_poller,
_subscriptions,
_truncate,
_validate_name,
cmd_alert,
@@ -169,6 +167,7 @@ class _FakeBot:
self.actions: list[tuple[str, str]] = []
self.replied: list[str] = []
self.state = _FakeState()
self._pstate: dict = {}
self.registry = _FakeRegistry()
self._admin = admin
@@ -205,14 +204,18 @@ def _pm(text: str, nick: str = "alice") -> Message:
)
def _clear() -> None:
"""Reset module-level state between tests."""
for task in _pollers.values():
def _clear(bot=None) -> None:
"""Reset per-bot plugin state between tests."""
if bot is None:
return
ps = _ps(bot)
for task in ps["pollers"].values():
if task and not task.done():
task.cancel()
_pollers.clear()
_subscriptions.clear()
_errors.clear()
ps["pollers"].clear()
ps["subs"].clear()
ps["errors"].clear()
ps["poll_count"].clear()
def _fake_yt(keyword):
@@ -595,8 +598,8 @@ class TestCmdAlertAdd:
assert len(data["seen"]["yt"]) == 2
assert len(data["seen"]["tw"]) == 2
assert len(data["seen"]["sx"]) == 2
assert "#test:mc-speed" in _pollers
_stop_poller("#test:mc-speed")
assert "#test:mc-speed" in _ps(bot)["pollers"]
_stop_poller(bot, "#test:mc-speed")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -644,7 +647,7 @@ class TestCmdAlertAdd:
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
await cmd_alert(bot, _msg("!alert add dupe other keyword"))
assert "already exists" in bot.replied[0]
_stop_poller("#test:dupe")
_stop_poller(bot, "#test:dupe")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -681,7 +684,7 @@ class TestCmdAlertAdd:
assert len(data["seen"]["yt"]) == 2
assert len(data["seen"].get("tw", [])) == 0
assert len(data["seen"]["sx"]) == 2
_stop_poller("#test:partial")
_stop_poller(bot, "#test:partial")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -704,7 +707,7 @@ class TestCmdAlertDel:
await cmd_alert(bot, _msg("!alert del todel"))
assert "Removed 'todel'" in bot.replied[0]
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)
asyncio.run(inner())
@@ -896,7 +899,7 @@ class TestPollOnce:
}
key = "#test:poll"
_save(bot, key, data)
_subscriptions[key] = data
_ps(bot)["subs"][key] = data
async def inner():
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
@@ -933,7 +936,7 @@ class TestPollOnce:
}
key = "#test:dedup"
_save(bot, key, data)
_subscriptions[key] = data
_ps(bot)["subs"][key] = data
async def inner():
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
@@ -953,7 +956,7 @@ class TestPollOnce:
}
key = "#test:partial"
_save(bot, key, data)
_subscriptions[key] = data
_ps(bot)["subs"][key] = data
backends = {"yt": _fake_yt_error, "tw": _fake_tw, "sx": _fake_sx}
async def inner():
@@ -965,7 +968,7 @@ class TestPollOnce:
assert len(tw_msgs) == 2
assert len(sx_msgs) == 2
# Error counter should be incremented for yt backend
assert _errors[key]["yt"] == 1
assert _ps(bot)["errors"][key]["yt"] == 1
updated = _load(bot, key)
assert "yt" in updated.get("last_errors", {})
@@ -981,7 +984,7 @@ class TestPollOnce:
}
key = "#test:quiet"
_save(bot, key, data)
_subscriptions[key] = data
_ps(bot)["subs"][key] = data
async def inner():
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
@@ -1012,7 +1015,7 @@ class TestPollOnce:
}
key = "#test:cap"
_save(bot, key, data)
_subscriptions[key] = data
_ps(bot)["subs"][key] = data
async def inner():
with patch.object(_mod, "_BACKENDS", {"yt": fake_many, "tw": _fake_tw}):
@@ -1034,13 +1037,13 @@ class TestPollOnce:
}
key = "#test:allerr"
_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}
async def inner():
with patch.object(_mod, "_BACKENDS", backends):
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
asyncio.run(inner())
@@ -1058,13 +1061,13 @@ class TestPollOnce:
}
key = "#test:clrerr"
_save(bot, key, data)
_subscriptions[key] = data
_errors[key] = {"yt": 3, "tw": 3, "sx": 3}
_ps(bot)["subs"][key] = data
_ps(bot)["errors"][key] = {"yt": 3, "tw": 3, "sx": 3}
async def inner():
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
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)
assert updated.get("last_errors", {}) == {}
@@ -1088,10 +1091,11 @@ class TestRestore:
async def inner():
_restore(bot)
assert "#test:restored" in _pollers
task = _pollers["#test:restored"]
ps = _ps(bot)
assert "#test:restored" in ps["pollers"]
task = ps["pollers"]["#test:restored"]
assert not task.done()
_stop_poller("#test:restored")
_stop_poller(bot, "#test:restored")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -1107,10 +1111,11 @@ class TestRestore:
_save(bot, "#test:active", data)
async def inner():
ps = _ps(bot)
dummy = asyncio.create_task(asyncio.sleep(9999))
_pollers["#test:active"] = dummy
ps["pollers"]["#test:active"] = dummy
_restore(bot)
assert _pollers["#test:active"] is dummy
assert ps["pollers"]["#test:active"] is dummy
dummy.cancel()
await asyncio.sleep(0)
@@ -1127,14 +1132,15 @@ class TestRestore:
_save(bot, "#test:done", data)
async def inner():
ps = _ps(bot)
done_task = asyncio.create_task(asyncio.sleep(0))
await done_task
_pollers["#test:done"] = done_task
ps["pollers"]["#test:done"] = done_task
_restore(bot)
new_task = _pollers["#test:done"]
new_task = ps["pollers"]["#test:done"]
assert new_task is not done_task
assert not new_task.done()
_stop_poller("#test:done")
_stop_poller(bot, "#test:done")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -1146,7 +1152,7 @@ class TestRestore:
async def inner():
_restore(bot)
assert "#test:bad" not in _pollers
assert "#test:bad" not in _ps(bot)["pollers"]
asyncio.run(inner())
@@ -1163,8 +1169,8 @@ class TestRestore:
async def inner():
msg = _msg("", target="botname")
await on_connect(bot, msg)
assert "#test:conn" in _pollers
_stop_poller("#test:conn")
assert "#test:conn" in _ps(bot)["pollers"]
_stop_poller(bot, "#test:conn")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -1185,16 +1191,17 @@ class TestPollerManagement:
}
key = "#test:mgmt"
_save(bot, key, data)
_subscriptions[key] = data
_ps(bot)["subs"][key] = data
async def inner():
ps = _ps(bot)
_start_poller(bot, key)
assert key in _pollers
assert not _pollers[key].done()
_stop_poller(key)
assert key in ps["pollers"]
assert not ps["pollers"][key].done()
_stop_poller(bot, key)
await asyncio.sleep(0)
assert key not in _pollers
assert key not in _subscriptions
assert key not in ps["pollers"]
assert key not in ps["subs"]
asyncio.run(inner())
@@ -1208,21 +1215,22 @@ class TestPollerManagement:
}
key = "#test:idem"
_save(bot, key, data)
_subscriptions[key] = data
_ps(bot)["subs"][key] = data
async def inner():
ps = _ps(bot)
_start_poller(bot, key)
first = _pollers[key]
first = ps["pollers"][key]
_start_poller(bot, key)
assert _pollers[key] is first
_stop_poller(key)
assert ps["pollers"][key] is first
_stop_poller(bot, key)
await asyncio.sleep(0)
asyncio.run(inner())
def test_stop_nonexistent(self):
_clear()
_stop_poller("#test:nonexistent")
bot = _FakeBot()
_stop_poller(bot, "#test:nonexistent")
# ---------------------------------------------------------------------------
@@ -1299,7 +1307,7 @@ class TestExtraInHistory:
}
_save(bot, "#test:hist", data)
# 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",
"date": "2026-01-15", "extra": "42pt 10c",
})
@@ -1321,7 +1329,7 @@ class TestExtraInHistory:
"interval": 300, "seen": {}, "last_poll": "", "last_error": "",
}
_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",
"date": "", "extra": "",
})
@@ -1348,7 +1356,7 @@ class TestExtraInInfo:
"interval": 300, "seen": {}, "last_poll": "", "last_error": "",
}
_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",
"url": "https://github.com/cool/repo",
"date": "2026-01-10", "extra": "42* 5fk",
@@ -1370,7 +1378,7 @@ class TestExtraInInfo:
"interval": 300, "seen": {}, "last_poll": "", "last_error": "",
}
_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",
"url": "https://youtube.com/watch?v=y2",
"date": "", "extra": "",

View File

@@ -5,7 +5,14 @@ from pathlib import Path
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:
@@ -110,3 +117,182 @@ class TestResolveConfig:
original = copy.deepcopy(DEFAULTS)
resolve_config(None)
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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,12 @@
"""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:
@@ -142,3 +148,19 @@ class TestFormat:
# No space in tail, not starting with colon, head exists -> no colon
result = format_msg("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

647
tests/test_mumble.py Normal file
View File

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

459
tests/test_music.py Normal file
View File

@@ -0,0 +1,459 @@
"""Tests for the music playback plugin."""
import asyncio
import importlib.util
import sys
from unittest.mock import AsyncMock, MagicMock, patch
# -- Load plugin module directly ---------------------------------------------
_spec = importlib.util.spec_from_file_location("music", "plugins/music.py")
_mod = importlib.util.module_from_spec(_spec)
sys.modules["music"] = _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 music plugin testing."""
def __init__(self, *, mumble: bool = True):
self.sent: list[tuple[str, str]] = []
self.replied: list[str] = []
self.state = _FakeState()
self._pstate: dict = {}
self._tasks: set[asyncio.Task] = set()
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)
def _is_admin(self, message) -> bool:
return False
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="!play url", 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 = {}
# ---------------------------------------------------------------------------
# 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_play_non_mumble(self):
bot = _FakeBot(mumble=False)
msg = _Msg(text="!play https://example.com")
asyncio.run(_mod.cmd_play(bot, msg))
assert any("Mumble-only" in r for r in bot.replied)
def test_stop_non_mumble_silent(self):
bot = _FakeBot(mumble=False)
msg = _Msg(text="!stop")
asyncio.run(_mod.cmd_stop(bot, msg))
assert bot.replied == []
def test_skip_non_mumble_silent(self):
bot = _FakeBot(mumble=False)
msg = _Msg(text="!skip")
asyncio.run(_mod.cmd_skip(bot, msg))
assert bot.replied == []
# ---------------------------------------------------------------------------
# TestPlayCommand
# ---------------------------------------------------------------------------
class TestPlayCommand:
def test_play_no_url(self):
bot = _FakeBot()
msg = _Msg(text="!play")
asyncio.run(_mod.cmd_play(bot, msg))
assert any("Usage" in r for r in bot.replied)
def test_play_queues_track(self):
bot = _FakeBot()
msg = _Msg(text="!play https://example.com/track")
tracks = [("https://example.com/track", "Test Track")]
with patch.object(_mod, "_resolve_tracks", return_value=tracks):
with patch.object(_mod, "_ensure_loop"):
asyncio.run(_mod.cmd_play(bot, msg))
assert any("Playing" in r for r in bot.replied)
ps = _mod._ps(bot)
assert len(ps["queue"]) == 1
assert ps["queue"][0].title == "Test Track"
def test_play_shows_queued_when_busy(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["current"] = _mod._Track(
url="x", title="Current", requester="Bob",
)
msg = _Msg(text="!play https://example.com/next")
tracks = [("https://example.com/next", "Next Track")]
with patch.object(_mod, "_resolve_tracks", return_value=tracks):
asyncio.run(_mod.cmd_play(bot, msg))
assert any("Queued" in r for r in bot.replied)
def test_play_queue_full(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["queue"] = [
_mod._Track(url="x", title="t", requester="a")
for _ in range(_mod._MAX_QUEUE)
]
msg = _Msg(text="!play https://example.com/overflow")
asyncio.run(_mod.cmd_play(bot, msg))
assert any("full" in r.lower() for r in bot.replied)
# ---------------------------------------------------------------------------
# TestStopCommand
# ---------------------------------------------------------------------------
class TestStopCommand:
def test_stop_clears_queue(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["queue"] = [_mod._Track(url="x", title="t", requester="a")]
ps["current"] = _mod._Track(url="y", title="s", requester="b")
msg = _Msg(text="!stop")
asyncio.run(_mod.cmd_stop(bot, msg))
assert ps["queue"] == []
assert ps["current"] is None
assert any("Stopped" in r for r in bot.replied)
# ---------------------------------------------------------------------------
# TestSkipCommand
# ---------------------------------------------------------------------------
class TestSkipCommand:
def test_skip_nothing_playing(self):
bot = _FakeBot()
msg = _Msg(text="!skip")
asyncio.run(_mod.cmd_skip(bot, msg))
assert any("Nothing" in r for r in bot.replied)
def test_skip_with_queue(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["current"] = _mod._Track(url="a", title="First", requester="x")
ps["queue"] = [_mod._Track(url="b", title="Second", requester="y")]
# We need to mock the task
mock_task = MagicMock()
mock_task.done.return_value = False
ps["task"] = mock_task
msg = _Msg(text="!skip")
with patch.object(_mod, "_ensure_loop"):
asyncio.run(_mod.cmd_skip(bot, msg))
assert any("Skipped" in r for r in bot.replied)
mock_task.cancel.assert_called_once()
def test_skip_empty_queue(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["current"] = _mod._Track(url="a", title="Only", requester="x")
mock_task = MagicMock()
mock_task.done.return_value = False
ps["task"] = mock_task
msg = _Msg(text="!skip")
asyncio.run(_mod.cmd_skip(bot, msg))
assert any("empty" in r.lower() for r in bot.replied)
# ---------------------------------------------------------------------------
# TestQueueCommand
# ---------------------------------------------------------------------------
class TestQueueCommand:
def test_queue_empty(self):
bot = _FakeBot()
msg = _Msg(text="!queue")
asyncio.run(_mod.cmd_queue(bot, msg))
assert any("empty" in r.lower() for r in bot.replied)
def test_queue_with_tracks(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["current"] = _mod._Track(url="a", title="Now", requester="x")
ps["queue"] = [
_mod._Track(url="b", title="Next", requester="y"),
]
msg = _Msg(text="!queue")
asyncio.run(_mod.cmd_queue(bot, msg))
assert any("Now" in r for r in bot.replied)
assert any("Next" in r for r in bot.replied)
def test_queue_with_url_delegates(self):
bot = _FakeBot()
msg = _Msg(text="!queue https://example.com/track")
tracks = [("https://example.com/track", "Title")]
with patch.object(_mod, "_resolve_tracks", return_value=tracks):
with patch.object(_mod, "_ensure_loop"):
asyncio.run(_mod.cmd_queue(bot, msg))
# Should have called cmd_play logic
assert any("Playing" in r or "Queued" in r for r in bot.replied)
# ---------------------------------------------------------------------------
# TestNpCommand
# ---------------------------------------------------------------------------
class TestNpCommand:
def test_np_nothing(self):
bot = _FakeBot()
msg = _Msg(text="!np")
asyncio.run(_mod.cmd_np(bot, msg))
assert any("Nothing" in r for r in bot.replied)
def test_np_playing(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["current"] = _mod._Track(
url="x", title="Cool Song", requester="DJ",
)
msg = _Msg(text="!np")
asyncio.run(_mod.cmd_np(bot, msg))
assert any("Cool Song" in r for r in bot.replied)
assert any("DJ" in r for r in bot.replied)
# ---------------------------------------------------------------------------
# TestVolumeCommand
# ---------------------------------------------------------------------------
class TestVolumeCommand:
def test_volume_show(self):
bot = _FakeBot()
msg = _Msg(text="!volume")
asyncio.run(_mod.cmd_volume(bot, msg))
assert any("50%" in r for r in bot.replied)
def test_volume_set(self):
bot = _FakeBot()
msg = _Msg(text="!volume 75")
asyncio.run(_mod.cmd_volume(bot, msg))
ps = _mod._ps(bot)
assert ps["volume"] == 75
assert any("75%" in r for r in bot.replied)
def test_volume_out_of_range(self):
bot = _FakeBot()
msg = _Msg(text="!volume 150")
asyncio.run(_mod.cmd_volume(bot, msg))
assert any("0-100" in r for r in bot.replied)
def test_volume_negative(self):
bot = _FakeBot()
msg = _Msg(text="!volume -10")
asyncio.run(_mod.cmd_volume(bot, msg))
assert any("0-100" in r for r in bot.replied)
def test_volume_invalid(self):
bot = _FakeBot()
msg = _Msg(text="!volume abc")
asyncio.run(_mod.cmd_volume(bot, msg))
assert any("Usage" in r for r in bot.replied)
# ---------------------------------------------------------------------------
# TestPerBotState
# ---------------------------------------------------------------------------
class TestPerBotState:
def test_ps_initializes(self):
bot = _FakeBot()
ps = _mod._ps(bot)
assert ps["queue"] == []
assert ps["current"] is None
assert ps["volume"] == 50
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)["volume"] = 80
assert _mod._ps(bot2)["volume"] == 50
# ---------------------------------------------------------------------------
# TestHelpers
# ---------------------------------------------------------------------------
class TestMusicHelpers:
def test_truncate_short(self):
assert _mod._truncate("short") == "short"
def test_truncate_long(self):
long = "x" * 100
result = _mod._truncate(long)
assert len(result) == 80
assert result.endswith("...")
# ---------------------------------------------------------------------------
# TestPlaylistExpansion
# ---------------------------------------------------------------------------
class TestPlaylistExpansion:
def test_enqueue_multiple_tracks(self):
bot = _FakeBot()
msg = _Msg(text="!play https://example.com/playlist")
tracks = [
("https://example.com/1", "Track 1"),
("https://example.com/2", "Track 2"),
("https://example.com/3", "Track 3"),
]
with patch.object(_mod, "_resolve_tracks", return_value=tracks):
with patch.object(_mod, "_ensure_loop"):
asyncio.run(_mod.cmd_play(bot, msg))
ps = _mod._ps(bot)
assert len(ps["queue"]) == 3
assert any("Queued 3 tracks" in r for r in bot.replied)
def test_truncate_at_queue_limit(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["current"] = _mod._Track(url="x", title="Playing", requester="a")
# Fill queue to 2 slots remaining
ps["queue"] = [
_mod._Track(url="x", title="t", requester="a")
for _ in range(_mod._MAX_QUEUE - 2)
]
msg = _Msg(text="!play https://example.com/playlist")
tracks = [
("https://example.com/1", "Track 1"),
("https://example.com/2", "Track 2"),
("https://example.com/3", "Track 3"),
("https://example.com/4", "Track 4"),
]
with patch.object(_mod, "_resolve_tracks", return_value=tracks):
asyncio.run(_mod.cmd_play(bot, msg))
assert len(ps["queue"]) == _mod._MAX_QUEUE
assert any("2 of 4" in r for r in bot.replied)
def test_start_loop_when_idle(self):
bot = _FakeBot()
msg = _Msg(text="!play https://example.com/playlist")
tracks = [
("https://example.com/1", "Track 1"),
("https://example.com/2", "Track 2"),
]
with patch.object(_mod, "_resolve_tracks", return_value=tracks):
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod.cmd_play(bot, msg))
mock_loop.assert_called_once()
def test_no_loop_start_when_busy(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["current"] = _mod._Track(url="x", title="Current", requester="a")
msg = _Msg(text="!play https://example.com/playlist")
tracks = [
("https://example.com/1", "Track 1"),
("https://example.com/2", "Track 2"),
]
with patch.object(_mod, "_resolve_tracks", return_value=tracks):
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod.cmd_play(bot, msg))
mock_loop.assert_not_called()
def test_resolve_tracks_single_video(self):
"""Subprocess returning a single url+title pair."""
result = MagicMock()
result.stdout = "https://example.com/v1\nSingle Video\n"
with patch("subprocess.run", return_value=result):
tracks = _mod._resolve_tracks("https://example.com/v1")
assert tracks == [("https://example.com/v1", "Single Video")]
def test_resolve_tracks_playlist(self):
"""Subprocess returning multiple url+title pairs."""
result = MagicMock()
result.stdout = (
"https://example.com/1\nFirst\n"
"https://example.com/2\nSecond\n"
)
with patch("subprocess.run", return_value=result):
tracks = _mod._resolve_tracks("https://example.com/pl")
assert len(tracks) == 2
assert tracks[0] == ("https://example.com/1", "First")
assert tracks[1] == ("https://example.com/2", "Second")
def test_resolve_tracks_error_fallback(self):
"""On error, returns [(url, url)]."""
with patch("subprocess.run", side_effect=Exception("fail")):
tracks = _mod._resolve_tracks("https://example.com/bad")
assert tracks == [("https://example.com/bad", "https://example.com/bad")]
def test_resolve_tracks_empty_output(self):
"""Empty stdout returns fallback."""
result = MagicMock()
result.stdout = ""
with patch("subprocess.run", return_value=result):
tracks = _mod._resolve_tracks("https://example.com/empty")
assert tracks == [("https://example.com/empty", "https://example.com/empty")]

View File

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

View File

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

View File

@@ -384,7 +384,7 @@ class TestPrefixMatch:
async def _noop(bot, msg):
pass
registry.register_command(name, _noop, plugin="test")
return Bot(config, registry)
return Bot("test", config, registry)
def test_exact_match(self):
bot = self._make_bot(["ping", "pong", "plugins"])
@@ -438,7 +438,7 @@ class TestIsAdmin:
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins",
"admins": admins or []},
}
bot = Bot(config, PluginRegistry())
bot = Bot("test", config, PluginRegistry())
if opers:
bot._opers = opers
return bot
@@ -565,7 +565,7 @@ def _make_test_bot() -> Bot:
"nick": "test", "user": "test", "realname": "test"},
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
}
bot = Bot(config, PluginRegistry())
bot = Bot("test", config, PluginRegistry())
bot.conn = _FakeConnection() # type: ignore[assignment]
return bot
@@ -637,7 +637,7 @@ class TestChannelFilter:
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
"channels": channels_cfg or {},
}
return Bot(config, PluginRegistry())
return Bot("test", config, PluginRegistry())
def test_core_always_allowed(self):
bot = self._make_bot({"#locked": {"plugins": ["core"]}})

View File

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

View File

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

759
tests/test_teams.py Normal file
View File

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

786
tests/test_telegram.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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