5.5 KiB
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 <episode> <position> |
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 <episode> |
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:
doPrivmsgoverride checking againstr'^!(\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)
[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}withAccept: 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
POSTwithAuthorization: Bearer {token}andContent-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.