Files
NtR-soudcloud-fetcher/docs/plans/2026-03-12-irc-plugins-design.md
2026-03-12 03:20:45 -04:00

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: 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)

[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.