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).""",
|
"""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 re
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from supybot import callbacks
|
from supybot import callbacks
|
||||||
from supybot.commands import optional, wrap
|
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 --------------------------------------------------------------
|
# --- 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
|
_MAX_IRC_LINE = 430
|
||||||
|
|
||||||
|
|
||||||
@@ -195,6 +205,45 @@ class NtrPlaylist(callbacks.Plugin):
|
|||||||
return
|
return
|
||||||
irc.reply(format_playlist(data))
|
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
|
@wrap
|
||||||
def status(self, irc, msg, args):
|
def status(self, irc, msg, args):
|
||||||
"""takes no arguments
|
"""takes no arguments
|
||||||
@@ -208,9 +257,10 @@ class NtrPlaylist(callbacks.Plugin):
|
|||||||
LOGGER.warning("API error for status: %s", exc)
|
LOGGER.warning("API error for status: %s", exc)
|
||||||
irc.reply(exc.detail)
|
irc.reply(exc.detail)
|
||||||
return
|
return
|
||||||
|
tz = self.registryValue("displayTimezone")
|
||||||
status = data.get("status", "unknown")
|
status = data.get("status", "unknown")
|
||||||
poller = "alive" if data.get("poller_alive") else "dead"
|
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)
|
count = data.get("current_week_track_count", 0)
|
||||||
irc.reply(
|
irc.reply(
|
||||||
f"Status: {status.upper()} | Poller: {poller} | Last fetch: {last_fetch} | Tracks this week: {count}"
|
f"Status: {status.upper()} | Poller: {poller} | Last fetch: {last_fetch} | Tracks this week: {count}"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from sopel import plugin
|
from sopel import plugin
|
||||||
from sopel.config import types
|
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")
|
api_base_url = types.ValidatedAttribute("api_base_url", default="http://127.0.0.1:8000")
|
||||||
admin_token = types.ValidatedAttribute("admin_token", default="")
|
admin_token = types.ValidatedAttribute("admin_token", default="")
|
||||||
admin_nicks = types.ListAttribute("admin_nicks")
|
admin_nicks = types.ListAttribute("admin_nicks")
|
||||||
|
display_timezone = types.ValidatedAttribute("display_timezone", default="America/New_York")
|
||||||
|
|
||||||
|
|
||||||
def setup(bot):
|
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
|
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:
|
def format_track(track: dict) -> str:
|
||||||
pos = track.get("position", 0)
|
pos = track.get("position", 0)
|
||||||
title = track.get("title", "")
|
title = track.get("title", "")
|
||||||
@@ -188,6 +199,43 @@ def ntr_playlist(bot, trigger):
|
|||||||
bot.say(format_playlist(data))
|
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")
|
@plugin.command("status")
|
||||||
def ntr_status(bot, trigger):
|
def ntr_status(bot, trigger):
|
||||||
base_url = bot.settings.ntr_playlist.api_base_url
|
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)
|
LOGGER.warning("API error for !status: %s", e)
|
||||||
bot.say(e.detail)
|
bot.say(e.detail)
|
||||||
return
|
return
|
||||||
|
tz = bot.settings.ntr_playlist.display_timezone
|
||||||
status = data.get("status", "unknown")
|
status = data.get("status", "unknown")
|
||||||
poller = "alive" if data.get("poller_alive") else "dead"
|
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)
|
count = data.get("current_week_track_count", 0)
|
||||||
bot.say(f"Status: {status.upper()} | Poller: {poller} | Last fetch: {last_fetch} | Tracks this week: {count}")
|
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