# NtR IRC Bot Plugins — Design Document > **Date**: 2026-03-12 > **Status**: Approved ## Purpose Two functionally-identical IRC bot plugins (Sopel and Limnoria) that query the NtR SoundCloud Fetcher API to serve playlist and track information in IRC channels. ## Approach Fully independent plugins with no shared code. Each plugin is a self-contained unit you drop into the bot's plugin directory. Both follow identical internal structure and function naming so a diff between them shows only framework-specific glue. ## Directory Layout ``` plugins/ ├── sopel/ │ └── ntr_playlist.py # Single-file Sopel plugin └── limnoria/ └── NtrPlaylist/ # Limnoria package plugin ├── __init__.py # Plugin metadata ├── config.py # Registry values ├── plugin.py # Command handlers └── test.py # Test stub ``` Sopel: copy `ntr_playlist.py` into `~/.sopel/plugins/`. Limnoria: copy `NtrPlaylist/` into `limnoria-bot/plugins/`. ## Commands | Trigger | API Call | Auth | Output | |---------|----------|------|--------| | `!1`, `!2`, ... `!N` | `GET /playlist/{n}` | None | Single track | | `!song ` | `GET /shows/by-episode/{episode}` → filter by position | None | Single track | | `!refresh` | `POST /admin/refresh` (bearer token) | Admin nicks | Refresh result | | `!status` | `GET /health` | None | Full health info | | `!playlist` | `GET /playlist` | None | Comma-separated track list | | `!playlist ` | `GET /shows/by-episode/{episode}` | None | Comma-separated track list | ### Number commands (`!1`, `!2`, etc.) Neither framework natively registers bare-digit commands. Both plugins use a regex/rule pattern to match messages starting with `!` followed by one or more digits and nothing else. - Sopel: `@plugin.rule(r'^!(\d+)$')` - Limnoria: `doPrivmsg` override checking against `r'^!(\d+)$'` ### Output Formats **Single track** (`!N`, `!song`): ``` Song #3: Night Drive by SomeArtist - https://soundcloud.com/someartist/night-drive ``` **Playlist** (`!playlist`): ``` Episode 530 (9 tracks): Night Drive by SomeArtist, Running Through My Mind by Purrple Panther, ... ``` **Status** (`!status`): ``` Status: OK | Poller: alive | Last fetch: 2026-03-12T02:00:00+00:00 | Tracks this week: 9 ``` **Refresh** (`!refresh`): ``` Refreshed — 9 tracks ``` **Errors** (pass through API detail): ``` No track at position 15 ``` ## Configuration Both plugins expose the same three settings: | Setting | Type | Default | Description | |---------|------|---------|-------------| | `api_base_url` | string | `http://127.0.0.1:8000` | NtR API base URL (no trailing slash) | | `admin_token` | string | *(empty)* | Bearer token for `POST /admin/refresh` | | `admin_nicks` | list of strings | *(empty)* | IRC nicknames allowed to run `!refresh` | ### Sopel (INI) ```ini [ntr_playlist] api_base_url = http://127.0.0.1:8000 admin_token = secret-token-here admin_nicks = NicktheRat SomeOtherAdmin ``` Defined via `StaticSection` with `ValidatedAttribute` and `ListAttribute`. ### Limnoria (registry) ``` config plugins.NtrPlaylist.apiBaseUrl http://127.0.0.1:8000 config plugins.NtrPlaylist.adminToken secret-token-here config plugins.NtrPlaylist.adminNicks NicktheRat SomeOtherAdmin ``` `adminToken` marked `private=True`. `adminNicks` uses `SpaceSeparatedListOfStrings`. ### Admin Check Compare the triggering user's IRC nickname against `admin_nicks`, case-insensitive. Framework-level admin/owner systems are not used. This keeps admin management identical and portable between plugins. ## HTTP Client Both plugins use `urllib.request` from stdlib — no external dependencies beyond the bot framework. ### Internal Helpers **`_api_get(base_url, path)`** → `dict` - `GET {base_url}{path}` with `Accept: application/json` - 10-second timeout - On success: parse and return JSON - On HTTP error: parse JSON error body, raise `ApiError(status_code, detail)` - On connection failure: raise `ApiError(0, "Cannot reach API")` **`_api_post(base_url, path, token, body=None)`** → `dict` - Same as above but `POST` with `Authorization: Bearer {token}` and `Content-Type: application/json` - Used only by `!refresh` ### Error Handling ``` try: data = _api_get(base_url, f"/playlist/{position}") reply with formatted track except ApiError as e: reply with e.detail ``` No retries at the plugin level. The API is local; IRC users expect immediate responses. If the API is down, the user sees "Cannot reach API". No async concerns — both frameworks run command handlers synchronously. A blocking 10-second timeout is acceptable. ## Design Decisions | Decision | Rationale | |----------|-----------| | Independent plugins, no shared code | Each plugin is one self-contained directory. No deployment headaches with shared imports. | | Regex for `!N` commands | Frameworks don't support bare-digit command names. Regex catch-all is the cleanest solution. | | Plugin-config admin nicks only | Portable between frameworks. No coupling to Sopel's `core.admins` or Limnoria's capability system. | | No caching | API is local, responses are fast. Simplifies code and guarantees fresh data. | | stdlib `urllib.request` | Zero external dependencies. Both frameworks already have Python 3.11+. | | `!song` fetches full show, filters client-side | API has no single-track-by-episode endpoint. The response is small enough that client-side filtering is fine. | ## Dependencies None beyond the bot framework itself and Python 3.11+ stdlib.