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:
user
2026-02-19 10:00:17 +01:00
parent c3b19feb0f
commit 1fe7da9ed8
10 changed files with 614 additions and 52 deletions

View File

@@ -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())