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:
@@ -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).""",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
57
tests/test_format_dt.py
Normal 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
|
||||
Reference in New Issue
Block a user