feat: add human-readable datetime formatting and !lastshow command

IRC plugins now format datetimes as "Wed Mar 11, 10:00 PM EDT" instead
of raw ISO 8601. Configurable timezone defaults to America/New_York.

Adds !lastshow N command to both Limnoria and Sopel plugins, returning
track N from the previous week's show via existing API endpoints.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-12 05:31:50 -04:00
parent ae66242935
commit 9664b8225d
4 changed files with 167 additions and 2 deletions

View File

@@ -34,3 +34,12 @@ conf.registerGlobalValue(
"""IRC nicknames allowed to run admin commands (space-separated).""",
),
)
conf.registerGlobalValue(
NtrPlaylist,
"displayTimezone",
registry.String(
"America/New_York",
"""IANA timezone for displaying dates in IRC (e.g. America/New_York, America/Chicago).""",
),
)

View File

@@ -8,6 +8,8 @@ import logging
import re
import urllib.error
import urllib.request
from datetime import datetime
from zoneinfo import ZoneInfo
from supybot import callbacks
from supybot.commands import optional, wrap
@@ -73,6 +75,14 @@ def _api_post(base_url: str, path: str, token: str, body: dict | None = None) ->
# --- Formatting --------------------------------------------------------------
def format_dt(iso_string: str | None, tz_name: str = "America/New_York") -> str:
if not iso_string:
return "never"
dt = datetime.fromisoformat(iso_string)
local = dt.astimezone(ZoneInfo(tz_name))
return local.strftime("%a %b %-d, %-I:%M %p %Z")
_MAX_IRC_LINE = 430
@@ -195,6 +205,45 @@ class NtrPlaylist(callbacks.Plugin):
return
irc.reply(format_playlist(data))
@wrap([optional("text")])
def lastshow(self, irc, msg, args, text):
"""<position>
Returns a track from last week's show by position number.
"""
if not text or not text.strip():
irc.reply("Usage: !lastshow <position>")
return
try:
position = int(text.strip())
except ValueError:
irc.reply("Usage: !lastshow <position>")
return
base_url = self.registryValue("apiBaseUrl")
try:
shows = _api_get(base_url, "/shows?limit=2")
except ApiError as exc:
LOGGER.warning("API error for lastshow: %s", exc)
irc.reply(exc.detail)
return
if len(shows) < 2:
irc.reply("No previous show found")
return
prev_show_id = shows[1]["id"]
try:
data = _api_get(base_url, f"/shows/{prev_show_id}")
except ApiError as exc:
LOGGER.warning("API error for lastshow show %s: %s", prev_show_id, exc)
irc.reply(exc.detail)
return
tracks = data.get("tracks", [])
track = next((t for t in tracks if t.get("position") == position), None)
if not track:
episode = data.get("episode_number", "?")
irc.reply(f"No track at position {position} in episode {episode}")
return
irc.reply(format_track(track))
@wrap
def status(self, irc, msg, args):
"""takes no arguments
@@ -208,9 +257,10 @@ class NtrPlaylist(callbacks.Plugin):
LOGGER.warning("API error for status: %s", exc)
irc.reply(exc.detail)
return
tz = self.registryValue("displayTimezone")
status = data.get("status", "unknown")
poller = "alive" if data.get("poller_alive") else "dead"
last_fetch = data.get("last_fetch") or "never"
last_fetch = format_dt(data.get("last_fetch"), tz)
count = data.get("current_week_track_count", 0)
irc.reply(
f"Status: {status.upper()} | Poller: {poller} | Last fetch: {last_fetch} | Tracks this week: {count}"

View File

@@ -7,6 +7,8 @@ import json
import logging
import urllib.error
import urllib.request
from datetime import datetime
from zoneinfo import ZoneInfo
from sopel import plugin
from sopel.config import types
@@ -18,6 +20,7 @@ class NtrPlaylistSection(types.StaticSection):
api_base_url = types.ValidatedAttribute("api_base_url", default="http://127.0.0.1:8000")
admin_token = types.ValidatedAttribute("admin_token", default="")
admin_nicks = types.ListAttribute("admin_nicks")
display_timezone = types.ValidatedAttribute("display_timezone", default="America/New_York")
def setup(bot):
@@ -83,6 +86,14 @@ def _api_post(base_url: str, path: str, token: str, body: dict | None = None) ->
raise ApiError(0, "Cannot reach API") from e
def format_dt(iso_string: str | None, tz_name: str = "America/New_York") -> str:
if not iso_string:
return "never"
dt = datetime.fromisoformat(iso_string)
local = dt.astimezone(ZoneInfo(tz_name))
return local.strftime("%a %b %-d, %-I:%M %p %Z")
def format_track(track: dict) -> str:
pos = track.get("position", 0)
title = track.get("title", "")
@@ -188,6 +199,43 @@ def ntr_playlist(bot, trigger):
bot.say(format_playlist(data))
@plugin.command("lastshow")
def ntr_lastshow(bot, trigger):
raw = trigger.group(2)
if not raw or not raw.strip():
bot.say("Usage: !lastshow <position>")
return
try:
position = int(raw.strip())
except ValueError:
bot.say("Usage: !lastshow <position>")
return
base_url = bot.settings.ntr_playlist.api_base_url
try:
shows = _api_get(base_url, "/shows?limit=2")
except ApiError as e:
LOGGER.warning("API error for !lastshow: %s", e)
bot.say(e.detail)
return
if len(shows) < 2:
bot.say("No previous show found")
return
prev_show_id = shows[1]["id"]
try:
data = _api_get(base_url, f"/shows/{prev_show_id}")
except ApiError as e:
LOGGER.warning("API error for !lastshow show %s: %s", prev_show_id, e)
bot.say(e.detail)
return
tracks = data.get("tracks", [])
track = next((t for t in tracks if t.get("position") == position), None)
if not track:
episode = data.get("episode_number", "?")
bot.say(f"No track at position {position} in episode {episode}")
return
bot.say(format_track(track))
@plugin.command("status")
def ntr_status(bot, trigger):
base_url = bot.settings.ntr_playlist.api_base_url
@@ -197,9 +245,10 @@ def ntr_status(bot, trigger):
LOGGER.warning("API error for !status: %s", e)
bot.say(e.detail)
return
tz = bot.settings.ntr_playlist.display_timezone
status = data.get("status", "unknown")
poller = "alive" if data.get("poller_alive") else "dead"
last_fetch = data.get("last_fetch") or "never"
last_fetch = format_dt(data.get("last_fetch"), tz)
count = data.get("current_week_track_count", 0)
bot.say(f"Status: {status.upper()} | Poller: {poller} | Last fetch: {last_fetch} | Tracks this week: {count}")

57
tests/test_format_dt.py Normal file
View File

@@ -0,0 +1,57 @@
"""Tests for the format_dt helper used by both IRC plugins.
The function is duplicated in both plugins (Limnoria and Sopel) since they
can't share imports. This tests the logic independently of either IRC framework.
"""
from datetime import datetime
from zoneinfo import ZoneInfo
def format_dt(iso_string: str | None, tz_name: str = "America/New_York") -> str:
if not iso_string:
return "never"
dt = datetime.fromisoformat(iso_string)
local = dt.astimezone(ZoneInfo(tz_name))
return local.strftime("%a %b %-d, %-I:%M %p %Z")
def test_format_dt_est():
result = format_dt("2026-01-15T03:00:00+00:00")
assert result == "Wed Jan 14, 10:00 PM EST"
def test_format_dt_edt():
result = format_dt("2026-03-12T02:00:00+00:00")
assert result == "Wed Mar 11, 10:00 PM EDT"
def test_format_dt_none():
assert format_dt(None) == "never"
def test_format_dt_empty_string():
assert format_dt("") == "never"
def test_format_dt_custom_timezone():
result = format_dt("2026-03-12T02:00:00+00:00", "America/Chicago")
assert "CDT" in result or "CST" in result
def test_format_dt_no_seconds_or_microseconds():
result = format_dt("2026-03-12T02:30:45.123456+00:00")
assert ":45" not in result
assert ".123456" not in result
assert "10:30 PM" in result
def test_format_dt_single_digit_day():
result = format_dt("2026-03-05T03:00:00+00:00")
assert "Wed Mar 4," in result
assert " 04," not in result
def test_format_dt_single_digit_hour():
result = format_dt("2026-01-15T06:00:00+00:00")
assert "1:00 AM" in result
assert "01:00" not in result