feat: metadata enrichment for alerts and subscription plugins
Alert backends now populate structured `extra` field with engagement metrics (views, stars, votes, etc.) instead of embedding them in titles. Subscription plugins show richer announcements: Twitch viewer counts, YouTube views/likes/dates, RSS published dates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ _spec.loader.exec_module(_mod)
|
||||
|
||||
from plugins.alert import ( # noqa: E402
|
||||
_MAX_SEEN,
|
||||
_compact_num,
|
||||
_delete,
|
||||
_errors,
|
||||
_extract_videos,
|
||||
@@ -27,6 +28,7 @@ from plugins.alert import ( # noqa: E402
|
||||
_pollers,
|
||||
_restore,
|
||||
_save,
|
||||
_save_result,
|
||||
_search_searx,
|
||||
_search_twitch,
|
||||
_search_youtube,
|
||||
@@ -179,6 +181,10 @@ class _FakeBot:
|
||||
async def reply(self, message, text: str) -> None:
|
||||
self.replied.append(text)
|
||||
|
||||
async def long_reply(self, message, lines, *, label: str = "") -> None:
|
||||
for line in lines:
|
||||
self.replied.append(line)
|
||||
|
||||
def _is_admin(self, message) -> bool:
|
||||
return self._admin
|
||||
|
||||
@@ -223,9 +229,9 @@ def _fake_tw(keyword):
|
||||
"""Fake Twitch backend returning two results (keyword in title)."""
|
||||
return [
|
||||
{"id": "stream:tw1", "title": "TW test Stream 1",
|
||||
"url": "https://twitch.tv/user1", "extra": ""},
|
||||
"url": "https://twitch.tv/user1", "extra": "500 viewers"},
|
||||
{"id": "vod:tw2", "title": "TW test VOD 1",
|
||||
"url": "https://twitch.tv/videos/tw2", "extra": ""},
|
||||
"url": "https://twitch.tv/videos/tw2", "extra": "1k views"},
|
||||
]
|
||||
|
||||
|
||||
@@ -322,6 +328,36 @@ class TestTruncate:
|
||||
assert not result.endswith(" ...")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCompactNum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCompactNum:
|
||||
def test_zero(self):
|
||||
assert _compact_num(0) == "0"
|
||||
|
||||
def test_small(self):
|
||||
assert _compact_num(999) == "999"
|
||||
|
||||
def test_one_k(self):
|
||||
assert _compact_num(1000) == "1k"
|
||||
|
||||
def test_one_point_five_k(self):
|
||||
assert _compact_num(1500) == "1.5k"
|
||||
|
||||
def test_one_m(self):
|
||||
assert _compact_num(1000000) == "1M"
|
||||
|
||||
def test_two_point_five_m(self):
|
||||
assert _compact_num(2500000) == "2.5M"
|
||||
|
||||
def test_exact_boundary(self):
|
||||
assert _compact_num(10000) == "10k"
|
||||
|
||||
def test_large_millions(self):
|
||||
assert _compact_num(12300000) == "12.3M"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestExtractVideos
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -873,6 +909,13 @@ class TestPollOnce:
|
||||
assert "[poll/yt/" in actions[0]
|
||||
assert "[poll/tw/" in actions[2]
|
||||
assert "[poll/sx/" in actions[4]
|
||||
# Twitch fakes have extra metadata; verify it appears in titles
|
||||
tw_titles = [s for t, s in bot.sent if t == "#test" and "TW test" in s]
|
||||
assert any("| 500 viewers" in t for t in tw_titles)
|
||||
assert any("| 1k views" in t for t in tw_titles)
|
||||
# YouTube fakes have no extra; verify no pipe suffix
|
||||
yt_titles = [s for t, s in bot.sent if t == "#test" and "YT test" in s]
|
||||
assert all("|" not in t for t in yt_titles)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
@@ -1239,3 +1282,103 @@ class TestSearchSearx:
|
||||
with patch("urllib.request.urlopen", side_effect=ConnectionError("fail")):
|
||||
results = _search_searx("test")
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestExtraInHistory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtraInHistory:
|
||||
def test_history_shows_extra(self):
|
||||
"""History output includes | extra suffix when extra is non-empty."""
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "hist", "channel": "#test",
|
||||
"interval": 300, "seen": {}, "last_poll": "", "last_error": "",
|
||||
}
|
||||
_save(bot, "#test:hist", data)
|
||||
# Insert a result with extra metadata
|
||||
_save_result("#test", "hist", "hn", {
|
||||
"id": "h1", "title": "Cool HN Post", "url": "https://hn.example.com/1",
|
||||
"date": "2026-01-15", "extra": "42pt 10c",
|
||||
})
|
||||
|
||||
async def inner():
|
||||
await cmd_alert(bot, _msg("!alert history hist"))
|
||||
assert len(bot.replied) >= 1
|
||||
found = any("| 42pt 10c" in line for line in bot.replied)
|
||||
assert found, f"Expected extra in history, got: {bot.replied}"
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_history_no_extra(self):
|
||||
"""History output has no pipe when extra is empty."""
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "hist2", "channel": "#test",
|
||||
"interval": 300, "seen": {}, "last_poll": "", "last_error": "",
|
||||
}
|
||||
_save(bot, "#test:hist2", data)
|
||||
_save_result("#test", "hist2", "yt", {
|
||||
"id": "y1", "title": "Plain Video", "url": "https://yt.example.com/1",
|
||||
"date": "", "extra": "",
|
||||
})
|
||||
|
||||
async def inner():
|
||||
await cmd_alert(bot, _msg("!alert history hist2"))
|
||||
assert len(bot.replied) >= 1
|
||||
assert all("|" not in line for line in bot.replied)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestExtraInInfo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtraInInfo:
|
||||
def test_info_shows_extra(self):
|
||||
"""Info output includes | extra suffix when extra is non-empty."""
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "inf", "channel": "#test",
|
||||
"interval": 300, "seen": {}, "last_poll": "", "last_error": "",
|
||||
}
|
||||
_save(bot, "#test:inf", data)
|
||||
short_id = _save_result("#test", "inf", "gh", {
|
||||
"id": "g1", "title": "cool/repo: A cool project",
|
||||
"url": "https://github.com/cool/repo",
|
||||
"date": "2026-01-10", "extra": "42* 5fk",
|
||||
})
|
||||
|
||||
async def inner():
|
||||
await cmd_alert(bot, _msg(f"!alert info {short_id}"))
|
||||
assert len(bot.replied) >= 1
|
||||
assert "| 42* 5fk" in bot.replied[0]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_info_no_extra(self):
|
||||
"""Info output has no pipe when extra is empty."""
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "inf2", "channel": "#test",
|
||||
"interval": 300, "seen": {}, "last_poll": "", "last_error": "",
|
||||
}
|
||||
_save(bot, "#test:inf2", data)
|
||||
short_id = _save_result("#test", "inf2", "yt", {
|
||||
"id": "y2", "title": "Some Video",
|
||||
"url": "https://youtube.com/watch?v=y2",
|
||||
"date": "", "extra": "",
|
||||
})
|
||||
|
||||
async def inner():
|
||||
await cmd_alert(bot, _msg(f"!alert info {short_id}"))
|
||||
assert len(bot.replied) >= 1
|
||||
assert "|" not in bot.replied[0]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
@@ -25,6 +25,7 @@ from plugins.rss import ( # noqa: E402
|
||||
_feeds,
|
||||
_load,
|
||||
_parse_atom,
|
||||
_parse_date,
|
||||
_parse_feed,
|
||||
_parse_rss,
|
||||
_poll_once,
|
||||
@@ -52,16 +53,19 @@ RSS_FEED = b"""\
|
||||
<guid>item-1</guid>
|
||||
<title>First Post</title>
|
||||
<link>https://example.com/1</link>
|
||||
<pubDate>Mon, 10 Feb 2026 09:00:00 +0000</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>item-2</guid>
|
||||
<title>Second Post</title>
|
||||
<link>https://example.com/2</link>
|
||||
<pubDate>Tue, 11 Feb 2026 14:30:00 +0000</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>item-3</guid>
|
||||
<title>Third Post</title>
|
||||
<link>https://example.com/3</link>
|
||||
<pubDate>Wed, 12 Feb 2026 08:00:00 +0000</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -88,11 +92,13 @@ ATOM_FEED = b"""\
|
||||
<id>atom-1</id>
|
||||
<title>Atom First</title>
|
||||
<link href="https://example.com/a1"/>
|
||||
<published>2026-02-15T10:00:00Z</published>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>atom-2</id>
|
||||
<title>Atom Second</title>
|
||||
<link href="https://example.com/a2"/>
|
||||
<published>2026-02-16T15:30:00Z</published>
|
||||
</entry>
|
||||
</feed>
|
||||
"""
|
||||
@@ -333,6 +339,20 @@ class TestParseRSS:
|
||||
assert items[0]["title"] == "First Post"
|
||||
assert items[0]["link"] == "https://example.com/1"
|
||||
|
||||
def test_parses_pubdate(self):
|
||||
import xml.etree.ElementTree as ET
|
||||
root = ET.fromstring(RSS_FEED)
|
||||
_, items = _parse_rss(root)
|
||||
assert items[0]["date"] == "2026-02-10"
|
||||
assert items[1]["date"] == "2026-02-11"
|
||||
assert items[2]["date"] == "2026-02-12"
|
||||
|
||||
def test_no_pubdate_empty_string(self):
|
||||
import xml.etree.ElementTree as ET
|
||||
root = ET.fromstring(RSS_NO_GUID)
|
||||
_, items = _parse_rss(root)
|
||||
assert items[0]["date"] == ""
|
||||
|
||||
def test_fallback_to_link_as_id(self):
|
||||
import xml.etree.ElementTree as ET
|
||||
root = ET.fromstring(RSS_NO_GUID)
|
||||
@@ -364,6 +384,19 @@ class TestParseAtom:
|
||||
assert items[0]["title"] == "Atom First"
|
||||
assert items[0]["link"] == "https://example.com/a1"
|
||||
|
||||
def test_parses_published_date(self):
|
||||
import xml.etree.ElementTree as ET
|
||||
root = ET.fromstring(ATOM_FEED)
|
||||
_, items = _parse_atom(root)
|
||||
assert items[0]["date"] == "2026-02-15"
|
||||
assert items[1]["date"] == "2026-02-16"
|
||||
|
||||
def test_no_published_empty_string(self):
|
||||
import xml.etree.ElementTree as ET
|
||||
root = ET.fromstring(ATOM_NO_ID)
|
||||
_, items = _parse_atom(root)
|
||||
assert items[0]["date"] == ""
|
||||
|
||||
def test_fallback_to_link_as_id(self):
|
||||
import xml.etree.ElementTree as ET
|
||||
root = ET.fromstring(ATOM_NO_ID)
|
||||
@@ -755,6 +788,9 @@ class TestCmdRssCheck:
|
||||
assert len(announcements) == 2
|
||||
assert "[news]" in announcements[0]
|
||||
assert "Second Post" in announcements[0]
|
||||
# Verify date suffix
|
||||
assert "| 2026-02-11" in announcements[0]
|
||||
assert "| 2026-02-12" in announcements[1]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
@@ -1073,3 +1109,27 @@ class TestCmdRssUsage:
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_rss(bot, _msg("!rss foobar")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestParseDate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseDate:
|
||||
def test_iso_format(self):
|
||||
assert _parse_date("2026-02-15T10:00:00Z") == "2026-02-15"
|
||||
|
||||
def test_iso_with_offset(self):
|
||||
assert _parse_date("2026-02-15T10:00:00+00:00") == "2026-02-15"
|
||||
|
||||
def test_rfc2822_format(self):
|
||||
assert _parse_date("Mon, 10 Feb 2026 09:00:00 +0000") == "2026-02-10"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _parse_date("") == ""
|
||||
|
||||
def test_garbage(self):
|
||||
assert _parse_date("not a date") == ""
|
||||
|
||||
def test_date_only(self):
|
||||
assert _parse_date("2026-01-01") == "2026-01-01"
|
||||
|
||||
@@ -17,6 +17,7 @@ sys.modules[_spec.name] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
from plugins.twitch import ( # noqa: E402
|
||||
_compact_num,
|
||||
_delete,
|
||||
_errors,
|
||||
_load,
|
||||
@@ -652,6 +653,17 @@ class TestCmdTwitchList:
|
||||
assert "broken (error)" in bot.replied[0]
|
||||
|
||||
def test_list_shows_live(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
_save(bot, "#test:xqc", {
|
||||
"name": "xqc", "channel": "#test",
|
||||
"last_error": "", "was_live": True,
|
||||
"last_viewers": 50000,
|
||||
})
|
||||
asyncio.run(cmd_twitch(bot, _msg("!twitch list")))
|
||||
assert "xqc (live, 50k)" in bot.replied[0]
|
||||
|
||||
def test_list_shows_live_no_viewers(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
_save(bot, "#test:xqc", {
|
||||
@@ -725,8 +737,10 @@ class TestCmdTwitchCheck:
|
||||
assert len(announcements) == 1
|
||||
assert "[xqc] is live" in announcements[0]
|
||||
assert "Fortnite" in announcements[0]
|
||||
# Check reply shows live status
|
||||
assert "| 50k viewers" in announcements[0]
|
||||
# Check reply shows live status with viewers
|
||||
assert "xqc: live" in bot.replied[0]
|
||||
assert "| 50k viewers" in bot.replied[0]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
@@ -794,6 +808,7 @@ class TestPollOnce:
|
||||
assert "[xqc] is live" in messages[0]
|
||||
assert "Playing games" in messages[0]
|
||||
assert "Fortnite" in messages[0]
|
||||
assert "| 50k viewers" in messages[0]
|
||||
assert "https://twitch.tv/xqc" in messages[0]
|
||||
updated = _load(bot, key)
|
||||
assert updated["was_live"] is True
|
||||
@@ -941,6 +956,7 @@ class TestPollOnce:
|
||||
assert len(messages) == 1
|
||||
assert "Just chatting" in messages[0]
|
||||
assert "(" not in messages[0] # No game parenthetical
|
||||
assert "| 100 viewers" in messages[0]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
@@ -1132,3 +1148,33 @@ class TestCmdTwitchUsage:
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_twitch(bot, _msg("!twitch foobar")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCompactNum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCompactNum:
|
||||
def test_zero(self):
|
||||
assert _compact_num(0) == "0"
|
||||
|
||||
def test_small(self):
|
||||
assert _compact_num(999) == "999"
|
||||
|
||||
def test_one_k(self):
|
||||
assert _compact_num(1000) == "1k"
|
||||
|
||||
def test_fractional_k(self):
|
||||
assert _compact_num(1500) == "1.5k"
|
||||
|
||||
def test_one_m(self):
|
||||
assert _compact_num(1_000_000) == "1M"
|
||||
|
||||
def test_fractional_m(self):
|
||||
assert _compact_num(2_500_000) == "2.5M"
|
||||
|
||||
def test_fifty_k(self):
|
||||
assert _compact_num(50000) == "50k"
|
||||
|
||||
def test_hundred(self):
|
||||
assert _compact_num(100) == "100"
|
||||
|
||||
@@ -19,6 +19,7 @@ _spec.loader.exec_module(_mod)
|
||||
from plugins.youtube import ( # noqa: E402
|
||||
_MAX_ANNOUNCE,
|
||||
_channels,
|
||||
_compact_num,
|
||||
_delete,
|
||||
_derive_name,
|
||||
_errors,
|
||||
@@ -44,26 +45,48 @@ from plugins.youtube import ( # noqa: E402
|
||||
YT_ATOM_FEED = b"""\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015"
|
||||
xmlns="http://www.w3.org/2005/Atom">
|
||||
xmlns="http://www.w3.org/2005/Atom"
|
||||
xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<title>3Blue1Brown - Videos</title>
|
||||
<author><name>3Blue1Brown</name></author>
|
||||
<entry>
|
||||
<id>yt:video:abc123</id>
|
||||
<yt:videoId>abc123</yt:videoId>
|
||||
<title>Linear Algebra</title>
|
||||
<published>2026-01-15T12:00:00+00:00</published>
|
||||
<link rel="alternate" href="https://www.youtube.com/watch?v=abc123"/>
|
||||
<media:group>
|
||||
<media:community>
|
||||
<media:statistics views="1500000"/>
|
||||
<media:starRating count="45000"/>
|
||||
</media:community>
|
||||
</media:group>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>yt:video:def456</id>
|
||||
<yt:videoId>def456</yt:videoId>
|
||||
<title>Calculus</title>
|
||||
<published>2026-02-01T08:30:00+00:00</published>
|
||||
<link rel="alternate" href="https://www.youtube.com/watch?v=def456"/>
|
||||
<media:group>
|
||||
<media:community>
|
||||
<media:statistics views="820000"/>
|
||||
<media:starRating count="32000"/>
|
||||
</media:community>
|
||||
</media:group>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>yt:video:ghi789</id>
|
||||
<yt:videoId>ghi789</yt:videoId>
|
||||
<title>Neural Networks</title>
|
||||
<published>2026-02-10T14:00:00+00:00</published>
|
||||
<link rel="alternate" href="https://www.youtube.com/watch?v=ghi789"/>
|
||||
<media:group>
|
||||
<media:community>
|
||||
<media:statistics views="250000"/>
|
||||
<media:starRating count="12000"/>
|
||||
</media:community>
|
||||
</media:group>
|
||||
</entry>
|
||||
</feed>
|
||||
"""
|
||||
@@ -362,6 +385,28 @@ class TestParseFeed:
|
||||
channel_name, _ = _parse_feed(YT_ATOM_FEED)
|
||||
assert channel_name == "3Blue1Brown"
|
||||
|
||||
def test_parses_published_date(self):
|
||||
_, items = _parse_feed(YT_ATOM_FEED)
|
||||
assert items[0]["date"] == "2026-01-15"
|
||||
assert items[1]["date"] == "2026-02-01"
|
||||
assert items[2]["date"] == "2026-02-10"
|
||||
|
||||
def test_parses_views(self):
|
||||
_, items = _parse_feed(YT_ATOM_FEED)
|
||||
assert items[0]["views"] == 1500000
|
||||
assert items[1]["views"] == 820000
|
||||
|
||||
def test_parses_likes(self):
|
||||
_, items = _parse_feed(YT_ATOM_FEED)
|
||||
assert items[0]["likes"] == 45000
|
||||
assert items[1]["likes"] == 32000
|
||||
|
||||
def test_no_media_defaults_zero(self):
|
||||
_, items = _parse_feed(YT_ATOM_NO_VIDEOID)
|
||||
assert items[0]["views"] == 0
|
||||
assert items[0]["likes"] == 0
|
||||
assert items[0]["date"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestResolveChannel
|
||||
@@ -789,6 +834,11 @@ class TestCmdYtCheck:
|
||||
assert len(announcements) == 2
|
||||
assert "[news]" in announcements[0]
|
||||
assert "Calculus" in announcements[0]
|
||||
# Verify metadata suffix (views, likes, date)
|
||||
assert "| " in announcements[0]
|
||||
assert "820kv" in announcements[0]
|
||||
assert "32klk" in announcements[0]
|
||||
assert "2026-02-01" in announcements[0]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
@@ -1103,3 +1153,27 @@ class TestCmdYtUsage:
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_yt(bot, _msg("!yt foobar")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCompactNum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCompactNum:
|
||||
def test_zero(self):
|
||||
assert _compact_num(0) == "0"
|
||||
|
||||
def test_small(self):
|
||||
assert _compact_num(999) == "999"
|
||||
|
||||
def test_one_k(self):
|
||||
assert _compact_num(1000) == "1k"
|
||||
|
||||
def test_fractional_k(self):
|
||||
assert _compact_num(1500) == "1.5k"
|
||||
|
||||
def test_one_m(self):
|
||||
assert _compact_num(1_000_000) == "1M"
|
||||
|
||||
def test_fractional_m(self):
|
||||
assert _compact_num(2_500_000) == "2.5M"
|
||||
|
||||
Reference in New Issue
Block a user