Compare commits
20 Commits
e9528bd879
...
6b7d733650
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b7d733650 | ||
|
|
c5c61e63cc | ||
|
|
67b2dc827d | ||
|
|
eae36aa1f9 | ||
|
|
d884d2bb55 | ||
|
|
d756e7c020 | ||
|
|
7206b27fb0 | ||
|
|
47b13c3f1f | ||
|
|
b074356ec6 | ||
|
|
9d4cb09069 | ||
|
|
ca46042c41 | ||
|
|
37c858f4d7 | ||
|
|
0d92e6ed31 | ||
|
|
3bcba8b0a9 | ||
|
|
4a304f2498 | ||
|
|
4a165e8b28 | ||
|
|
014b609686 | ||
|
|
c8879f6089 | ||
|
|
144193e3bb | ||
|
|
073659607e |
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
35
ROADMAP.md
35
ROADMAP.md
@@ -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
|
||||
|
||||
87
TASKS.md
87
TASKS.md
@@ -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
82
TODO.md
@@ -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
515
docs/API.md
Normal file
@@ -0,0 +1,515 @@
|
||||
# derp - Plugin API Reference
|
||||
|
||||
Stable public surface for plugin authors. Covers decorators, bot
|
||||
methods, IRC primitives, state persistence, and network helpers.
|
||||
|
||||
## Stability Guarantee
|
||||
|
||||
Symbols documented below follow [semver](https://semver.org/):
|
||||
|
||||
| Change type | Allowed in |
|
||||
|-------------|------------|
|
||||
| Breaking (remove, rename, change signature) | Major only (3.0, 4.0, ...) |
|
||||
| Additions (new functions, parameters, fields) | Minor (2.1, 2.2, ...) |
|
||||
| Bug fixes (behavior corrections) | Patch (2.0.1, 2.0.2, ...) |
|
||||
|
||||
**Deprecation policy:** deprecated in a minor release, removed in the
|
||||
next major. Deprecated symbols emit a log warning.
|
||||
|
||||
**Extension points:** attributes prefixed with `_` are documented for
|
||||
reference but considered unstable -- they may change in minor releases.
|
||||
|
||||
---
|
||||
|
||||
## `derp.plugin` -- Decorators & Registry
|
||||
|
||||
### Decorators
|
||||
|
||||
```python
|
||||
@command(name: str, help: str = "", admin: bool = False, tier: str = "")
|
||||
```
|
||||
|
||||
Register an async function as a bot command. If `tier` is empty, it
|
||||
defaults to `"admin"` when `admin=True`, otherwise `"user"`.
|
||||
|
||||
```python
|
||||
@event(event_type: str)
|
||||
```
|
||||
|
||||
Register an async function as an IRC event handler. The `event_type`
|
||||
is uppercased automatically (e.g. `"join"` becomes `"JOIN"`).
|
||||
|
||||
### Constants
|
||||
|
||||
| Name | Type | Value |
|
||||
|------|------|-------|
|
||||
| `TIERS` | `tuple[str, ...]` | `("user", "trusted", "oper", "admin")` |
|
||||
|
||||
### `Handler` dataclass
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `name` | `str` | -- | Command or event name |
|
||||
| `callback` | `Callable` | -- | Async handler function |
|
||||
| `help` | `str` | `""` | Help text |
|
||||
| `plugin` | `str` | `""` | Source plugin name |
|
||||
| `admin` | `bool` | `False` | Legacy admin flag |
|
||||
| `tier` | `str` | `"user"` | Required permission tier |
|
||||
|
||||
### `PluginRegistry`
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `register_command` | `(name, callback, help="", plugin="", admin=False, tier="user")` | Register a command handler |
|
||||
| `register_event` | `(event_type, callback, plugin="")` | Register an event handler |
|
||||
| `load_plugin` | `(path: Path) -> int` | Load plugin from `.py` file; returns handler count or -1 |
|
||||
| `load_directory` | `(dir_path: Path) -> None` | Load all `.py` plugins from a directory |
|
||||
| `unload_plugin` | `(name: str) -> bool` | Unload plugin (refuses `core`); returns success |
|
||||
| `reload_plugin` | `(name: str) -> tuple[bool, str]` | Reload from original path; returns `(ok, reason)` |
|
||||
|
||||
**Extension points (unstable):**
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `_modules` | `dict[str, Any]` | Loaded plugin modules by name |
|
||||
| `_paths` | `dict[str, Path]` | File paths of loaded plugins |
|
||||
|
||||
---
|
||||
|
||||
## `derp.bot` -- Bot Instance
|
||||
|
||||
### Stable Attributes
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `name` | `str` | Server/bot instance name |
|
||||
| `config` | `dict` | Merged TOML configuration |
|
||||
| `nick` | `str` | Current IRC nick |
|
||||
| `prefix` | `str` | Command prefix (e.g. `"!"`) |
|
||||
| `state` | `StateStore` | Persistent key-value storage |
|
||||
| `registry` | `PluginRegistry` | Command and event registry |
|
||||
|
||||
### Sending Messages
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `send` | `(target: str, text: str) -> None` | Send PRIVMSG (rate-limited, auto-split) |
|
||||
| `reply` | `(msg: Message, text: str) -> None` | Reply to channel or PM source |
|
||||
| `long_reply` | `(msg: Message, lines: list[str], *, label: str = "") -> None` | Reply with paste overflow for long output |
|
||||
| `action` | `(target: str, text: str) -> None` | Send CTCP ACTION (/me) |
|
||||
|
||||
### IRC Control
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `join` | `(channel: str) -> None` | Join a channel |
|
||||
| `part` | `(channel: str, reason: str = "") -> None` | Part a channel |
|
||||
| `quit` | `(reason: str = "bye") -> None` | Quit server and stop bot |
|
||||
| `kick` | `(channel: str, nick: str, reason: str = "") -> None` | Kick user from channel |
|
||||
| `mode` | `(target: str, mode_str: str, *args: str) -> None` | Set channel/user mode |
|
||||
| `set_topic` | `(channel: str, topic: str) -> None` | Set channel topic |
|
||||
|
||||
### Utility
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `shorten_url` | `(url: str) -> str` | Shorten URL via FlaskPaste; returns original on failure |
|
||||
| `load_plugins` | `(plugins_dir: str \| Path \| None = None) -> None` | Load all plugins from directory |
|
||||
| `load_plugin` | `(name: str) -> tuple[bool, str]` | Hot-load a plugin by name |
|
||||
| `reload_plugin` | `(name: str) -> tuple[bool, str]` | Reload a plugin |
|
||||
| `unload_plugin` | `(name: str) -> tuple[bool, str]` | Unload a plugin |
|
||||
|
||||
### Extension Points (unstable)
|
||||
|
||||
| Attribute / Method | Description |
|
||||
|--------------------|-------------|
|
||||
| `_pstate` | Per-bot plugin runtime state dict |
|
||||
| `_get_tier(msg)` | Determine sender's permission tier |
|
||||
| `_is_admin(msg)` | Check if sender is admin |
|
||||
| `_dispatch_command(msg)` | Parse and dispatch a command from PRIVMSG |
|
||||
| `_spawn(coro, *, name=)` | Spawn a tracked background task |
|
||||
| `registry._modules` | Direct access to loaded plugin modules |
|
||||
|
||||
---
|
||||
|
||||
## `derp.irc` -- IRC Protocol
|
||||
|
||||
### `Message` dataclass
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `raw` | `str` | Original IRC line |
|
||||
| `prefix` | `str \| None` | Sender prefix (`nick!user@host`) |
|
||||
| `nick` | `str \| None` | Sender nick (extracted from prefix) |
|
||||
| `command` | `str` | IRC command (uppercased) |
|
||||
| `params` | `list[str]` | Command parameters |
|
||||
| `tags` | `dict[str, str]` | IRCv3 message tags |
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `target` | `str \| None` | First param (channel or nick) |
|
||||
| `text` | `str \| None` | Trailing text (last param) |
|
||||
| `is_channel` | `bool` | Whether target starts with `#` or `&` |
|
||||
|
||||
### Functions
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `parse` | `(line: str) -> Message` | Parse a raw IRC line |
|
||||
| `format_msg` | `(command: str, *params: str) -> str` | Format an IRC command |
|
||||
|
||||
### `IRCConnection`
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `connect` | `() -> None` | Open TCP/TLS connection |
|
||||
| `send` | `(line: str) -> None` | Send raw IRC line (appends CRLF) |
|
||||
| `readline` | `() -> str \| None` | Read one line; `None` on EOF |
|
||||
| `close` | `() -> None` | Close connection |
|
||||
| `connected` | *(property)* `-> bool` | Whether connection is open |
|
||||
|
||||
---
|
||||
|
||||
## `derp.state` -- Persistent Storage
|
||||
|
||||
### `StateStore`
|
||||
|
||||
SQLite-backed key-value store. Each plugin gets its own namespace.
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `get` | `(plugin: str, key: str, default: str \| None = None) -> str \| None` | Get a value |
|
||||
| `set` | `(plugin: str, key: str, value: str) -> None` | Set a value (upsert) |
|
||||
| `delete` | `(plugin: str, key: str) -> bool` | Delete a key; returns `True` if removed |
|
||||
| `keys` | `(plugin: str) -> list[str]` | List all keys for a plugin |
|
||||
| `clear` | `(plugin: str) -> int` | Delete all state for a plugin; returns count |
|
||||
| `close` | `() -> None` | Close the database connection |
|
||||
|
||||
---
|
||||
|
||||
## `derp.http` -- HTTP & Network
|
||||
|
||||
HTTP/TCP helpers with optional SOCKS5 proxy routing. All functions accept
|
||||
a `proxy` parameter (default `True`) to toggle SOCKS5.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `urlopen` | `(req, *, timeout=None, context=None, retries=None, proxy=True)` | HTTP request with optional SOCKS5, connection pooling, retries |
|
||||
| `build_opener` | `(*handlers, context=None, proxy=True)` | Build URL opener, optionally with SOCKS5 handler |
|
||||
| `create_connection` | `(address, *, timeout=None, proxy=True)` | TCP `socket.create_connection` with optional SOCKS5, retries |
|
||||
| `open_connection` | `(host, port, *, timeout=None, proxy=True)` | Async `asyncio.open_connection` with optional SOCKS5, retries |
|
||||
|
||||
---
|
||||
|
||||
## `derp.dns` -- DNS Helpers
|
||||
|
||||
Wire-format encode/decode for raw DNS queries and responses.
|
||||
|
||||
### Constants
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `QTYPES` | `dict[str, int]` | Query type name to number (`A`, `NS`, `CNAME`, `SOA`, `PTR`, `MX`, `TXT`, `AAAA`) |
|
||||
| `QTYPE_NAMES` | `dict[int, str]` | Reverse mapping (number to name) |
|
||||
| `RCODES` | `dict[int, str]` | Response code to name |
|
||||
| `TOR_DNS_ADDR` | `str` | Tor DNS resolver address |
|
||||
| `TOR_DNS_PORT` | `int` | Tor DNS resolver port |
|
||||
|
||||
### Functions
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `get_resolver` | `() -> str` | First IPv4 nameserver from `/etc/resolv.conf` |
|
||||
| `encode_name` | `(name: str) -> bytes` | Encode domain to DNS wire format |
|
||||
| `decode_name` | `(data: bytes, offset: int) -> tuple[str, int]` | Decode DNS name with pointer compression |
|
||||
| `build_query` | `(name: str, qtype: int) -> bytes` | Build a DNS query packet |
|
||||
| `parse_rdata` | `(rtype: int, data: bytes, offset: int, rdlength: int) -> str` | Parse an RR's rdata to string |
|
||||
| `parse_response` | `(data: bytes) -> tuple[int, list[str]]` | Parse DNS response; returns `(rcode, values)` |
|
||||
| `reverse_name` | `(addr: str) -> str` | Convert IP to reverse DNS name |
|
||||
|
||||
---
|
||||
|
||||
## `derp.teams` -- Teams Adapter
|
||||
|
||||
Alternative bot adapter for Microsoft Teams via outgoing/incoming webhooks.
|
||||
Exposes the same plugin API as `derp.bot.Bot` so protocol-agnostic plugins
|
||||
work without modification.
|
||||
|
||||
### `TeamsMessage` dataclass
|
||||
|
||||
Duck-typed compatible with IRC `Message`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `raw` | `dict` | Original Activity JSON |
|
||||
| `nick` | `str \| None` | Sender display name |
|
||||
| `prefix` | `str \| None` | Sender AAD object ID (for ACL) |
|
||||
| `text` | `str \| None` | Message body (stripped of @mention) |
|
||||
| `target` | `str \| None` | Conversation/channel ID |
|
||||
| `is_channel` | `bool` | Always `True` (outgoing webhooks) |
|
||||
| `command` | `str` | Always `"PRIVMSG"` (compat shim) |
|
||||
| `params` | `list[str]` | `[target, text]` |
|
||||
| `tags` | `dict[str, str]` | Empty dict (no IRCv3 tags) |
|
||||
| `_replies` | `list[str]` | Reply buffer (unstable) |
|
||||
|
||||
### `TeamsBot`
|
||||
|
||||
Same stable attributes and methods as `Bot`:
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `name` | `str` | Always `"teams"` |
|
||||
| `config` | `dict` | Merged TOML configuration |
|
||||
| `nick` | `str` | Bot display name (`teams.bot_name`) |
|
||||
| `prefix` | `str` | Command prefix (from `[bot]`) |
|
||||
| `state` | `StateStore` | Persistent key-value storage |
|
||||
| `registry` | `PluginRegistry` | Shared command and event registry |
|
||||
|
||||
**Sending messages** -- same signatures, different transport:
|
||||
|
||||
| Method | Behaviour |
|
||||
|--------|-----------|
|
||||
| `send(target, text)` | POST to incoming webhook URL |
|
||||
| `reply(msg, text)` | Append to `msg._replies` (HTTP response) |
|
||||
| `long_reply(msg, lines, *, label="")` | Paste overflow, appends to replies |
|
||||
| `action(target, text)` | Italic text via incoming webhook |
|
||||
| `shorten_url(url)` | Same FlaskPaste integration |
|
||||
|
||||
**IRC no-ops** (debug log, no error):
|
||||
|
||||
`join`, `part`, `kick`, `mode`, `set_topic`
|
||||
|
||||
**Plugin management** -- delegates to shared registry:
|
||||
|
||||
`load_plugins`, `load_plugin`, `reload_plugin`, `unload_plugin`
|
||||
|
||||
**Permission tiers** -- same model, exact AAD object ID matching:
|
||||
|
||||
`_get_tier(msg)`, `_is_admin(msg)`
|
||||
|
||||
---
|
||||
|
||||
## `derp.telegram` -- Telegram Adapter
|
||||
|
||||
Alternative bot adapter for Telegram via long-polling (`getUpdates`).
|
||||
All HTTP routed through SOCKS5 proxy. Exposes the same plugin API as
|
||||
`derp.bot.Bot` so protocol-agnostic plugins work without modification.
|
||||
|
||||
### `TelegramMessage` dataclass
|
||||
|
||||
Duck-typed compatible with IRC `Message`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `raw` | `dict` | Original Telegram Update |
|
||||
| `nick` | `str \| None` | Sender first_name (or username fallback) |
|
||||
| `prefix` | `str \| None` | Sender user_id as string (for ACL) |
|
||||
| `text` | `str \| None` | Message body (stripped of @bot suffix) |
|
||||
| `target` | `str \| None` | chat_id as string |
|
||||
| `is_channel` | `bool` | `True` for groups, `False` for DMs |
|
||||
| `command` | `str` | Always `"PRIVMSG"` (compat shim) |
|
||||
| `params` | `list[str]` | `[target, text]` |
|
||||
| `tags` | `dict[str, str]` | Empty dict (no IRCv3 tags) |
|
||||
|
||||
### `TelegramBot`
|
||||
|
||||
Same stable attributes and methods as `Bot`:
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `name` | `str` | Always `"telegram"` |
|
||||
| `config` | `dict` | Merged TOML configuration |
|
||||
| `nick` | `str` | Bot display name (from `getMe`) |
|
||||
| `prefix` | `str` | Command prefix (from `[telegram]` or `[bot]`) |
|
||||
| `state` | `StateStore` | Persistent key-value storage |
|
||||
| `registry` | `PluginRegistry` | Shared command and event registry |
|
||||
|
||||
**Sending messages** -- same signatures, Telegram API transport:
|
||||
|
||||
| Method | Behaviour |
|
||||
|--------|-----------|
|
||||
| `send(target, text)` | `sendMessage` API call (proxied, rate-limited) |
|
||||
| `reply(msg, text)` | `send(msg.target, text)` |
|
||||
| `long_reply(msg, lines, *, label="")` | Paste overflow, same logic as IRC |
|
||||
| `action(target, text)` | Italic Markdown text via `sendMessage` |
|
||||
| `shorten_url(url)` | Same FlaskPaste integration |
|
||||
|
||||
**Message splitting**: messages > 4096 chars split at line boundaries.
|
||||
|
||||
**IRC no-ops** (debug log, no error):
|
||||
|
||||
`join`, `part`, `kick`, `mode`, `set_topic`
|
||||
|
||||
**Plugin management** -- delegates to shared registry:
|
||||
|
||||
`load_plugins`, `load_plugin`, `reload_plugin`, `unload_plugin`
|
||||
|
||||
**Permission tiers** -- same model, exact user_id string matching:
|
||||
|
||||
`_get_tier(msg)`, `_is_admin(msg)`
|
||||
|
||||
### Helper Functions
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `_strip_bot_suffix` | `(text: str, bot_username: str) -> str` | Strip `@username` from command text |
|
||||
| `_build_telegram_message` | `(update: dict, bot_username: str) -> TelegramMessage \| None` | Parse Telegram Update into message |
|
||||
| `_split_message` | `(text: str, max_len: int = 4096) -> list[str]` | Split long text at line boundaries |
|
||||
|
||||
---
|
||||
|
||||
## `derp.mumble` -- Mumble Adapter
|
||||
|
||||
Alternative bot adapter for Mumble via TCP/TLS protobuf control channel
|
||||
(text chat only). All TCP routed through SOCKS5 proxy. Uses a minimal
|
||||
built-in protobuf encoder/decoder (no external dependency). Exposes the
|
||||
same plugin API as `derp.bot.Bot`.
|
||||
|
||||
### `MumbleMessage` dataclass
|
||||
|
||||
Duck-typed compatible with IRC `Message`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `raw` | `dict` | Decoded protobuf fields |
|
||||
| `nick` | `str \| None` | Sender username (from session lookup) |
|
||||
| `prefix` | `str \| None` | Sender username (for ACL) |
|
||||
| `text` | `str \| None` | Message body (HTML stripped) |
|
||||
| `target` | `str \| None` | channel_id as string (or `"dm"`) |
|
||||
| `is_channel` | `bool` | `True` for channel msgs, `False` for DMs |
|
||||
| `command` | `str` | Always `"PRIVMSG"` (compat shim) |
|
||||
| `params` | `list[str]` | `[target, text]` |
|
||||
| `tags` | `dict[str, str]` | Empty dict (no IRCv3 tags) |
|
||||
|
||||
### `MumbleBot`
|
||||
|
||||
Same stable attributes and methods as `Bot`:
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `name` | `str` | Always `"mumble"` |
|
||||
| `config` | `dict` | Merged TOML configuration |
|
||||
| `nick` | `str` | Bot username (from config) |
|
||||
| `prefix` | `str` | Command prefix (from `[mumble]` or `[bot]`) |
|
||||
| `state` | `StateStore` | Persistent key-value storage |
|
||||
| `registry` | `PluginRegistry` | Shared command and event registry |
|
||||
|
||||
**Sending messages** -- same signatures, Mumble protobuf transport:
|
||||
|
||||
| Method | Behaviour |
|
||||
|--------|-----------|
|
||||
| `send(target, text)` | TextMessage to channel (HTML-escaped) |
|
||||
| `reply(msg, text)` | `send(msg.target, text)` |
|
||||
| `long_reply(msg, lines, *, label="")` | Paste overflow, same logic as IRC |
|
||||
| `action(target, text)` | Italic HTML text (`<i>...</i>`) |
|
||||
| `shorten_url(url)` | Same FlaskPaste integration |
|
||||
|
||||
**IRC no-ops** (debug log, no error):
|
||||
|
||||
`join`, `part`, `kick`, `mode`, `set_topic`
|
||||
|
||||
**Plugin management** -- delegates to shared registry:
|
||||
|
||||
`load_plugins`, `load_plugin`, `reload_plugin`, `unload_plugin`
|
||||
|
||||
**Permission tiers** -- same model, exact username string matching:
|
||||
|
||||
`_get_tier(msg)`, `_is_admin(msg)`
|
||||
|
||||
### Protobuf Codec (internal)
|
||||
|
||||
Minimal protobuf wire format helpers -- not part of the stable API:
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `_encode_varint(value)` | Encode unsigned int as protobuf varint |
|
||||
| `_decode_varint(data, offset)` | Decode varint, returns `(value, offset)` |
|
||||
| `_encode_field(num, wire_type, value)` | Encode a single protobuf field |
|
||||
| `_decode_fields(data)` | Decode payload into `{field_num: [values]}` |
|
||||
| `_pack_msg(msg_type, payload)` | Wrap payload in 6-byte Mumble header |
|
||||
| `_unpack_header(data)` | Unpack header into `(msg_type, length)` |
|
||||
|
||||
### Helper Functions
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `_strip_html` | `(text: str) -> str` | Strip HTML tags and unescape entities |
|
||||
| `_escape_html` | `(text: str) -> str` | Escape text for Mumble HTML messages |
|
||||
| `_build_mumble_message` | `(fields, users, our_session) -> MumbleMessage \| None` | Build message from decoded TextMessage fields |
|
||||
|
||||
---
|
||||
|
||||
## Handler Signatures
|
||||
|
||||
All command and event handlers are async functions receiving `bot` and
|
||||
`message`:
|
||||
|
||||
```python
|
||||
async def cmd_name(bot: Bot, message: Message) -> None: ...
|
||||
async def on_event(bot: Bot, message: Message) -> None: ...
|
||||
```
|
||||
|
||||
The `message.text` contains the full message text including the command
|
||||
prefix and name. To extract arguments:
|
||||
|
||||
```python
|
||||
args = message.text.split(None, 1)[1] if " " in message.text else ""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Boilerplate
|
||||
|
||||
### Minimal command plugin
|
||||
|
||||
```python
|
||||
from derp.plugin import command
|
||||
|
||||
@command("greet", help="Say hello")
|
||||
async def cmd_greet(bot, message):
|
||||
await bot.reply(message, f"Hello, {message.nick}!")
|
||||
```
|
||||
|
||||
### Stateful plugin with `_ps(bot)` pattern
|
||||
|
||||
Plugins that need per-bot runtime state use a `_ps(bot)` helper to
|
||||
namespace state in `bot._pstate`:
|
||||
|
||||
```python
|
||||
from derp.plugin import command, event
|
||||
|
||||
_NS = "myplugin"
|
||||
|
||||
def _ps(bot):
|
||||
"""Return per-bot plugin state, initialising on first call."""
|
||||
if _NS not in bot._pstate:
|
||||
bot._pstate[_NS] = {"counter": 0}
|
||||
return bot._pstate[_NS]
|
||||
|
||||
@command("count", help="Increment counter")
|
||||
async def cmd_count(bot, message):
|
||||
ps = _ps(bot)
|
||||
ps["counter"] += 1
|
||||
await bot.reply(message, f"Count: {ps['counter']}")
|
||||
|
||||
@event("JOIN")
|
||||
async def on_join(bot, message):
|
||||
if message.nick != bot.nick:
|
||||
ps = _ps(bot)
|
||||
ps["counter"] += 1
|
||||
```
|
||||
|
||||
### Persistent state
|
||||
|
||||
Use `bot.state` for data that survives restarts:
|
||||
|
||||
```python
|
||||
@command("note", help="Save a note")
|
||||
async def cmd_note(bot, message):
|
||||
args = message.text.split(None, 2)
|
||||
if len(args) < 3:
|
||||
await bot.reply(message, "Usage: !note <key> <value>")
|
||||
return
|
||||
bot.state.set("note", args[1], args[2])
|
||||
await bot.reply(message, f"Saved: {args[1]}")
|
||||
```
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
342
docs/USAGE.md
342
docs/USAGE.md
@@ -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
|
||||
|
||||
45
patches/apply_pymumble_ssl.py
Normal file
45
patches/apply_pymumble_ssl.py
Normal 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")
|
||||
102
plugins/alert.py
102
plugins/alert.py
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
330
plugins/music.py
Normal 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}%")
|
||||
@@ -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
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
maxminddb>=2.0
|
||||
pymumble>=1.6
|
||||
PySocks>=1.7.1
|
||||
urllib3[socks]>=2.0
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""derp - asyncio IRC bot with plugin system."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "2.0.0"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
615
src/derp/mumble.py
Normal 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
532
src/derp/teams.py
Normal file
@@ -0,0 +1,532 @@
|
||||
"""Microsoft Teams adapter: outgoing webhook receiver + incoming webhook sender."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from derp import http
|
||||
from derp.bot import _TokenBucket
|
||||
from derp.plugin import TIERS, PluginRegistry
|
||||
from derp.state import StateStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_MAX_BODY = 65536 # 64 KB
|
||||
_AMBIGUOUS = object() # sentinel for ambiguous prefix matches
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TeamsMessage:
|
||||
"""Parsed Teams Activity message, duck-typed with IRC Message.
|
||||
|
||||
Plugins that use only ``msg.nick``, ``msg.text``, ``msg.target``,
|
||||
``msg.is_channel``, ``msg.prefix``, ``msg.command``, ``msg.params``,
|
||||
and ``msg.tags`` work without modification.
|
||||
"""
|
||||
|
||||
raw: dict
|
||||
nick: str | None
|
||||
prefix: str | None # AAD object ID (for ACL matching)
|
||||
text: str | None
|
||||
target: str | None # conversation/channel ID
|
||||
is_channel: bool = True # outgoing webhooks are always channels
|
||||
command: str = "PRIVMSG" # compatibility shim
|
||||
params: list[str] = field(default_factory=list)
|
||||
tags: dict[str, str] = field(default_factory=dict)
|
||||
_replies: list[str] = field(default_factory=list, repr=False)
|
||||
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _verify_hmac(secret: str, body: bytes, auth_header: str) -> bool:
|
||||
"""Verify Teams outgoing webhook HMAC-SHA256 signature.
|
||||
|
||||
The secret is base64-encoded. The Authorization header format is
|
||||
``HMAC <base64(hmac-sha256(b64decode(secret), body))>``.
|
||||
"""
|
||||
if not secret:
|
||||
return True # no secret configured = open access
|
||||
if not auth_header.startswith("HMAC "):
|
||||
return False
|
||||
try:
|
||||
key = base64.b64decode(secret)
|
||||
except Exception:
|
||||
log.error("teams: invalid base64 webhook secret")
|
||||
return False
|
||||
expected = base64.b64encode(
|
||||
hmac.new(key, body, hashlib.sha256).digest(),
|
||||
).decode("ascii")
|
||||
return hmac.compare_digest(expected, auth_header[5:])
|
||||
|
||||
|
||||
def _strip_mention(text: str, bot_name: str) -> str:
|
||||
"""Strip ``<at>BotName</at>`` prefix from message text."""
|
||||
return re.sub(r"<at>[^<]*</at>\s*", "", text).strip()
|
||||
|
||||
|
||||
def _parse_activity(body: bytes) -> dict | None:
|
||||
"""Parse Teams Activity JSON. Returns None on failure."""
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _build_teams_message(activity: dict, bot_name: str) -> TeamsMessage:
|
||||
"""Build a TeamsMessage from a Teams Activity dict."""
|
||||
sender = activity.get("from", {})
|
||||
conv = activity.get("conversation", {})
|
||||
nick = sender.get("name")
|
||||
prefix = sender.get("aadObjectId")
|
||||
raw_text = activity.get("text", "")
|
||||
text = _strip_mention(raw_text, bot_name)
|
||||
target = conv.get("id")
|
||||
return TeamsMessage(
|
||||
raw=activity,
|
||||
nick=nick,
|
||||
prefix=prefix,
|
||||
text=text,
|
||||
target=target,
|
||||
params=[target or "", text] if target else [text],
|
||||
)
|
||||
|
||||
|
||||
def _http_response(status: int, reason: str, body: str = "",
|
||||
content_type: str = "text/plain; charset=utf-8") -> bytes:
|
||||
"""Build a minimal HTTP/1.1 response."""
|
||||
body_bytes = body.encode("utf-8") if body else b""
|
||||
lines = [
|
||||
f"HTTP/1.1 {status} {reason}",
|
||||
f"Content-Type: {content_type}",
|
||||
f"Content-Length: {len(body_bytes)}",
|
||||
"Connection: close",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
return "\r\n".join(lines).encode("utf-8") + body_bytes
|
||||
|
||||
|
||||
def _json_response(status: int, reason: str, data: dict) -> bytes:
|
||||
"""Build an HTTP/1.1 JSON response."""
|
||||
body = json.dumps(data)
|
||||
return _http_response(status, reason, body, "application/json")
|
||||
|
||||
|
||||
# -- TeamsBot ----------------------------------------------------------------
|
||||
|
||||
|
||||
class TeamsBot:
|
||||
"""Microsoft Teams bot adapter via outgoing/incoming webhooks.
|
||||
|
||||
Exposes the same public API as :class:`derp.bot.Bot` so that
|
||||
protocol-agnostic plugins work without modification.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, config: dict, registry: PluginRegistry) -> None:
|
||||
self.name = name
|
||||
self.config = config
|
||||
self.registry = registry
|
||||
self._pstate: dict = {}
|
||||
|
||||
teams_cfg = config.get("teams", {})
|
||||
self._proxy: bool = teams_cfg.get("proxy", True)
|
||||
self.nick: str = teams_cfg.get("bot_name", "derp")
|
||||
self.prefix: str = config.get("bot", {}).get("prefix", "!")
|
||||
self._running = False
|
||||
self._started: float = time.monotonic()
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
self._admins: list[str] = teams_cfg.get("admins", [])
|
||||
self._operators: list[str] = teams_cfg.get("operators", [])
|
||||
self._trusted: list[str] = teams_cfg.get("trusted", [])
|
||||
self.state = StateStore(f"data/state-{name}.db")
|
||||
self._server: asyncio.Server | None = None
|
||||
|
||||
self._webhook_secret: str = teams_cfg.get("webhook_secret", "")
|
||||
self._incoming_url: str = teams_cfg.get("incoming_webhook_url", "")
|
||||
self._bind: str = teams_cfg.get("bind", "127.0.0.1")
|
||||
self._port: int = teams_cfg.get("port", 8081)
|
||||
|
||||
rate_cfg = config.get("bot", {})
|
||||
self._bucket = _TokenBucket(
|
||||
rate=rate_cfg.get("rate_limit", 2.0),
|
||||
burst=rate_cfg.get("rate_burst", 5),
|
||||
)
|
||||
|
||||
# -- Lifecycle -----------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the HTTP server for receiving outgoing webhooks."""
|
||||
self._running = True
|
||||
try:
|
||||
self._server = await asyncio.start_server(
|
||||
self._handle_connection, self._bind, self._port,
|
||||
)
|
||||
except OSError as exc:
|
||||
log.error("teams: failed to bind %s:%d: %s",
|
||||
self._bind, self._port, exc)
|
||||
return
|
||||
log.info("teams: listening on %s:%d", self._bind, self._port)
|
||||
try:
|
||||
while self._running:
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
log.info("teams: stopped")
|
||||
|
||||
# -- HTTP server ---------------------------------------------------------
|
||||
|
||||
async def _handle_connection(
|
||||
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
"""Handle a single HTTP connection from Teams."""
|
||||
try:
|
||||
# Read request line
|
||||
request_line = await asyncio.wait_for(
|
||||
reader.readline(), timeout=10.0)
|
||||
if not request_line:
|
||||
return
|
||||
parts = request_line.decode("utf-8", errors="replace").strip().split()
|
||||
if len(parts) < 2:
|
||||
writer.write(_http_response(400, "Bad Request",
|
||||
"malformed request"))
|
||||
return
|
||||
method, path = parts[0], parts[1]
|
||||
|
||||
# Read headers
|
||||
headers: dict[str, str] = {}
|
||||
while True:
|
||||
line = await asyncio.wait_for(reader.readline(), timeout=10.0)
|
||||
if not line or line == b"\r\n" or line == b"\n":
|
||||
break
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if ":" in decoded:
|
||||
key, val = decoded.split(":", 1)
|
||||
headers[key.strip().lower()] = val.strip()
|
||||
|
||||
# Method check
|
||||
if method != "POST":
|
||||
writer.write(_http_response(405, "Method Not Allowed",
|
||||
"POST only"))
|
||||
return
|
||||
|
||||
# Path check
|
||||
if path != "/api/messages":
|
||||
writer.write(_http_response(404, "Not Found"))
|
||||
return
|
||||
|
||||
# Read body
|
||||
content_length = int(headers.get("content-length", "0"))
|
||||
if content_length > _MAX_BODY:
|
||||
writer.write(_http_response(413, "Payload Too Large",
|
||||
f"max {_MAX_BODY} bytes"))
|
||||
return
|
||||
body = await asyncio.wait_for(
|
||||
reader.readexactly(content_length), timeout=10.0)
|
||||
|
||||
# Verify HMAC signature
|
||||
auth = headers.get("authorization", "")
|
||||
if not _verify_hmac(self._webhook_secret, body, auth):
|
||||
writer.write(_http_response(401, "Unauthorized",
|
||||
"bad signature"))
|
||||
return
|
||||
|
||||
# Parse Activity JSON
|
||||
activity = _parse_activity(body)
|
||||
if activity is None:
|
||||
writer.write(_http_response(400, "Bad Request",
|
||||
"invalid JSON"))
|
||||
return
|
||||
|
||||
# Only handle message activities
|
||||
if activity.get("type") != "message":
|
||||
writer.write(_json_response(200, "OK",
|
||||
{"type": "message", "text": ""}))
|
||||
return
|
||||
|
||||
# Build message and dispatch
|
||||
msg = _build_teams_message(activity, self.nick)
|
||||
await self._dispatch_command(msg)
|
||||
|
||||
# Collect replies
|
||||
reply_text = "\n".join(msg._replies) if msg._replies else ""
|
||||
writer.write(_json_response(200, "OK", {
|
||||
"type": "message",
|
||||
"text": reply_text,
|
||||
}))
|
||||
|
||||
except (asyncio.TimeoutError, asyncio.IncompleteReadError,
|
||||
ConnectionError):
|
||||
log.debug("teams: client disconnected")
|
||||
except Exception:
|
||||
log.exception("teams: error handling request")
|
||||
try:
|
||||
writer.write(_http_response(500, "Internal Server Error"))
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- Command dispatch ----------------------------------------------------
|
||||
|
||||
async def _dispatch_command(self, msg: TeamsMessage) -> None:
|
||||
"""Parse and dispatch a command from a Teams message."""
|
||||
text = msg.text
|
||||
if not text or not text.startswith(self.prefix):
|
||||
return
|
||||
|
||||
parts = text[len(self.prefix):].split(None, 1)
|
||||
cmd_name = parts[0].lower() if parts else ""
|
||||
handler = self._resolve_command(cmd_name)
|
||||
if handler is None:
|
||||
return
|
||||
if handler is _AMBIGUOUS:
|
||||
matches = [k for k in self.registry.commands
|
||||
if k.startswith(cmd_name)]
|
||||
names = ", ".join(self.prefix + m for m in sorted(matches))
|
||||
msg._replies.append(
|
||||
f"Ambiguous command '{self.prefix}{cmd_name}': {names}")
|
||||
return
|
||||
|
||||
if not self._plugin_allowed(handler.plugin, msg.target):
|
||||
return
|
||||
|
||||
required = handler.tier
|
||||
if required != "user":
|
||||
sender = self._get_tier(msg)
|
||||
if TIERS.index(sender) < TIERS.index(required):
|
||||
msg._replies.append(
|
||||
f"Permission denied: {self.prefix}{cmd_name} "
|
||||
f"requires {required}")
|
||||
return
|
||||
|
||||
try:
|
||||
await handler.callback(self, msg)
|
||||
except Exception:
|
||||
log.exception("teams: error in command handler '%s'", cmd_name)
|
||||
|
||||
def _resolve_command(self, name: str):
|
||||
"""Resolve command name with unambiguous prefix matching.
|
||||
|
||||
Returns the Handler on exact or unique prefix match, the sentinel
|
||||
``_AMBIGUOUS`` if multiple commands match, or None if nothing matches.
|
||||
"""
|
||||
handler = self.registry.commands.get(name)
|
||||
if handler is not None:
|
||||
return handler
|
||||
matches = [v for k, v in self.registry.commands.items()
|
||||
if k.startswith(name)]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
return _AMBIGUOUS
|
||||
return None
|
||||
|
||||
def _plugin_allowed(self, plugin_name: str, channel: str | None) -> bool:
|
||||
"""Channel filtering is IRC-only; all plugins are allowed on Teams."""
|
||||
return True
|
||||
|
||||
# -- Permission tiers ----------------------------------------------------
|
||||
|
||||
def _get_tier(self, msg: TeamsMessage) -> str:
|
||||
"""Determine permission tier from AAD object ID.
|
||||
|
||||
Unlike IRC (fnmatch hostmask patterns), Teams matches exact
|
||||
AAD object IDs from the ``teams.admins``, ``teams.operators``,
|
||||
and ``teams.trusted`` config lists.
|
||||
"""
|
||||
if not msg.prefix:
|
||||
return "user"
|
||||
for aad_id in self._admins:
|
||||
if msg.prefix == aad_id:
|
||||
return "admin"
|
||||
for aad_id in self._operators:
|
||||
if msg.prefix == aad_id:
|
||||
return "oper"
|
||||
for aad_id in self._trusted:
|
||||
if msg.prefix == aad_id:
|
||||
return "trusted"
|
||||
return "user"
|
||||
|
||||
def _is_admin(self, msg: TeamsMessage) -> bool:
|
||||
"""Check if the message sender is a bot admin."""
|
||||
return self._get_tier(msg) == "admin"
|
||||
|
||||
# -- Public API for plugins ----------------------------------------------
|
||||
|
||||
async def send(self, target: str, text: str) -> None:
|
||||
"""Send a message via incoming webhook (proactive messages).
|
||||
|
||||
Requires ``teams.incoming_webhook_url`` to be configured.
|
||||
Does nothing if no URL is set.
|
||||
"""
|
||||
if not self._incoming_url:
|
||||
log.debug("teams: send() skipped, no incoming_webhook_url")
|
||||
return
|
||||
await self._bucket.acquire()
|
||||
payload = json.dumps({"text": text}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
self._incoming_url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None, lambda: http.urlopen(req, proxy=self._proxy),
|
||||
)
|
||||
except Exception:
|
||||
log.exception("teams: failed to send via incoming webhook")
|
||||
|
||||
async def reply(self, msg, text: str) -> None:
|
||||
"""Reply by appending to the message reply buffer.
|
||||
|
||||
Collected replies are returned as the HTTP response body.
|
||||
"""
|
||||
msg._replies.append(text)
|
||||
|
||||
async def long_reply(
|
||||
self, msg, lines: list[str], *,
|
||||
label: str = "",
|
||||
) -> None:
|
||||
"""Reply with a list of lines; paste overflow to FlaskPaste.
|
||||
|
||||
Same overflow logic as :meth:`derp.bot.Bot.long_reply` but
|
||||
appends to the reply buffer instead of sending via IRC.
|
||||
"""
|
||||
threshold = self.config.get("bot", {}).get("paste_threshold", 4)
|
||||
if not lines:
|
||||
return
|
||||
|
||||
if len(lines) <= threshold:
|
||||
for line in lines:
|
||||
msg._replies.append(line)
|
||||
return
|
||||
|
||||
# Attempt paste overflow
|
||||
fp = self.registry._modules.get("flaskpaste")
|
||||
paste_url = None
|
||||
if fp:
|
||||
full_text = "\n".join(lines)
|
||||
loop = asyncio.get_running_loop()
|
||||
paste_url = await loop.run_in_executor(
|
||||
None, fp.create_paste, self, full_text,
|
||||
)
|
||||
|
||||
if paste_url:
|
||||
preview_count = min(2, threshold - 1)
|
||||
for line in lines[:preview_count]:
|
||||
msg._replies.append(line)
|
||||
remaining = len(lines) - preview_count
|
||||
suffix = f" ({label})" if label else ""
|
||||
msg._replies.append(
|
||||
f"... {remaining} more lines{suffix}: {paste_url}")
|
||||
else:
|
||||
for line in lines:
|
||||
msg._replies.append(line)
|
||||
|
||||
async def action(self, target: str, text: str) -> None:
|
||||
"""Send an action as italic text via incoming webhook."""
|
||||
await self.send(target, f"_{text}_")
|
||||
|
||||
async def shorten_url(self, url: str) -> str:
|
||||
"""Shorten a URL via FlaskPaste. Returns original on failure."""
|
||||
fp = self.registry._modules.get("flaskpaste")
|
||||
if not fp:
|
||||
return url
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
return await loop.run_in_executor(None, fp.shorten_url, self, url)
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
# -- IRC no-ops ----------------------------------------------------------
|
||||
|
||||
async def join(self, channel: str) -> None:
|
||||
"""No-op: IRC-only concept."""
|
||||
log.debug("teams: join() is a no-op")
|
||||
|
||||
async def part(self, channel: str, reason: str = "") -> None:
|
||||
"""No-op: IRC-only concept."""
|
||||
log.debug("teams: part() is a no-op")
|
||||
|
||||
async def quit(self, reason: str = "bye") -> None:
|
||||
"""Stop the Teams adapter."""
|
||||
self._running = False
|
||||
|
||||
async def kick(self, channel: str, nick: str, reason: str = "") -> None:
|
||||
"""No-op: IRC-only concept."""
|
||||
log.debug("teams: kick() is a no-op")
|
||||
|
||||
async def mode(self, target: str, mode_str: str, *args: str) -> None:
|
||||
"""No-op: IRC-only concept."""
|
||||
log.debug("teams: mode() is a no-op")
|
||||
|
||||
async def set_topic(self, channel: str, topic: str) -> None:
|
||||
"""No-op: IRC-only concept."""
|
||||
log.debug("teams: set_topic() is a no-op")
|
||||
|
||||
# -- Plugin management (delegated to registry) ---------------------------
|
||||
|
||||
def load_plugins(self, plugins_dir: str | Path | None = None) -> None:
|
||||
"""Load plugins from the configured directory."""
|
||||
if plugins_dir is None:
|
||||
plugins_dir = self.config.get("bot", {}).get(
|
||||
"plugins_dir", "plugins")
|
||||
path = Path(plugins_dir)
|
||||
self.registry.load_directory(path)
|
||||
|
||||
@property
|
||||
def plugins_dir(self) -> Path:
|
||||
"""Resolved path to the plugins directory."""
|
||||
return Path(self.config.get("bot", {}).get("plugins_dir", "plugins"))
|
||||
|
||||
def load_plugin(self, name: str) -> tuple[bool, str]:
|
||||
"""Hot-load a new plugin by name from the plugins directory."""
|
||||
if name in self.registry._modules:
|
||||
return False, f"plugin already loaded: {name}"
|
||||
path = self.plugins_dir / f"{name}.py"
|
||||
if not path.is_file():
|
||||
return False, f"{name}.py not found"
|
||||
count = self.registry.load_plugin(path)
|
||||
if count < 0:
|
||||
return False, f"failed to load {name}"
|
||||
return True, f"{count} handlers"
|
||||
|
||||
def reload_plugin(self, name: str) -> tuple[bool, str]:
|
||||
"""Reload a plugin, picking up any file changes."""
|
||||
return self.registry.reload_plugin(name)
|
||||
|
||||
def unload_plugin(self, name: str) -> tuple[bool, str]:
|
||||
"""Unload a plugin, removing all its handlers."""
|
||||
if self.registry.unload_plugin(name):
|
||||
return True, ""
|
||||
if name == "core":
|
||||
return False, "cannot unload core"
|
||||
return False, f"plugin not loaded: {name}"
|
||||
|
||||
def _spawn(self, coro, *, name: str | None = None) -> asyncio.Task:
|
||||
"""Spawn a background task and track it for cleanup."""
|
||||
task = asyncio.create_task(coro, name=name)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
return task
|
||||
490
src/derp/telegram.py
Normal file
490
src/derp/telegram.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""Telegram adapter: long-polling via getUpdates, all HTTP through SOCKS5."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from derp import http
|
||||
from derp.bot import _TokenBucket
|
||||
from derp.plugin import TIERS, PluginRegistry
|
||||
from derp.state import StateStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_API_BASE = "https://api.telegram.org/bot"
|
||||
_MAX_MSG_LEN = 4096
|
||||
_AMBIGUOUS = object() # sentinel for ambiguous prefix matches
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TelegramMessage:
|
||||
"""Parsed Telegram update, duck-typed with IRC Message.
|
||||
|
||||
Plugins that use only ``msg.nick``, ``msg.text``, ``msg.target``,
|
||||
``msg.is_channel``, ``msg.prefix``, ``msg.command``, ``msg.params``,
|
||||
and ``msg.tags`` work without modification.
|
||||
"""
|
||||
|
||||
raw: dict # original Telegram Update
|
||||
nick: str | None # first_name (or username fallback)
|
||||
prefix: str | None # user_id as string (for ACL)
|
||||
text: str | None # message text
|
||||
target: str | None # chat_id as string
|
||||
is_channel: bool = True # True for groups, False for DMs
|
||||
command: str = "PRIVMSG" # compat shim
|
||||
params: list[str] = field(default_factory=list)
|
||||
tags: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _strip_bot_suffix(text: str, bot_username: str) -> str:
|
||||
"""Strip ``@botusername`` suffix from command text.
|
||||
|
||||
``!help@mybot`` -> ``!help``
|
||||
"""
|
||||
if not bot_username:
|
||||
return text
|
||||
suffix = f"@{bot_username}"
|
||||
if " " in text:
|
||||
first, rest = text.split(" ", 1)
|
||||
if first.lower().endswith(suffix.lower()):
|
||||
return first[: -len(suffix)] + " " + rest
|
||||
return text
|
||||
if text.lower().endswith(suffix.lower()):
|
||||
return text[: -len(suffix)]
|
||||
return text
|
||||
|
||||
|
||||
def _build_telegram_message(
|
||||
update: dict, bot_username: str,
|
||||
) -> TelegramMessage | None:
|
||||
"""Build a TelegramMessage from a Telegram Update dict.
|
||||
|
||||
Returns None if the update has no usable message.
|
||||
"""
|
||||
msg = update.get("message") or update.get("edited_message")
|
||||
if not msg or not isinstance(msg, dict):
|
||||
return None
|
||||
|
||||
sender = msg.get("from", {})
|
||||
chat = msg.get("chat", {})
|
||||
|
||||
nick = sender.get("first_name") or sender.get("username")
|
||||
user_id = sender.get("id")
|
||||
prefix = str(user_id) if user_id is not None else None
|
||||
|
||||
raw_text = msg.get("text", "")
|
||||
text = _strip_bot_suffix(raw_text, bot_username) if raw_text else raw_text
|
||||
|
||||
chat_id = chat.get("id")
|
||||
target = str(chat_id) if chat_id is not None else None
|
||||
|
||||
chat_type = chat.get("type", "private")
|
||||
is_channel = chat_type in ("group", "supergroup", "channel")
|
||||
|
||||
return TelegramMessage(
|
||||
raw=update,
|
||||
nick=nick,
|
||||
prefix=prefix,
|
||||
text=text,
|
||||
target=target,
|
||||
is_channel=is_channel,
|
||||
params=[target or "", text] if target else [text],
|
||||
)
|
||||
|
||||
|
||||
def _split_message(text: str, max_len: int = _MAX_MSG_LEN) -> list[str]:
|
||||
"""Split text at line boundaries to fit within max_len."""
|
||||
if len(text.encode("utf-8")) <= max_len:
|
||||
return [text]
|
||||
|
||||
chunks: list[str] = []
|
||||
current: list[str] = []
|
||||
current_len = 0
|
||||
|
||||
for line in text.split("\n"):
|
||||
line_len = len(line.encode("utf-8")) + 1 # +1 for newline
|
||||
if current and current_len + line_len > max_len:
|
||||
chunks.append("\n".join(current))
|
||||
current = []
|
||||
current_len = 0
|
||||
current.append(line)
|
||||
current_len += line_len
|
||||
|
||||
if current:
|
||||
chunks.append("\n".join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
# -- TelegramBot -------------------------------------------------------------
|
||||
|
||||
|
||||
class TelegramBot:
|
||||
"""Telegram bot adapter via long-polling (getUpdates).
|
||||
|
||||
Exposes the same public API as :class:`derp.bot.Bot` so that
|
||||
protocol-agnostic plugins work without modification.
|
||||
HTTP is routed through ``derp.http.urlopen`` (SOCKS5 optional
|
||||
via ``telegram.proxy`` config).
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, config: dict, registry: PluginRegistry) -> None:
|
||||
self.name = name
|
||||
self.config = config
|
||||
self.registry = registry
|
||||
self._pstate: dict = {}
|
||||
|
||||
tg_cfg = config.get("telegram", {})
|
||||
self._token: str = tg_cfg.get("bot_token", "")
|
||||
self._poll_timeout: int = tg_cfg.get("poll_timeout", 30)
|
||||
self._proxy: bool = tg_cfg.get("proxy", True)
|
||||
self.nick: str = "" # set by getMe
|
||||
self._bot_username: str = "" # set by getMe
|
||||
self.prefix: str = (
|
||||
tg_cfg.get("prefix")
|
||||
or config.get("bot", {}).get("prefix", "!")
|
||||
)
|
||||
self._running = False
|
||||
self._started: float = time.monotonic()
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
self._admins: list[str] = [str(x) for x in tg_cfg.get("admins", [])]
|
||||
self._operators: list[str] = [str(x) for x in tg_cfg.get("operators", [])]
|
||||
self._trusted: list[str] = [str(x) for x in tg_cfg.get("trusted", [])]
|
||||
self.state = StateStore(f"data/state-{name}.db")
|
||||
self._offset: int = 0
|
||||
|
||||
rate_cfg = config.get("bot", {})
|
||||
self._bucket = _TokenBucket(
|
||||
rate=rate_cfg.get("rate_limit", 2.0),
|
||||
burst=rate_cfg.get("rate_burst", 5),
|
||||
)
|
||||
|
||||
# -- Telegram API --------------------------------------------------------
|
||||
|
||||
def _api_url(self, method: str) -> str:
|
||||
"""Build Telegram Bot API URL."""
|
||||
return f"{_API_BASE}{self._token}/{method}"
|
||||
|
||||
def _api_call(self, method: str, payload: dict | None = None) -> dict:
|
||||
"""Synchronous Telegram API call through SOCKS5 proxy.
|
||||
|
||||
Meant to run in a thread executor.
|
||||
"""
|
||||
url = self._api_url(method)
|
||||
if payload:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
else:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
|
||||
timeout = self._poll_timeout + 5 if method == "getUpdates" else 30
|
||||
resp = http.urlopen(req, timeout=timeout, proxy=self._proxy)
|
||||
body = resp.read() if hasattr(resp, "read") else resp.data
|
||||
return json.loads(body)
|
||||
|
||||
# -- Lifecycle -----------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Verify token, then enter long-poll loop."""
|
||||
self._running = True
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Verify token via getMe
|
||||
try:
|
||||
result = await loop.run_in_executor(None, self._api_call, "getMe")
|
||||
if not result.get("ok"):
|
||||
log.error("telegram: getMe failed: %s", result)
|
||||
return
|
||||
me = result.get("result", {})
|
||||
self.nick = me.get("first_name", "bot")
|
||||
self._bot_username = me.get("username", "")
|
||||
log.info("telegram: authenticated as @%s", self._bot_username)
|
||||
except Exception:
|
||||
log.exception("telegram: failed to authenticate")
|
||||
return
|
||||
|
||||
# Long-poll loop
|
||||
while self._running:
|
||||
try:
|
||||
updates = await loop.run_in_executor(
|
||||
None, self._poll_updates,
|
||||
)
|
||||
for update in updates:
|
||||
msg = _build_telegram_message(update, self._bot_username)
|
||||
if msg is not None:
|
||||
await self._dispatch_command(msg)
|
||||
except Exception:
|
||||
if self._running:
|
||||
log.exception("telegram: poll error, backing off 5s")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
log.info("telegram: stopped")
|
||||
|
||||
def _poll_updates(self) -> list[dict]:
|
||||
"""Fetch updates from Telegram (blocking, run in executor)."""
|
||||
payload = {
|
||||
"offset": self._offset,
|
||||
"timeout": self._poll_timeout,
|
||||
}
|
||||
result = self._api_call("getUpdates", payload)
|
||||
if not result.get("ok"):
|
||||
log.warning("telegram: getUpdates failed: %s", result)
|
||||
return []
|
||||
updates = result.get("result", [])
|
||||
if updates:
|
||||
self._offset = updates[-1]["update_id"] + 1
|
||||
return updates
|
||||
|
||||
# -- Command dispatch ----------------------------------------------------
|
||||
|
||||
async def _dispatch_command(self, msg: TelegramMessage) -> None:
|
||||
"""Parse and dispatch a command from a Telegram message."""
|
||||
text = msg.text
|
||||
if not text or not text.startswith(self.prefix):
|
||||
return
|
||||
|
||||
parts = text[len(self.prefix):].split(None, 1)
|
||||
cmd_name = parts[0].lower() if parts else ""
|
||||
handler = self._resolve_command(cmd_name)
|
||||
if handler is None:
|
||||
return
|
||||
if handler is _AMBIGUOUS:
|
||||
matches = [k for k in self.registry.commands
|
||||
if k.startswith(cmd_name)]
|
||||
names = ", ".join(self.prefix + m for m in sorted(matches))
|
||||
await self.reply(
|
||||
msg,
|
||||
f"Ambiguous command '{self.prefix}{cmd_name}': {names}",
|
||||
)
|
||||
return
|
||||
|
||||
if not self._plugin_allowed(handler.plugin, msg.target):
|
||||
return
|
||||
|
||||
required = handler.tier
|
||||
if required != "user":
|
||||
sender = self._get_tier(msg)
|
||||
if TIERS.index(sender) < TIERS.index(required):
|
||||
await self.reply(
|
||||
msg,
|
||||
f"Permission denied: {self.prefix}{cmd_name} "
|
||||
f"requires {required}",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await handler.callback(self, msg)
|
||||
except Exception:
|
||||
log.exception("telegram: error in command handler '%s'", cmd_name)
|
||||
|
||||
def _resolve_command(self, name: str):
|
||||
"""Resolve command name with unambiguous prefix matching.
|
||||
|
||||
Returns the Handler on exact or unique prefix match, the sentinel
|
||||
``_AMBIGUOUS`` if multiple commands match, or None if nothing matches.
|
||||
"""
|
||||
handler = self.registry.commands.get(name)
|
||||
if handler is not None:
|
||||
return handler
|
||||
matches = [v for k, v in self.registry.commands.items()
|
||||
if k.startswith(name)]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
return _AMBIGUOUS
|
||||
return None
|
||||
|
||||
def _plugin_allowed(self, plugin_name: str, channel: str | None) -> bool:
|
||||
"""Channel filtering is IRC-only; all plugins are allowed on Telegram."""
|
||||
return True
|
||||
|
||||
# -- Permission tiers ----------------------------------------------------
|
||||
|
||||
def _get_tier(self, msg: TelegramMessage) -> str:
|
||||
"""Determine permission tier from user_id.
|
||||
|
||||
Matches exact string comparison of user_id against config lists.
|
||||
"""
|
||||
if not msg.prefix:
|
||||
return "user"
|
||||
for uid in self._admins:
|
||||
if msg.prefix == uid:
|
||||
return "admin"
|
||||
for uid in self._operators:
|
||||
if msg.prefix == uid:
|
||||
return "oper"
|
||||
for uid in self._trusted:
|
||||
if msg.prefix == uid:
|
||||
return "trusted"
|
||||
return "user"
|
||||
|
||||
def _is_admin(self, msg: TelegramMessage) -> bool:
|
||||
"""Check if the message sender is a bot admin."""
|
||||
return self._get_tier(msg) == "admin"
|
||||
|
||||
# -- Public API for plugins ----------------------------------------------
|
||||
|
||||
async def send(self, target: str, text: str) -> None:
|
||||
"""Send a message via sendMessage API (proxied, rate-limited).
|
||||
|
||||
Long messages are split at line boundaries to fit Telegram's
|
||||
4096-character limit.
|
||||
"""
|
||||
await self._bucket.acquire()
|
||||
loop = asyncio.get_running_loop()
|
||||
for chunk in _split_message(text):
|
||||
payload = {
|
||||
"chat_id": target,
|
||||
"text": chunk,
|
||||
}
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None, self._api_call, "sendMessage", payload,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("telegram: failed to send message")
|
||||
|
||||
async def reply(self, msg, text: str) -> None:
|
||||
"""Reply to the source chat."""
|
||||
if msg.target:
|
||||
await self.send(msg.target, text)
|
||||
|
||||
async def long_reply(
|
||||
self, msg, lines: list[str], *,
|
||||
label: str = "",
|
||||
) -> None:
|
||||
"""Reply with a list of lines; paste overflow to FlaskPaste.
|
||||
|
||||
Same overflow logic as :meth:`derp.bot.Bot.long_reply`.
|
||||
"""
|
||||
threshold = self.config.get("bot", {}).get("paste_threshold", 4)
|
||||
if not lines or not msg.target:
|
||||
return
|
||||
|
||||
if len(lines) <= threshold:
|
||||
for line in lines:
|
||||
await self.send(msg.target, line)
|
||||
return
|
||||
|
||||
# Attempt paste overflow
|
||||
fp = self.registry._modules.get("flaskpaste")
|
||||
paste_url = None
|
||||
if fp:
|
||||
full_text = "\n".join(lines)
|
||||
loop = asyncio.get_running_loop()
|
||||
paste_url = await loop.run_in_executor(
|
||||
None, fp.create_paste, self, full_text,
|
||||
)
|
||||
|
||||
if paste_url:
|
||||
preview_count = min(2, threshold - 1)
|
||||
for line in lines[:preview_count]:
|
||||
await self.send(msg.target, line)
|
||||
remaining = len(lines) - preview_count
|
||||
suffix = f" ({label})" if label else ""
|
||||
await self.send(
|
||||
msg.target,
|
||||
f"... {remaining} more lines{suffix}: {paste_url}",
|
||||
)
|
||||
else:
|
||||
for line in lines:
|
||||
await self.send(msg.target, line)
|
||||
|
||||
async def action(self, target: str, text: str) -> None:
|
||||
"""Send an action as italic Markdown text."""
|
||||
await self.send(target, f"_{text}_")
|
||||
|
||||
async def shorten_url(self, url: str) -> str:
|
||||
"""Shorten a URL via FlaskPaste. Returns original on failure."""
|
||||
fp = self.registry._modules.get("flaskpaste")
|
||||
if not fp:
|
||||
return url
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
return await loop.run_in_executor(None, fp.shorten_url, self, url)
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
# -- IRC no-ops ----------------------------------------------------------
|
||||
|
||||
async def join(self, channel: str) -> None:
|
||||
"""No-op: IRC-only concept."""
|
||||
log.debug("telegram: join() is a no-op")
|
||||
|
||||
async def part(self, channel: str, reason: str = "") -> None:
|
||||
"""No-op: IRC-only concept."""
|
||||
log.debug("telegram: part() is a no-op")
|
||||
|
||||
async def quit(self, reason: str = "bye") -> None:
|
||||
"""Stop the Telegram adapter."""
|
||||
self._running = False
|
||||
|
||||
async def kick(self, channel: str, nick: str, reason: str = "") -> None:
|
||||
"""No-op: IRC-only concept."""
|
||||
log.debug("telegram: kick() is a no-op")
|
||||
|
||||
async def mode(self, target: str, mode_str: str, *args: str) -> None:
|
||||
"""No-op: IRC-only concept."""
|
||||
log.debug("telegram: mode() is a no-op")
|
||||
|
||||
async def set_topic(self, channel: str, topic: str) -> None:
|
||||
"""No-op: IRC-only concept."""
|
||||
log.debug("telegram: set_topic() is a no-op")
|
||||
|
||||
# -- Plugin management (delegated to registry) ---------------------------
|
||||
|
||||
def load_plugins(self, plugins_dir: str | Path | None = None) -> None:
|
||||
"""Load plugins from the configured directory."""
|
||||
if plugins_dir is None:
|
||||
plugins_dir = self.config.get("bot", {}).get(
|
||||
"plugins_dir", "plugins")
|
||||
path = Path(plugins_dir)
|
||||
self.registry.load_directory(path)
|
||||
|
||||
@property
|
||||
def plugins_dir(self) -> Path:
|
||||
"""Resolved path to the plugins directory."""
|
||||
return Path(self.config.get("bot", {}).get("plugins_dir", "plugins"))
|
||||
|
||||
def load_plugin(self, name: str) -> tuple[bool, str]:
|
||||
"""Hot-load a new plugin by name from the plugins directory."""
|
||||
if name in self.registry._modules:
|
||||
return False, f"plugin already loaded: {name}"
|
||||
path = self.plugins_dir / f"{name}.py"
|
||||
if not path.is_file():
|
||||
return False, f"{name}.py not found"
|
||||
count = self.registry.load_plugin(path)
|
||||
if count < 0:
|
||||
return False, f"failed to load {name}"
|
||||
return True, f"{count} handlers"
|
||||
|
||||
def reload_plugin(self, name: str) -> tuple[bool, str]:
|
||||
"""Reload a plugin, picking up any file changes."""
|
||||
return self.registry.reload_plugin(name)
|
||||
|
||||
def unload_plugin(self, name: str) -> tuple[bool, str]:
|
||||
"""Unload a plugin, removing all its handlers."""
|
||||
if self.registry.unload_plugin(name):
|
||||
return True, ""
|
||||
if name == "core":
|
||||
return False, "cannot unload core"
|
||||
return False, f"plugin not loaded: {name}"
|
||||
|
||||
def _spawn(self, coro, *, name: str | None = None) -> asyncio.Task:
|
||||
"""Spawn a background task and track it for cleanup."""
|
||||
task = asyncio.create_task(coro, name=name)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
return task
|
||||
@@ -63,7 +63,7 @@ class _Harness:
|
||||
},
|
||||
}
|
||||
self.registry = PluginRegistry()
|
||||
self.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"))
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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
647
tests/test_mumble.py
Normal 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("& < > "") == '& < > "'
|
||||
|
||||
def test_strip_html_nested(self):
|
||||
assert _strip_html("<p><b>hello</b> <i>world</i></p>") == "hello world"
|
||||
|
||||
def test_strip_html_plain(self):
|
||||
assert _strip_html("no tags here") == "no tags here"
|
||||
|
||||
def test_escape_html(self):
|
||||
assert _escape_html("<script>alert('xss')") == "<script>alert('xss')"
|
||||
|
||||
def test_escape_html_ampersand(self):
|
||||
assert _escape_html("a & b") == "a & b"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMumbleBotReply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMumbleBotReply:
|
||||
def test_send_calls_send_html(self):
|
||||
bot = _make_bot()
|
||||
sent: list[tuple[str, str]] = []
|
||||
|
||||
async def _fake_send_html(target, html_text):
|
||||
sent.append((target, html_text))
|
||||
|
||||
with patch.object(bot, "_send_html", side_effect=_fake_send_html):
|
||||
asyncio.run(bot.send("5", "hello"))
|
||||
assert sent == [("5", "hello")]
|
||||
|
||||
def test_send_escapes_html(self):
|
||||
bot = _make_bot()
|
||||
sent: list[tuple[str, str]] = []
|
||||
|
||||
async def _fake_send_html(target, html_text):
|
||||
sent.append((target, html_text))
|
||||
|
||||
with patch.object(bot, "_send_html", side_effect=_fake_send_html):
|
||||
asyncio.run(bot.send("0", "<script>alert('xss')"))
|
||||
assert "<script>" in sent[0][1]
|
||||
|
||||
def test_reply_sends_to_target(self):
|
||||
bot = _make_bot()
|
||||
msg = _mu_msg(target="5")
|
||||
sent: list[tuple[str, str]] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append((target, text))
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot.reply(msg, "pong"))
|
||||
assert sent == [("5", "pong")]
|
||||
|
||||
def test_reply_dm_fallback(self):
|
||||
bot = _make_bot()
|
||||
msg = _mu_msg(target="dm", is_channel=False)
|
||||
sent: list[tuple[str, str]] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append((target, text))
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot.reply(msg, "dm reply"))
|
||||
assert sent == [("0", "dm reply")]
|
||||
|
||||
def test_long_reply_under_threshold(self):
|
||||
bot = _make_bot()
|
||||
msg = _mu_msg()
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot.long_reply(msg, ["a", "b", "c"]))
|
||||
assert sent == ["a", "b", "c"]
|
||||
|
||||
def test_long_reply_over_threshold_no_paste(self):
|
||||
bot = _make_bot()
|
||||
msg = _mu_msg()
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot.long_reply(msg, ["a", "b", "c", "d", "e"]))
|
||||
assert sent == ["a", "b", "c", "d", "e"]
|
||||
|
||||
def test_long_reply_empty(self):
|
||||
bot = _make_bot()
|
||||
msg = _mu_msg()
|
||||
with patch.object(bot, "send") as mock_send:
|
||||
asyncio.run(bot.long_reply(msg, []))
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_action_format(self):
|
||||
bot = _make_bot()
|
||||
sent: list[tuple[str, str]] = []
|
||||
|
||||
async def _fake_send_html(target, html_text):
|
||||
sent.append((target, html_text))
|
||||
|
||||
with patch.object(bot, "_send_html", side_effect=_fake_send_html):
|
||||
asyncio.run(bot.action("0", "does a thing"))
|
||||
assert sent == [("0", "<i>does a thing</i>")]
|
||||
|
||||
def test_action_escapes_content(self):
|
||||
bot = _make_bot()
|
||||
sent: list[tuple[str, str]] = []
|
||||
|
||||
async def _fake_send_html(target, html_text):
|
||||
sent.append((target, html_text))
|
||||
|
||||
with patch.object(bot, "_send_html", side_effect=_fake_send_html):
|
||||
asyncio.run(bot.action("0", "<script>"))
|
||||
assert sent == [("0", "<i><script></i>")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMumbleBotDispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMumbleBotDispatch:
|
||||
def test_dispatch_known_command(self):
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command(
|
||||
"echo", _echo_handler, help="echo", plugin="test")
|
||||
msg = _mu_msg(text="!echo world")
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert sent == ["world"]
|
||||
|
||||
def test_dispatch_unknown_command(self):
|
||||
bot = _make_bot()
|
||||
msg = _mu_msg(text="!nonexistent")
|
||||
with patch.object(bot, "send") as mock_send:
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_dispatch_no_prefix(self):
|
||||
bot = _make_bot()
|
||||
msg = _mu_msg(text="just a message")
|
||||
with patch.object(bot, "send") as mock_send:
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_dispatch_empty_text(self):
|
||||
bot = _make_bot()
|
||||
msg = _mu_msg(text="")
|
||||
with patch.object(bot, "send") as mock_send:
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_dispatch_none_text(self):
|
||||
bot = _make_bot()
|
||||
msg = _mu_msg()
|
||||
msg.text = None
|
||||
with patch.object(bot, "send") as mock_send:
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_dispatch_ambiguous(self):
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command("ping", _echo_handler, plugin="test")
|
||||
bot.registry.register_command("plugins", _echo_handler, plugin="test")
|
||||
msg = _mu_msg(text="!p")
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert len(sent) == 1
|
||||
assert "Ambiguous" in sent[0]
|
||||
|
||||
def test_dispatch_tier_denied(self):
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command(
|
||||
"secret", _admin_handler, plugin="test", tier="admin")
|
||||
msg = _mu_msg(text="!secret", prefix="nobody")
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert len(sent) == 1
|
||||
assert "Permission denied" in sent[0]
|
||||
|
||||
def test_dispatch_tier_allowed(self):
|
||||
bot = _make_bot(admins=["Alice"])
|
||||
bot.registry.register_command(
|
||||
"secret", _admin_handler, plugin="test", tier="admin")
|
||||
msg = _mu_msg(text="!secret", prefix="Alice")
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert sent == ["admin action done"]
|
||||
|
||||
def test_dispatch_prefix_match(self):
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command("echo", _echo_handler, plugin="test")
|
||||
msg = _mu_msg(text="!ec hello")
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert sent == ["hello"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMumbleBotTier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMumbleBotTier:
|
||||
def test_admin_tier(self):
|
||||
bot = _make_bot(admins=["AdminUser"])
|
||||
msg = _mu_msg(prefix="AdminUser")
|
||||
assert bot._get_tier(msg) == "admin"
|
||||
|
||||
def test_oper_tier(self):
|
||||
bot = _make_bot(operators=["OperUser"])
|
||||
msg = _mu_msg(prefix="OperUser")
|
||||
assert bot._get_tier(msg) == "oper"
|
||||
|
||||
def test_trusted_tier(self):
|
||||
bot = _make_bot(trusted=["TrustedUser"])
|
||||
msg = _mu_msg(prefix="TrustedUser")
|
||||
assert bot._get_tier(msg) == "trusted"
|
||||
|
||||
def test_user_tier_default(self):
|
||||
bot = _make_bot()
|
||||
msg = _mu_msg(prefix="RandomUser")
|
||||
assert bot._get_tier(msg) == "user"
|
||||
|
||||
def test_no_prefix(self):
|
||||
bot = _make_bot(admins=["Admin"])
|
||||
msg = _mu_msg()
|
||||
msg.prefix = None
|
||||
assert bot._get_tier(msg) == "user"
|
||||
|
||||
def test_is_admin_true(self):
|
||||
bot = _make_bot(admins=["Admin"])
|
||||
msg = _mu_msg(prefix="Admin")
|
||||
assert bot._is_admin(msg) is True
|
||||
|
||||
def test_is_admin_false(self):
|
||||
bot = _make_bot()
|
||||
msg = _mu_msg(prefix="Nobody")
|
||||
assert bot._is_admin(msg) is False
|
||||
|
||||
def test_priority_order(self):
|
||||
"""Admin takes priority over oper and trusted."""
|
||||
bot = _make_bot(admins=["User"], operators=["User"], trusted=["User"])
|
||||
msg = _mu_msg(prefix="User")
|
||||
assert bot._get_tier(msg) == "admin"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMumbleBotNoOps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMumbleBotNoOps:
|
||||
def test_join_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.join("#channel"))
|
||||
|
||||
def test_part_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.part("#channel", "reason"))
|
||||
|
||||
def test_kick_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.kick("#channel", "nick", "reason"))
|
||||
|
||||
def test_mode_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.mode("#channel", "+o", "nick"))
|
||||
|
||||
def test_set_topic_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.set_topic("#channel", "new topic"))
|
||||
|
||||
def test_quit_stops(self):
|
||||
bot = _make_bot()
|
||||
bot._running = True
|
||||
asyncio.run(bot.quit())
|
||||
assert bot._running is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestPluginManagement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPluginManagement:
|
||||
def test_load_plugin_not_found(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.load_plugin("nonexistent_xyz")
|
||||
assert ok is False
|
||||
assert "not found" in msg
|
||||
|
||||
def test_load_plugin_already_loaded(self):
|
||||
bot = _make_bot()
|
||||
bot.registry._modules["test"] = object()
|
||||
ok, msg = bot.load_plugin("test")
|
||||
assert ok is False
|
||||
assert "already loaded" in msg
|
||||
|
||||
def test_unload_core_refused(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.unload_plugin("core")
|
||||
assert ok is False
|
||||
assert "cannot unload core" in msg
|
||||
|
||||
def test_unload_not_loaded(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.unload_plugin("nonexistent")
|
||||
assert ok is False
|
||||
assert "not loaded" in msg
|
||||
|
||||
def test_reload_delegates(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.reload_plugin("nonexistent")
|
||||
assert ok is False
|
||||
assert "not loaded" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMumbleBotConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMumbleBotConfig:
|
||||
def test_prefix_from_mumble_section(self):
|
||||
config = {
|
||||
"mumble": {
|
||||
"enabled": True,
|
||||
"host": "127.0.0.1",
|
||||
"port": 64738,
|
||||
"username": "derp",
|
||||
"password": "",
|
||||
"prefix": "/",
|
||||
"admins": [],
|
||||
"operators": [],
|
||||
"trusted": [],
|
||||
},
|
||||
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
|
||||
}
|
||||
bot = MumbleBot("test", config, PluginRegistry())
|
||||
assert bot.prefix == "/"
|
||||
|
||||
def test_prefix_falls_back_to_bot(self):
|
||||
config = {
|
||||
"mumble": {
|
||||
"enabled": True,
|
||||
"host": "127.0.0.1",
|
||||
"port": 64738,
|
||||
"username": "derp",
|
||||
"password": "",
|
||||
"admins": [],
|
||||
"operators": [],
|
||||
"trusted": [],
|
||||
},
|
||||
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
|
||||
}
|
||||
bot = MumbleBot("test", config, PluginRegistry())
|
||||
assert bot.prefix == "!"
|
||||
|
||||
def test_admins_coerced_to_str(self):
|
||||
bot = _make_bot(admins=[111, 222])
|
||||
assert bot._admins == ["111", "222"]
|
||||
|
||||
def test_default_port(self):
|
||||
bot = _make_bot()
|
||||
assert bot._port == 64738
|
||||
|
||||
def test_nick_from_username(self):
|
||||
bot = _make_bot()
|
||||
assert bot.nick == "derp"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestPcmScaling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPcmScaling:
|
||||
def test_unity_volume(self):
|
||||
pcm = struct.pack("<hh", 1000, -1000)
|
||||
result = _scale_pcm(pcm, 1.0)
|
||||
assert result == pcm
|
||||
|
||||
def test_half_volume(self):
|
||||
pcm = struct.pack("<h", 1000)
|
||||
result = _scale_pcm(pcm, 0.5)
|
||||
samples = struct.unpack("<h", result)
|
||||
assert samples[0] == 500
|
||||
|
||||
def test_clamp_positive(self):
|
||||
pcm = struct.pack("<h", 32767)
|
||||
result = _scale_pcm(pcm, 2.0)
|
||||
samples = struct.unpack("<h", result)
|
||||
assert samples[0] == 32767
|
||||
|
||||
def test_clamp_negative(self):
|
||||
pcm = struct.pack("<h", -32768)
|
||||
result = _scale_pcm(pcm, 2.0)
|
||||
samples = struct.unpack("<h", result)
|
||||
assert samples[0] == -32768
|
||||
|
||||
def test_zero_volume(self):
|
||||
pcm = struct.pack("<hh", 32767, -32768)
|
||||
result = _scale_pcm(pcm, 0.0)
|
||||
samples = struct.unpack("<hh", result)
|
||||
assert samples == (0, 0)
|
||||
|
||||
def test_preserves_length(self):
|
||||
pcm = b"\x00" * 1920
|
||||
result = _scale_pcm(pcm, 0.5)
|
||||
assert len(result) == 1920
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestShellQuote
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestShellQuote:
|
||||
def test_simple(self):
|
||||
assert _shell_quote("hello") == "'hello'"
|
||||
|
||||
def test_single_quote(self):
|
||||
assert _shell_quote("it's") == "'it'\\''s'"
|
||||
|
||||
def test_url(self):
|
||||
url = "https://youtube.com/watch?v=abc&t=10"
|
||||
quoted = _shell_quote(url)
|
||||
assert quoted.startswith("'")
|
||||
assert quoted.endswith("'")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestPcmRamping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPcmRamping:
|
||||
def test_flat_when_equal(self):
|
||||
"""When vol_start == vol_end, behaves like _scale_pcm."""
|
||||
pcm = struct.pack("<hh", 1000, -1000)
|
||||
result = _scale_pcm_ramp(pcm, 0.5, 0.5)
|
||||
expected = _scale_pcm(pcm, 0.5)
|
||||
assert result == expected
|
||||
|
||||
def test_linear_interpolation(self):
|
||||
"""Volume ramps linearly from start to end across samples."""
|
||||
pcm = struct.pack("<hhhh", 10000, 10000, 10000, 10000)
|
||||
result = _scale_pcm_ramp(pcm, 0.0, 1.0)
|
||||
samples = struct.unpack("<hhhh", result)
|
||||
# At i=0: vol=0.0, i=1: vol=0.25, i=2: vol=0.5, i=3: vol=0.75
|
||||
assert samples[0] == 0
|
||||
assert samples[1] == 2500
|
||||
assert samples[2] == 5000
|
||||
assert samples[3] == 7500
|
||||
|
||||
def test_clamp_positive(self):
|
||||
"""Ramping up with loud samples clamps to 32767."""
|
||||
pcm = struct.pack("<h", 32767)
|
||||
result = _scale_pcm_ramp(pcm, 2.0, 2.0)
|
||||
samples = struct.unpack("<h", result)
|
||||
assert samples[0] == 32767
|
||||
|
||||
def test_clamp_negative(self):
|
||||
"""Ramping up with negative samples clamps to -32768."""
|
||||
pcm = struct.pack("<h", -32768)
|
||||
result = _scale_pcm_ramp(pcm, 2.0, 2.0)
|
||||
samples = struct.unpack("<h", result)
|
||||
assert samples[0] == -32768
|
||||
|
||||
def test_preserves_length(self):
|
||||
"""Output length equals input length."""
|
||||
pcm = b"\x00" * 1920
|
||||
result = _scale_pcm_ramp(pcm, 0.0, 1.0)
|
||||
assert len(result) == 1920
|
||||
|
||||
def test_empty_data(self):
|
||||
"""Empty input returns empty output."""
|
||||
result = _scale_pcm_ramp(b"", 0.0, 1.0)
|
||||
assert result == b""
|
||||
|
||||
def test_reverse_direction(self):
|
||||
"""Volume ramps down from start to end."""
|
||||
pcm = struct.pack("<hhhh", 10000, 10000, 10000, 10000)
|
||||
result = _scale_pcm_ramp(pcm, 1.0, 0.0)
|
||||
samples = struct.unpack("<hhhh", result)
|
||||
# At i=0: vol=1.0, i=1: vol=0.75, i=2: vol=0.5, i=3: vol=0.25
|
||||
assert samples[0] == 10000
|
||||
assert samples[1] == 7500
|
||||
assert samples[2] == 5000
|
||||
assert samples[3] == 2500
|
||||
459
tests/test_music.py
Normal file
459
tests/test_music.py
Normal 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")]
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]}})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
759
tests/test_teams.py
Normal file
@@ -0,0 +1,759 @@
|
||||
"""Tests for the Microsoft Teams adapter."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
from derp.plugin import PluginRegistry
|
||||
from derp.teams import (
|
||||
_MAX_BODY,
|
||||
TeamsBot,
|
||||
TeamsMessage,
|
||||
_build_teams_message,
|
||||
_http_response,
|
||||
_json_response,
|
||||
_parse_activity,
|
||||
_strip_mention,
|
||||
_verify_hmac,
|
||||
)
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_bot(secret="", admins=None, operators=None, trusted=None,
|
||||
incoming_url=""):
|
||||
"""Create a TeamsBot with test config."""
|
||||
config = {
|
||||
"teams": {
|
||||
"enabled": True,
|
||||
"bot_name": "derp",
|
||||
"bind": "127.0.0.1",
|
||||
"port": 0,
|
||||
"webhook_secret": secret,
|
||||
"incoming_webhook_url": incoming_url,
|
||||
"admins": admins or [],
|
||||
"operators": operators or [],
|
||||
"trusted": trusted or [],
|
||||
},
|
||||
"bot": {
|
||||
"prefix": "!",
|
||||
"paste_threshold": 4,
|
||||
"plugins_dir": "plugins",
|
||||
"rate_limit": 2.0,
|
||||
"rate_burst": 5,
|
||||
},
|
||||
}
|
||||
registry = PluginRegistry()
|
||||
return TeamsBot("teams-test", config, registry)
|
||||
|
||||
|
||||
def _activity(text="hello", nick="Alice", aad_id="aad-123",
|
||||
conv_id="conv-456", msg_type="message"):
|
||||
"""Build a minimal Teams Activity dict."""
|
||||
return {
|
||||
"type": msg_type,
|
||||
"from": {"name": nick, "aadObjectId": aad_id},
|
||||
"conversation": {"id": conv_id},
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
def _teams_msg(text="!ping", nick="Alice", aad_id="aad-123",
|
||||
target="conv-456"):
|
||||
"""Create a TeamsMessage for command testing."""
|
||||
return TeamsMessage(
|
||||
raw={}, nick=nick, prefix=aad_id, text=text, target=target,
|
||||
params=[target, text],
|
||||
)
|
||||
|
||||
|
||||
def _sign_teams(secret: str, body: bytes) -> str:
|
||||
"""Generate Teams HMAC-SHA256 Authorization header value."""
|
||||
key = base64.b64decode(secret)
|
||||
sig = base64.b64encode(
|
||||
hmac.new(key, body, hashlib.sha256).digest(),
|
||||
).decode("ascii")
|
||||
return f"HMAC {sig}"
|
||||
|
||||
|
||||
class _FakeReader:
|
||||
"""Mock asyncio.StreamReader from raw HTTP bytes."""
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
|
||||
async def readline(self) -> bytes:
|
||||
start = self._pos
|
||||
idx = self._data.find(b"\n", start)
|
||||
if idx == -1:
|
||||
self._pos = len(self._data)
|
||||
return self._data[start:]
|
||||
self._pos = idx + 1
|
||||
return self._data[start:self._pos]
|
||||
|
||||
async def readexactly(self, n: int) -> bytes:
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += n
|
||||
return chunk
|
||||
|
||||
|
||||
class _FakeWriter:
|
||||
"""Mock asyncio.StreamWriter that captures output."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.data = b""
|
||||
self._closed = False
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self.data += data
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _build_request(method: str, path: str, body: bytes,
|
||||
headers: dict[str, str] | None = None) -> bytes:
|
||||
"""Build raw HTTP request bytes."""
|
||||
hdrs = headers or {}
|
||||
if "Content-Length" not in hdrs:
|
||||
hdrs["Content-Length"] = str(len(body))
|
||||
lines = [f"{method} {path} HTTP/1.1"]
|
||||
for k, v in hdrs.items():
|
||||
lines.append(f"{k}: {v}")
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
return "\r\n".join(lines).encode("utf-8") + body
|
||||
|
||||
|
||||
# -- Test helpers for registering commands -----------------------------------
|
||||
|
||||
|
||||
async def _echo_handler(bot, msg):
|
||||
"""Simple command handler that echoes text."""
|
||||
args = msg.text.split(None, 1)
|
||||
reply = args[1] if len(args) > 1 else "no args"
|
||||
await bot.reply(msg, reply)
|
||||
|
||||
|
||||
async def _admin_handler(bot, msg):
|
||||
"""Admin-only command handler."""
|
||||
await bot.reply(msg, "admin action done")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTeamsMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTeamsMessage:
|
||||
def test_defaults(self):
|
||||
msg = TeamsMessage(raw={}, nick=None, prefix=None, text=None,
|
||||
target=None)
|
||||
assert msg.is_channel is True
|
||||
assert msg.command == "PRIVMSG"
|
||||
assert msg.params == []
|
||||
assert msg.tags == {}
|
||||
assert msg._replies == []
|
||||
|
||||
def test_custom_values(self):
|
||||
msg = TeamsMessage(
|
||||
raw={"type": "message"}, nick="Alice", prefix="aad-123",
|
||||
text="hello", target="conv-456", is_channel=True,
|
||||
command="PRIVMSG", params=["conv-456", "hello"],
|
||||
tags={"key": "val"},
|
||||
)
|
||||
assert msg.nick == "Alice"
|
||||
assert msg.prefix == "aad-123"
|
||||
assert msg.text == "hello"
|
||||
assert msg.target == "conv-456"
|
||||
assert msg.tags == {"key": "val"}
|
||||
|
||||
def test_duck_type_compat(self):
|
||||
"""TeamsMessage has the same attribute names as IRC Message."""
|
||||
msg = _teams_msg()
|
||||
attrs = ["raw", "nick", "prefix", "text", "target",
|
||||
"is_channel", "command", "params", "tags"]
|
||||
for attr in attrs:
|
||||
assert hasattr(msg, attr), f"missing attribute: {attr}"
|
||||
|
||||
def test_replies_buffer(self):
|
||||
msg = _teams_msg()
|
||||
assert msg._replies == []
|
||||
msg._replies.append("pong")
|
||||
msg._replies.append("line2")
|
||||
assert len(msg._replies) == 2
|
||||
|
||||
def test_raw_dict(self):
|
||||
activity = {"type": "message", "id": "123"}
|
||||
msg = TeamsMessage(raw=activity, nick=None, prefix=None,
|
||||
text=None, target=None)
|
||||
assert msg.raw is activity
|
||||
|
||||
def test_prefix_is_aad_id(self):
|
||||
msg = _teams_msg(aad_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
|
||||
assert msg.prefix == "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestVerifyHmac
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVerifyHmac:
|
||||
def test_valid_signature(self):
|
||||
# base64-encoded secret
|
||||
secret = base64.b64encode(b"test-secret").decode()
|
||||
body = b'{"type":"message","text":"hello"}'
|
||||
auth = _sign_teams(secret, body)
|
||||
assert _verify_hmac(secret, body, auth) is True
|
||||
|
||||
def test_invalid_signature(self):
|
||||
secret = base64.b64encode(b"test-secret").decode()
|
||||
body = b'{"type":"message","text":"hello"}'
|
||||
assert _verify_hmac(secret, body, "HMAC badsignature") is False
|
||||
|
||||
def test_missing_hmac_prefix(self):
|
||||
secret = base64.b64encode(b"test-secret").decode()
|
||||
body = b'{"text":"hello"}'
|
||||
# No "HMAC " prefix
|
||||
key = base64.b64decode(secret)
|
||||
sig = base64.b64encode(
|
||||
hmac.new(key, body, hashlib.sha256).digest()
|
||||
).decode()
|
||||
assert _verify_hmac(secret, body, sig) is False
|
||||
|
||||
def test_empty_secret_allows_all(self):
|
||||
assert _verify_hmac("", b"any body", "") is True
|
||||
assert _verify_hmac("", b"any body", "HMAC whatever") is True
|
||||
|
||||
def test_invalid_base64_secret(self):
|
||||
assert _verify_hmac("not-valid-b64!!!", b"body", "HMAC x") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestStripMention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStripMention:
|
||||
def test_strip_at_mention(self):
|
||||
assert _strip_mention("<at>derp</at> !help", "derp") == "!help"
|
||||
|
||||
def test_strip_with_extra_spaces(self):
|
||||
assert _strip_mention("<at>derp</at> !ping", "derp") == "!ping"
|
||||
|
||||
def test_no_mention(self):
|
||||
assert _strip_mention("!help", "derp") == "!help"
|
||||
|
||||
def test_multiple_mentions(self):
|
||||
text = "<at>derp</at> hello <at>other</at> world"
|
||||
assert _strip_mention(text, "derp") == "hello world"
|
||||
|
||||
def test_empty_text(self):
|
||||
assert _strip_mention("", "derp") == ""
|
||||
|
||||
def test_mention_only(self):
|
||||
assert _strip_mention("<at>derp</at>", "derp") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestParseActivity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseActivity:
|
||||
def test_valid_activity(self):
|
||||
body = json.dumps({"type": "message", "text": "hello"}).encode()
|
||||
result = _parse_activity(body)
|
||||
assert result == {"type": "message", "text": "hello"}
|
||||
|
||||
def test_invalid_json(self):
|
||||
assert _parse_activity(b"not json") is None
|
||||
|
||||
def test_not_a_dict(self):
|
||||
assert _parse_activity(b'["array"]') is None
|
||||
|
||||
def test_empty_body(self):
|
||||
assert _parse_activity(b"") is None
|
||||
|
||||
def test_unicode_error(self):
|
||||
assert _parse_activity(b"\xff\xfe") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestBuildTeamsMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildTeamsMessage:
|
||||
def test_basic_message(self):
|
||||
activity = _activity(text="<at>derp</at> !ping")
|
||||
msg = _build_teams_message(activity, "derp")
|
||||
assert msg.nick == "Alice"
|
||||
assert msg.prefix == "aad-123"
|
||||
assert msg.text == "!ping"
|
||||
assert msg.target == "conv-456"
|
||||
assert msg.is_channel is True
|
||||
assert msg.command == "PRIVMSG"
|
||||
|
||||
def test_strips_mention(self):
|
||||
activity = _activity(text="<at>Bot</at> !help commands")
|
||||
msg = _build_teams_message(activity, "Bot")
|
||||
assert msg.text == "!help commands"
|
||||
|
||||
def test_missing_from(self):
|
||||
activity = {"type": "message", "text": "hello",
|
||||
"conversation": {"id": "conv"}}
|
||||
msg = _build_teams_message(activity, "derp")
|
||||
assert msg.nick is None
|
||||
assert msg.prefix is None
|
||||
|
||||
def test_missing_conversation(self):
|
||||
activity = {"type": "message", "text": "hello",
|
||||
"from": {"name": "Alice", "aadObjectId": "aad"}}
|
||||
msg = _build_teams_message(activity, "derp")
|
||||
assert msg.target is None
|
||||
|
||||
def test_raw_preserved(self):
|
||||
activity = _activity()
|
||||
msg = _build_teams_message(activity, "derp")
|
||||
assert msg.raw is activity
|
||||
|
||||
def test_params_populated(self):
|
||||
activity = _activity(text="<at>derp</at> !test arg")
|
||||
msg = _build_teams_message(activity, "derp")
|
||||
assert msg.params[0] == "conv-456"
|
||||
assert msg.params[1] == "!test arg"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTeamsBotReply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTeamsBotReply:
|
||||
def test_reply_appends(self):
|
||||
bot = _make_bot()
|
||||
msg = _teams_msg()
|
||||
asyncio.run(bot.reply(msg, "pong"))
|
||||
assert msg._replies == ["pong"]
|
||||
|
||||
def test_multi_reply(self):
|
||||
bot = _make_bot()
|
||||
msg = _teams_msg()
|
||||
|
||||
async def _run():
|
||||
await bot.reply(msg, "line 1")
|
||||
await bot.reply(msg, "line 2")
|
||||
await bot.reply(msg, "line 3")
|
||||
|
||||
asyncio.run(_run())
|
||||
assert msg._replies == ["line 1", "line 2", "line 3"]
|
||||
|
||||
def test_long_reply_under_threshold(self):
|
||||
bot = _make_bot()
|
||||
msg = _teams_msg()
|
||||
lines = ["a", "b", "c"]
|
||||
asyncio.run(bot.long_reply(msg, lines))
|
||||
assert msg._replies == ["a", "b", "c"]
|
||||
|
||||
def test_long_reply_over_threshold_no_paste(self):
|
||||
"""Over threshold with no FlaskPaste sends all lines."""
|
||||
bot = _make_bot()
|
||||
msg = _teams_msg()
|
||||
lines = ["a", "b", "c", "d", "e", "f"] # 6 > threshold of 4
|
||||
asyncio.run(bot.long_reply(msg, lines))
|
||||
assert msg._replies == lines
|
||||
|
||||
def test_long_reply_empty(self):
|
||||
bot = _make_bot()
|
||||
msg = _teams_msg()
|
||||
asyncio.run(bot.long_reply(msg, []))
|
||||
assert msg._replies == []
|
||||
|
||||
def test_action_format(self):
|
||||
"""action() maps to italic text via send()."""
|
||||
bot = _make_bot(incoming_url="http://example.com/hook")
|
||||
# action sends to incoming webhook; without actual URL it logs debug
|
||||
bot._incoming_url = ""
|
||||
asyncio.run(bot.action("conv", "does a thing"))
|
||||
# No incoming URL, so send() is a no-op (debug log)
|
||||
|
||||
def test_send_no_incoming_url(self):
|
||||
"""send() is a no-op when no incoming_webhook_url is configured."""
|
||||
bot = _make_bot()
|
||||
# Should not raise
|
||||
asyncio.run(bot.send("target", "text"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTeamsBotTier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTeamsBotTier:
|
||||
def test_admin_tier(self):
|
||||
bot = _make_bot(admins=["aad-admin"])
|
||||
msg = _teams_msg(aad_id="aad-admin")
|
||||
assert bot._get_tier(msg) == "admin"
|
||||
|
||||
def test_oper_tier(self):
|
||||
bot = _make_bot(operators=["aad-oper"])
|
||||
msg = _teams_msg(aad_id="aad-oper")
|
||||
assert bot._get_tier(msg) == "oper"
|
||||
|
||||
def test_trusted_tier(self):
|
||||
bot = _make_bot(trusted=["aad-trusted"])
|
||||
msg = _teams_msg(aad_id="aad-trusted")
|
||||
assert bot._get_tier(msg) == "trusted"
|
||||
|
||||
def test_user_tier_default(self):
|
||||
bot = _make_bot()
|
||||
msg = _teams_msg(aad_id="aad-unknown")
|
||||
assert bot._get_tier(msg) == "user"
|
||||
|
||||
def test_no_prefix(self):
|
||||
bot = _make_bot(admins=["aad-admin"])
|
||||
msg = _teams_msg(aad_id=None)
|
||||
msg.prefix = None
|
||||
assert bot._get_tier(msg) == "user"
|
||||
|
||||
def test_is_admin_true(self):
|
||||
bot = _make_bot(admins=["aad-admin"])
|
||||
msg = _teams_msg(aad_id="aad-admin")
|
||||
assert bot._is_admin(msg) is True
|
||||
|
||||
def test_is_admin_false(self):
|
||||
bot = _make_bot()
|
||||
msg = _teams_msg(aad_id="aad-nobody")
|
||||
assert bot._is_admin(msg) is False
|
||||
|
||||
def test_priority_order(self):
|
||||
"""Admin takes priority over oper and trusted."""
|
||||
bot = _make_bot(admins=["aad-x"], operators=["aad-x"],
|
||||
trusted=["aad-x"])
|
||||
msg = _teams_msg(aad_id="aad-x")
|
||||
assert bot._get_tier(msg) == "admin"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTeamsBotDispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTeamsBotDispatch:
|
||||
def test_dispatch_known_command(self):
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command(
|
||||
"echo", _echo_handler, help="echo", plugin="test")
|
||||
msg = _teams_msg(text="!echo world")
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert msg._replies == ["world"]
|
||||
|
||||
def test_dispatch_unknown_command(self):
|
||||
bot = _make_bot()
|
||||
msg = _teams_msg(text="!nonexistent")
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert msg._replies == []
|
||||
|
||||
def test_dispatch_no_prefix(self):
|
||||
bot = _make_bot()
|
||||
msg = _teams_msg(text="just a message")
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert msg._replies == []
|
||||
|
||||
def test_dispatch_empty_text(self):
|
||||
bot = _make_bot()
|
||||
msg = _teams_msg(text="")
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert msg._replies == []
|
||||
|
||||
def test_dispatch_none_text(self):
|
||||
bot = _make_bot()
|
||||
msg = _teams_msg(text=None)
|
||||
msg.text = None
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert msg._replies == []
|
||||
|
||||
def test_dispatch_ambiguous(self):
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command(
|
||||
"ping", _echo_handler, plugin="test")
|
||||
bot.registry.register_command(
|
||||
"plugins", _echo_handler, plugin="test")
|
||||
msg = _teams_msg(text="!p")
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert len(msg._replies) == 1
|
||||
assert "Ambiguous" in msg._replies[0]
|
||||
|
||||
def test_dispatch_tier_denied(self):
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command(
|
||||
"secret", _admin_handler, plugin="test", tier="admin")
|
||||
msg = _teams_msg(text="!secret", aad_id="aad-nobody")
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert len(msg._replies) == 1
|
||||
assert "Permission denied" in msg._replies[0]
|
||||
|
||||
def test_dispatch_tier_allowed(self):
|
||||
bot = _make_bot(admins=["aad-admin"])
|
||||
bot.registry.register_command(
|
||||
"secret", _admin_handler, plugin="test", tier="admin")
|
||||
msg = _teams_msg(text="!secret", aad_id="aad-admin")
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert msg._replies == ["admin action done"]
|
||||
|
||||
def test_dispatch_prefix_match(self):
|
||||
"""Unambiguous prefix resolves to the full command."""
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command(
|
||||
"echo", _echo_handler, plugin="test")
|
||||
msg = _teams_msg(text="!ec hello")
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert msg._replies == ["hello"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTeamsBotNoOps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTeamsBotNoOps:
|
||||
def test_join_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.join("#channel"))
|
||||
|
||||
def test_part_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.part("#channel", "reason"))
|
||||
|
||||
def test_kick_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.kick("#channel", "nick", "reason"))
|
||||
|
||||
def test_mode_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.mode("#channel", "+o", "nick"))
|
||||
|
||||
def test_set_topic_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.set_topic("#channel", "new topic"))
|
||||
|
||||
def test_quit_stops(self):
|
||||
bot = _make_bot()
|
||||
bot._running = True
|
||||
asyncio.run(bot.quit())
|
||||
assert bot._running is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestHTTPHandler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHTTPHandler:
|
||||
def _b64_secret(self):
|
||||
return base64.b64encode(b"test-secret-key").decode()
|
||||
|
||||
def test_valid_post_with_reply(self):
|
||||
secret = self._b64_secret()
|
||||
bot = _make_bot(secret=secret)
|
||||
bot.registry.register_command(
|
||||
"ping", _echo_handler, plugin="test")
|
||||
activity = _activity(text="<at>derp</at> !ping")
|
||||
body = json.dumps(activity).encode()
|
||||
auth = _sign_teams(secret, body)
|
||||
raw = _build_request("POST", "/api/messages", body, {
|
||||
"Content-Length": str(len(body)),
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth,
|
||||
})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(bot._handle_connection(reader, writer))
|
||||
assert b"200 OK" in writer.data
|
||||
resp_body = writer.data.split(b"\r\n\r\n", 1)[1]
|
||||
data = json.loads(resp_body)
|
||||
assert data["type"] == "message"
|
||||
|
||||
def test_get_405(self):
|
||||
bot = _make_bot()
|
||||
raw = _build_request("GET", "/api/messages", b"")
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(bot._handle_connection(reader, writer))
|
||||
assert b"405" in writer.data
|
||||
|
||||
def test_wrong_path_404(self):
|
||||
bot = _make_bot()
|
||||
raw = _build_request("POST", "/wrong/path", b"")
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(bot._handle_connection(reader, writer))
|
||||
assert b"404" in writer.data
|
||||
|
||||
def test_bad_signature_401(self):
|
||||
secret = self._b64_secret()
|
||||
bot = _make_bot(secret=secret)
|
||||
body = json.dumps(_activity()).encode()
|
||||
raw = _build_request("POST", "/api/messages", body, {
|
||||
"Content-Length": str(len(body)),
|
||||
"Authorization": "HMAC badsignature",
|
||||
})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(bot._handle_connection(reader, writer))
|
||||
assert b"401" in writer.data
|
||||
|
||||
def test_bad_json_400(self):
|
||||
bot = _make_bot()
|
||||
body = b"not json at all"
|
||||
raw = _build_request("POST", "/api/messages", body, {
|
||||
"Content-Length": str(len(body)),
|
||||
})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(bot._handle_connection(reader, writer))
|
||||
assert b"400" in writer.data
|
||||
assert b"invalid JSON" in writer.data
|
||||
|
||||
def test_non_message_activity(self):
|
||||
bot = _make_bot()
|
||||
body = json.dumps({"type": "conversationUpdate"}).encode()
|
||||
raw = _build_request("POST", "/api/messages", body, {
|
||||
"Content-Length": str(len(body)),
|
||||
})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(bot._handle_connection(reader, writer))
|
||||
assert b"200 OK" in writer.data
|
||||
resp_body = writer.data.split(b"\r\n\r\n", 1)[1]
|
||||
data = json.loads(resp_body)
|
||||
assert data["text"] == ""
|
||||
|
||||
def test_body_too_large_413(self):
|
||||
bot = _make_bot()
|
||||
raw = _build_request("POST", "/api/messages", b"", {
|
||||
"Content-Length": str(_MAX_BODY + 1),
|
||||
})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(bot._handle_connection(reader, writer))
|
||||
assert b"413" in writer.data
|
||||
|
||||
def test_command_dispatch_full_cycle(self):
|
||||
"""Full request lifecycle: receive, dispatch, reply."""
|
||||
bot = _make_bot()
|
||||
|
||||
async def _pong(b, m):
|
||||
await b.reply(m, "pong")
|
||||
|
||||
bot.registry.register_command("ping", _pong, plugin="test")
|
||||
activity = _activity(text="<at>derp</at> !ping")
|
||||
body = json.dumps(activity).encode()
|
||||
raw = _build_request("POST", "/api/messages", body, {
|
||||
"Content-Length": str(len(body)),
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(bot._handle_connection(reader, writer))
|
||||
assert b"200 OK" in writer.data
|
||||
resp_body = writer.data.split(b"\r\n\r\n", 1)[1]
|
||||
data = json.loads(resp_body)
|
||||
assert data["text"] == "pong"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestHttpResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHttpResponse:
|
||||
def test_plain_200(self):
|
||||
resp = _http_response(200, "OK", "sent")
|
||||
assert b"200 OK" in resp
|
||||
assert b"sent" in resp
|
||||
assert b"text/plain" in resp
|
||||
|
||||
def test_json_response(self):
|
||||
resp = _json_response(200, "OK", {"type": "message", "text": "hi"})
|
||||
assert b"200 OK" in resp
|
||||
assert b"application/json" in resp
|
||||
body = resp.split(b"\r\n\r\n", 1)[1]
|
||||
data = json.loads(body)
|
||||
assert data["text"] == "hi"
|
||||
|
||||
def test_404_response(self):
|
||||
resp = _http_response(404, "Not Found")
|
||||
assert b"404 Not Found" in resp
|
||||
assert b"Content-Length: 0" in resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTeamsBotPluginManagement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTeamsBotPluginManagement:
|
||||
def test_load_plugin_not_found(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.load_plugin("nonexistent_xyz")
|
||||
assert ok is False
|
||||
assert "not found" in msg
|
||||
|
||||
def test_load_plugin_already_loaded(self):
|
||||
bot = _make_bot()
|
||||
bot.registry._modules["test"] = object()
|
||||
ok, msg = bot.load_plugin("test")
|
||||
assert ok is False
|
||||
assert "already loaded" in msg
|
||||
|
||||
def test_unload_core_refused(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.unload_plugin("core")
|
||||
assert ok is False
|
||||
assert "cannot unload core" in msg
|
||||
|
||||
def test_unload_not_loaded(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.unload_plugin("nonexistent")
|
||||
assert ok is False
|
||||
assert "not loaded" in msg
|
||||
|
||||
def test_reload_delegates(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.reload_plugin("nonexistent")
|
||||
assert ok is False
|
||||
assert "not loaded" in msg
|
||||
|
||||
|
||||
class TestTeamsBotConfig:
|
||||
def test_proxy_default_true(self):
|
||||
bot = _make_bot()
|
||||
assert bot._proxy is True
|
||||
|
||||
def test_proxy_disabled(self):
|
||||
config = {
|
||||
"teams": {
|
||||
"enabled": True,
|
||||
"bot_name": "derp",
|
||||
"bind": "127.0.0.1",
|
||||
"port": 8081,
|
||||
"webhook_secret": "",
|
||||
"incoming_webhook_url": "",
|
||||
"proxy": False,
|
||||
"admins": [],
|
||||
"operators": [],
|
||||
"trusted": [],
|
||||
},
|
||||
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
|
||||
}
|
||||
bot = TeamsBot("test", config, PluginRegistry())
|
||||
assert bot._proxy is False
|
||||
786
tests/test_telegram.py
Normal file
786
tests/test_telegram.py
Normal file
@@ -0,0 +1,786 @@
|
||||
"""Tests for the Telegram adapter."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from derp.plugin import PluginRegistry
|
||||
from derp.telegram import (
|
||||
_MAX_MSG_LEN,
|
||||
TelegramBot,
|
||||
TelegramMessage,
|
||||
_build_telegram_message,
|
||||
_split_message,
|
||||
_strip_bot_suffix,
|
||||
)
|
||||
|
||||
# -- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_bot(token="test:token", admins=None, operators=None, trusted=None,
|
||||
prefix=None):
|
||||
"""Create a TelegramBot with test config."""
|
||||
config = {
|
||||
"telegram": {
|
||||
"enabled": True,
|
||||
"bot_token": token,
|
||||
"poll_timeout": 1,
|
||||
"admins": admins or [],
|
||||
"operators": operators or [],
|
||||
"trusted": trusted or [],
|
||||
},
|
||||
"bot": {
|
||||
"prefix": prefix or "!",
|
||||
"paste_threshold": 4,
|
||||
"plugins_dir": "plugins",
|
||||
"rate_limit": 2.0,
|
||||
"rate_burst": 5,
|
||||
},
|
||||
}
|
||||
registry = PluginRegistry()
|
||||
bot = TelegramBot("tg-test", config, registry)
|
||||
bot.nick = "TestBot"
|
||||
bot._bot_username = "testbot"
|
||||
return bot
|
||||
|
||||
|
||||
def _update(text="!ping", nick="Alice", user_id=123,
|
||||
chat_id=-456, chat_type="group", username="alice"):
|
||||
"""Build a minimal Telegram Update dict."""
|
||||
return {
|
||||
"update_id": 1000,
|
||||
"message": {
|
||||
"message_id": 1,
|
||||
"from": {
|
||||
"id": user_id,
|
||||
"first_name": nick,
|
||||
"username": username,
|
||||
},
|
||||
"chat": {
|
||||
"id": chat_id,
|
||||
"type": chat_type,
|
||||
},
|
||||
"text": text,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tg_msg(text="!ping", nick="Alice", user_id="123",
|
||||
target="-456", is_channel=True):
|
||||
"""Create a TelegramMessage for command testing."""
|
||||
return TelegramMessage(
|
||||
raw={}, nick=nick, prefix=user_id, text=text, target=target,
|
||||
is_channel=is_channel,
|
||||
params=[target, text],
|
||||
)
|
||||
|
||||
|
||||
# -- Test helpers for registering commands -----------------------------------
|
||||
|
||||
|
||||
async def _echo_handler(bot, msg):
|
||||
"""Simple command handler that echoes text."""
|
||||
args = msg.text.split(None, 1)
|
||||
reply = args[1] if len(args) > 1 else "no args"
|
||||
await bot.reply(msg, reply)
|
||||
|
||||
|
||||
async def _admin_handler(bot, msg):
|
||||
"""Admin-only command handler."""
|
||||
await bot.reply(msg, "admin action done")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTelegramMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTelegramMessage:
|
||||
def test_defaults(self):
|
||||
msg = TelegramMessage(raw={}, nick=None, prefix=None, text=None,
|
||||
target=None)
|
||||
assert msg.is_channel is True
|
||||
assert msg.command == "PRIVMSG"
|
||||
assert msg.params == []
|
||||
assert msg.tags == {}
|
||||
|
||||
def test_custom_values(self):
|
||||
msg = TelegramMessage(
|
||||
raw={"update_id": 1}, nick="Alice", prefix="123",
|
||||
text="hello", target="-456", is_channel=True,
|
||||
command="PRIVMSG", params=["-456", "hello"],
|
||||
tags={"key": "val"},
|
||||
)
|
||||
assert msg.nick == "Alice"
|
||||
assert msg.prefix == "123"
|
||||
assert msg.text == "hello"
|
||||
assert msg.target == "-456"
|
||||
assert msg.tags == {"key": "val"}
|
||||
|
||||
def test_duck_type_compat(self):
|
||||
"""TelegramMessage has the same attribute names as IRC Message."""
|
||||
msg = _tg_msg()
|
||||
attrs = ["raw", "nick", "prefix", "text", "target",
|
||||
"is_channel", "command", "params", "tags"]
|
||||
for attr in attrs:
|
||||
assert hasattr(msg, attr), f"missing attribute: {attr}"
|
||||
|
||||
def test_dm_message(self):
|
||||
msg = _tg_msg(is_channel=False)
|
||||
assert msg.is_channel is False
|
||||
|
||||
def test_prefix_is_user_id(self):
|
||||
msg = _tg_msg(user_id="999888777")
|
||||
assert msg.prefix == "999888777"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestBuildTelegramMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildTelegramMessage:
|
||||
def test_group_message(self):
|
||||
update = _update(text="!ping", chat_type="group")
|
||||
msg = _build_telegram_message(update, "testbot")
|
||||
assert msg is not None
|
||||
assert msg.nick == "Alice"
|
||||
assert msg.prefix == "123"
|
||||
assert msg.text == "!ping"
|
||||
assert msg.target == "-456"
|
||||
assert msg.is_channel is True
|
||||
|
||||
def test_dm_message(self):
|
||||
update = _update(chat_type="private", chat_id=789)
|
||||
msg = _build_telegram_message(update, "testbot")
|
||||
assert msg is not None
|
||||
assert msg.is_channel is False
|
||||
assert msg.target == "789"
|
||||
|
||||
def test_supergroup_message(self):
|
||||
update = _update(chat_type="supergroup")
|
||||
msg = _build_telegram_message(update, "testbot")
|
||||
assert msg is not None
|
||||
assert msg.is_channel is True
|
||||
|
||||
def test_missing_from(self):
|
||||
update = {"update_id": 1, "message": {
|
||||
"message_id": 1,
|
||||
"chat": {"id": -456, "type": "group"},
|
||||
"text": "hello",
|
||||
}}
|
||||
msg = _build_telegram_message(update, "testbot")
|
||||
assert msg is not None
|
||||
assert msg.nick is None
|
||||
assert msg.prefix is None
|
||||
|
||||
def test_missing_text(self):
|
||||
update = {"update_id": 1, "message": {
|
||||
"message_id": 1,
|
||||
"from": {"id": 123, "first_name": "Alice"},
|
||||
"chat": {"id": -456, "type": "group"},
|
||||
}}
|
||||
msg = _build_telegram_message(update, "testbot")
|
||||
assert msg is not None
|
||||
assert msg.text == ""
|
||||
|
||||
def test_no_message(self):
|
||||
update = {"update_id": 1}
|
||||
msg = _build_telegram_message(update, "testbot")
|
||||
assert msg is None
|
||||
|
||||
def test_strips_bot_suffix(self):
|
||||
update = _update(text="!help@testbot")
|
||||
msg = _build_telegram_message(update, "testbot")
|
||||
assert msg is not None
|
||||
assert msg.text == "!help"
|
||||
|
||||
def test_edited_message(self):
|
||||
update = {
|
||||
"update_id": 1,
|
||||
"edited_message": {
|
||||
"message_id": 1,
|
||||
"from": {"id": 123, "first_name": "Alice"},
|
||||
"chat": {"id": -456, "type": "group"},
|
||||
"text": "!ping",
|
||||
},
|
||||
}
|
||||
msg = _build_telegram_message(update, "testbot")
|
||||
assert msg is not None
|
||||
assert msg.text == "!ping"
|
||||
|
||||
def test_raw_preserved(self):
|
||||
update = _update()
|
||||
msg = _build_telegram_message(update, "testbot")
|
||||
assert msg.raw is update
|
||||
|
||||
def test_username_fallback_for_nick(self):
|
||||
update = _update()
|
||||
# Remove first_name, keep username
|
||||
update["message"]["from"] = {"id": 123, "username": "alice_u"}
|
||||
msg = _build_telegram_message(update, "testbot")
|
||||
assert msg.nick == "alice_u"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestStripBotSuffix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStripBotSuffix:
|
||||
def test_strip_command(self):
|
||||
assert _strip_bot_suffix("!help@mybot", "mybot") == "!help"
|
||||
|
||||
def test_strip_with_args(self):
|
||||
assert _strip_bot_suffix("!echo@mybot hello", "mybot") == "!echo hello"
|
||||
|
||||
def test_no_suffix(self):
|
||||
assert _strip_bot_suffix("!help", "mybot") == "!help"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _strip_bot_suffix("!help@MyBot", "mybot") == "!help"
|
||||
|
||||
def test_empty_username(self):
|
||||
assert _strip_bot_suffix("!help@bot", "") == "!help@bot"
|
||||
|
||||
def test_plain_text(self):
|
||||
assert _strip_bot_suffix("hello world", "mybot") == "hello world"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTelegramBotReply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTelegramBotReply:
|
||||
def test_send_calls_api(self):
|
||||
bot = _make_bot()
|
||||
with patch.object(bot, "_api_call", return_value={"ok": True}):
|
||||
asyncio.run(bot.send("-456", "hello"))
|
||||
bot._api_call.assert_called_once_with(
|
||||
"sendMessage", {"chat_id": "-456", "text": "hello"})
|
||||
|
||||
def test_reply_sends_to_target(self):
|
||||
bot = _make_bot()
|
||||
msg = _tg_msg(target="-456")
|
||||
sent: list[tuple[str, str]] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append((target, text))
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot.reply(msg, "pong"))
|
||||
assert sent == [("-456", "pong")]
|
||||
|
||||
def test_reply_no_target(self):
|
||||
bot = _make_bot()
|
||||
msg = _tg_msg(target=None)
|
||||
msg.target = None
|
||||
with patch.object(bot, "send") as mock_send:
|
||||
asyncio.run(bot.reply(msg, "pong"))
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_long_reply_under_threshold(self):
|
||||
bot = _make_bot()
|
||||
msg = _tg_msg()
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot.long_reply(msg, ["a", "b", "c"]))
|
||||
assert sent == ["a", "b", "c"]
|
||||
|
||||
def test_long_reply_over_threshold_no_paste(self):
|
||||
bot = _make_bot()
|
||||
msg = _tg_msg()
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot.long_reply(msg, ["a", "b", "c", "d", "e"]))
|
||||
assert sent == ["a", "b", "c", "d", "e"]
|
||||
|
||||
def test_long_reply_empty(self):
|
||||
bot = _make_bot()
|
||||
msg = _tg_msg()
|
||||
with patch.object(bot, "send") as mock_send:
|
||||
asyncio.run(bot.long_reply(msg, []))
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_action_format(self):
|
||||
bot = _make_bot()
|
||||
sent: list[tuple[str, str]] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append((target, text))
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot.action("-456", "does a thing"))
|
||||
assert sent == [("-456", "_does a thing_")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTelegramBotDispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTelegramBotDispatch:
|
||||
def test_dispatch_known_command(self):
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command(
|
||||
"echo", _echo_handler, help="echo", plugin="test")
|
||||
msg = _tg_msg(text="!echo world")
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert sent == ["world"]
|
||||
|
||||
def test_dispatch_unknown_command(self):
|
||||
bot = _make_bot()
|
||||
msg = _tg_msg(text="!nonexistent")
|
||||
with patch.object(bot, "send") as mock_send:
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_dispatch_no_prefix(self):
|
||||
bot = _make_bot()
|
||||
msg = _tg_msg(text="just a message")
|
||||
with patch.object(bot, "send") as mock_send:
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_dispatch_empty_text(self):
|
||||
bot = _make_bot()
|
||||
msg = _tg_msg(text="")
|
||||
with patch.object(bot, "send") as mock_send:
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_dispatch_none_text(self):
|
||||
bot = _make_bot()
|
||||
msg = _tg_msg()
|
||||
msg.text = None
|
||||
with patch.object(bot, "send") as mock_send:
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
mock_send.assert_not_called()
|
||||
|
||||
def test_dispatch_ambiguous(self):
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command("ping", _echo_handler, plugin="test")
|
||||
bot.registry.register_command("plugins", _echo_handler, plugin="test")
|
||||
msg = _tg_msg(text="!p")
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert len(sent) == 1
|
||||
assert "Ambiguous" in sent[0]
|
||||
|
||||
def test_dispatch_tier_denied(self):
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command(
|
||||
"secret", _admin_handler, plugin="test", tier="admin")
|
||||
msg = _tg_msg(text="!secret", user_id="999")
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert len(sent) == 1
|
||||
assert "Permission denied" in sent[0]
|
||||
|
||||
def test_dispatch_tier_allowed(self):
|
||||
bot = _make_bot(admins=[123])
|
||||
bot.registry.register_command(
|
||||
"secret", _admin_handler, plugin="test", tier="admin")
|
||||
msg = _tg_msg(text="!secret", user_id="123")
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert sent == ["admin action done"]
|
||||
|
||||
def test_dispatch_prefix_match(self):
|
||||
bot = _make_bot()
|
||||
bot.registry.register_command("echo", _echo_handler, plugin="test")
|
||||
msg = _tg_msg(text="!ec hello")
|
||||
sent: list[str] = []
|
||||
|
||||
async def _fake_send(target, text):
|
||||
sent.append(text)
|
||||
|
||||
with patch.object(bot, "send", side_effect=_fake_send):
|
||||
asyncio.run(bot._dispatch_command(msg))
|
||||
assert sent == ["hello"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTelegramBotTier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTelegramBotTier:
|
||||
def test_admin_tier(self):
|
||||
bot = _make_bot(admins=[111])
|
||||
msg = _tg_msg(user_id="111")
|
||||
assert bot._get_tier(msg) == "admin"
|
||||
|
||||
def test_oper_tier(self):
|
||||
bot = _make_bot(operators=[222])
|
||||
msg = _tg_msg(user_id="222")
|
||||
assert bot._get_tier(msg) == "oper"
|
||||
|
||||
def test_trusted_tier(self):
|
||||
bot = _make_bot(trusted=[333])
|
||||
msg = _tg_msg(user_id="333")
|
||||
assert bot._get_tier(msg) == "trusted"
|
||||
|
||||
def test_user_tier_default(self):
|
||||
bot = _make_bot()
|
||||
msg = _tg_msg(user_id="999")
|
||||
assert bot._get_tier(msg) == "user"
|
||||
|
||||
def test_no_prefix(self):
|
||||
bot = _make_bot(admins=[111])
|
||||
msg = _tg_msg()
|
||||
msg.prefix = None
|
||||
assert bot._get_tier(msg) == "user"
|
||||
|
||||
def test_is_admin_true(self):
|
||||
bot = _make_bot(admins=[111])
|
||||
msg = _tg_msg(user_id="111")
|
||||
assert bot._is_admin(msg) is True
|
||||
|
||||
def test_is_admin_false(self):
|
||||
bot = _make_bot()
|
||||
msg = _tg_msg(user_id="999")
|
||||
assert bot._is_admin(msg) is False
|
||||
|
||||
def test_priority_order(self):
|
||||
"""Admin takes priority over oper and trusted."""
|
||||
bot = _make_bot(admins=[111], operators=[111], trusted=[111])
|
||||
msg = _tg_msg(user_id="111")
|
||||
assert bot._get_tier(msg) == "admin"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTelegramBotNoOps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTelegramBotNoOps:
|
||||
def test_join_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.join("#channel"))
|
||||
|
||||
def test_part_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.part("#channel", "reason"))
|
||||
|
||||
def test_kick_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.kick("#channel", "nick", "reason"))
|
||||
|
||||
def test_mode_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.mode("#channel", "+o", "nick"))
|
||||
|
||||
def test_set_topic_noop(self):
|
||||
bot = _make_bot()
|
||||
asyncio.run(bot.set_topic("#channel", "new topic"))
|
||||
|
||||
def test_quit_stops(self):
|
||||
bot = _make_bot()
|
||||
bot._running = True
|
||||
asyncio.run(bot.quit())
|
||||
assert bot._running is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTelegramBotPoll
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTelegramBotPoll:
|
||||
def test_poll_updates_parses(self):
|
||||
bot = _make_bot()
|
||||
result = {
|
||||
"ok": True,
|
||||
"result": [
|
||||
{"update_id": 100, "message": {
|
||||
"message_id": 1,
|
||||
"from": {"id": 123, "first_name": "Alice"},
|
||||
"chat": {"id": -456, "type": "group"},
|
||||
"text": "hello",
|
||||
}},
|
||||
],
|
||||
}
|
||||
with patch.object(bot, "_api_call", return_value=result):
|
||||
updates = bot._poll_updates()
|
||||
assert len(updates) == 1
|
||||
assert bot._offset == 101
|
||||
|
||||
def test_poll_updates_empty(self):
|
||||
bot = _make_bot()
|
||||
with patch.object(bot, "_api_call",
|
||||
return_value={"ok": True, "result": []}):
|
||||
updates = bot._poll_updates()
|
||||
assert updates == []
|
||||
assert bot._offset == 0
|
||||
|
||||
def test_poll_updates_failed(self):
|
||||
bot = _make_bot()
|
||||
with patch.object(bot, "_api_call",
|
||||
return_value={"ok": False, "description": "err"}):
|
||||
updates = bot._poll_updates()
|
||||
assert updates == []
|
||||
|
||||
def test_offset_advances(self):
|
||||
bot = _make_bot()
|
||||
result = {
|
||||
"ok": True,
|
||||
"result": [
|
||||
{"update_id": 50, "message": {
|
||||
"message_id": 1,
|
||||
"from": {"id": 1, "first_name": "A"},
|
||||
"chat": {"id": -1, "type": "group"},
|
||||
"text": "a",
|
||||
}},
|
||||
{"update_id": 51, "message": {
|
||||
"message_id": 2,
|
||||
"from": {"id": 2, "first_name": "B"},
|
||||
"chat": {"id": -2, "type": "group"},
|
||||
"text": "b",
|
||||
}},
|
||||
],
|
||||
}
|
||||
with patch.object(bot, "_api_call", return_value=result):
|
||||
bot._poll_updates()
|
||||
assert bot._offset == 52
|
||||
|
||||
def test_start_getme_failure(self):
|
||||
config = {
|
||||
"telegram": {
|
||||
"enabled": True, "bot_token": "t", "poll_timeout": 1,
|
||||
"admins": [], "operators": [], "trusted": [],
|
||||
},
|
||||
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
|
||||
}
|
||||
bot = TelegramBot("tg-test", config, PluginRegistry())
|
||||
with patch.object(bot, "_api_call",
|
||||
return_value={"ok": False}):
|
||||
asyncio.run(bot.start())
|
||||
assert bot.nick == ""
|
||||
|
||||
def test_start_getme_exception(self):
|
||||
config = {
|
||||
"telegram": {
|
||||
"enabled": True, "bot_token": "t", "poll_timeout": 1,
|
||||
"admins": [], "operators": [], "trusted": [],
|
||||
},
|
||||
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
|
||||
}
|
||||
bot = TelegramBot("tg-test", config, PluginRegistry())
|
||||
with patch.object(bot, "_api_call",
|
||||
side_effect=Exception("network")):
|
||||
asyncio.run(bot.start())
|
||||
assert bot.nick == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTelegramApiCall
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTelegramApiCall:
|
||||
def test_api_url(self):
|
||||
bot = _make_bot(token="123:ABC")
|
||||
url = bot._api_url("getMe")
|
||||
assert url == "https://api.telegram.org/bot123:ABC/getMe"
|
||||
|
||||
def test_api_call_get(self):
|
||||
bot = _make_bot()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'{"ok": true, "result": {}}'
|
||||
with patch("derp.telegram.http.urlopen", return_value=mock_resp):
|
||||
result = bot._api_call("getMe")
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_api_call_post(self):
|
||||
bot = _make_bot()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'{"ok": true, "result": {}}'
|
||||
with patch("derp.telegram.http.urlopen", return_value=mock_resp):
|
||||
result = bot._api_call("sendMessage", {"chat_id": "1", "text": "hi"})
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_split_long_message(self):
|
||||
# Build a message that exceeds 4096 bytes
|
||||
lines = [f"line {i}: {'x' * 100}" for i in range(50)]
|
||||
text = "\n".join(lines)
|
||||
chunks = _split_message(text)
|
||||
assert len(chunks) > 1
|
||||
for chunk in chunks:
|
||||
assert len(chunk.encode("utf-8")) <= _MAX_MSG_LEN
|
||||
|
||||
def test_short_message_no_split(self):
|
||||
chunks = _split_message("hello world")
|
||||
assert chunks == ["hello world"]
|
||||
|
||||
def test_send_splits_long_text(self):
|
||||
bot = _make_bot()
|
||||
lines = [f"line {i}: {'x' * 100}" for i in range(50)]
|
||||
text = "\n".join(lines)
|
||||
calls: list[dict] = []
|
||||
|
||||
def _fake_api_call(method, payload=None):
|
||||
if method == "sendMessage" and payload:
|
||||
calls.append(payload)
|
||||
return {"ok": True}
|
||||
|
||||
with patch.object(bot, "_api_call", side_effect=_fake_api_call):
|
||||
asyncio.run(bot.send("-456", text))
|
||||
assert len(calls) > 1
|
||||
for call in calls:
|
||||
assert len(call["text"].encode("utf-8")) <= _MAX_MSG_LEN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestPluginManagement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPluginManagement:
|
||||
def test_load_plugin_not_found(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.load_plugin("nonexistent_xyz")
|
||||
assert ok is False
|
||||
assert "not found" in msg
|
||||
|
||||
def test_load_plugin_already_loaded(self):
|
||||
bot = _make_bot()
|
||||
bot.registry._modules["test"] = object()
|
||||
ok, msg = bot.load_plugin("test")
|
||||
assert ok is False
|
||||
assert "already loaded" in msg
|
||||
|
||||
def test_unload_core_refused(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.unload_plugin("core")
|
||||
assert ok is False
|
||||
assert "cannot unload core" in msg
|
||||
|
||||
def test_unload_not_loaded(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.unload_plugin("nonexistent")
|
||||
assert ok is False
|
||||
assert "not loaded" in msg
|
||||
|
||||
def test_reload_delegates(self):
|
||||
bot = _make_bot()
|
||||
ok, msg = bot.reload_plugin("nonexistent")
|
||||
assert ok is False
|
||||
assert "not loaded" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSplitMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSplitMessage:
|
||||
def test_short_text(self):
|
||||
assert _split_message("hi") == ["hi"]
|
||||
|
||||
def test_exact_boundary(self):
|
||||
text = "a" * 4096
|
||||
result = _split_message(text)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_multi_line_split(self):
|
||||
lines = ["line " + str(i) for i in range(1000)]
|
||||
text = "\n".join(lines)
|
||||
chunks = _split_message(text)
|
||||
assert len(chunks) > 1
|
||||
reassembled = "\n".join(chunks)
|
||||
assert reassembled == text
|
||||
|
||||
def test_empty_text(self):
|
||||
assert _split_message("") == [""]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTelegramBotConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTelegramBotConfig:
|
||||
def test_prefix_from_telegram_section(self):
|
||||
config = {
|
||||
"telegram": {
|
||||
"enabled": True,
|
||||
"bot_token": "t",
|
||||
"poll_timeout": 1,
|
||||
"prefix": "/",
|
||||
"admins": [],
|
||||
"operators": [],
|
||||
"trusted": [],
|
||||
},
|
||||
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
|
||||
}
|
||||
bot = TelegramBot("test", config, PluginRegistry())
|
||||
assert bot.prefix == "/"
|
||||
|
||||
def test_prefix_falls_back_to_bot(self):
|
||||
config = {
|
||||
"telegram": {
|
||||
"enabled": True,
|
||||
"bot_token": "t",
|
||||
"poll_timeout": 1,
|
||||
"admins": [],
|
||||
"operators": [],
|
||||
"trusted": [],
|
||||
},
|
||||
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
|
||||
}
|
||||
bot = TelegramBot("test", config, PluginRegistry())
|
||||
assert bot.prefix == "!"
|
||||
|
||||
def test_admins_coerced_to_str(self):
|
||||
bot = _make_bot(admins=[111, 222])
|
||||
assert bot._admins == ["111", "222"]
|
||||
|
||||
def test_proxy_default_true(self):
|
||||
bot = _make_bot()
|
||||
assert bot._proxy is True
|
||||
|
||||
def test_proxy_disabled(self):
|
||||
config = {
|
||||
"telegram": {
|
||||
"enabled": True,
|
||||
"bot_token": "t",
|
||||
"poll_timeout": 1,
|
||||
"proxy": False,
|
||||
"admins": [],
|
||||
"operators": [],
|
||||
"trusted": [],
|
||||
},
|
||||
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
|
||||
}
|
||||
bot = TelegramBot("test", config, PluginRegistry())
|
||||
assert bot._proxy is False
|
||||
@@ -19,16 +19,14 @@ _spec.loader.exec_module(_mod)
|
||||
from plugins.twitch import ( # noqa: E402
|
||||
_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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user