feat: add URL shortening to subscription announcements
Bot.shorten_url() method delegates to flaskpaste plugin when loaded. RSS, YouTube, and pastemoni announcements auto-shorten links. Includes test_flaskpaste.py (9 cases) and FakeBot updates in 3 test files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -275,6 +275,8 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
|
|||||||
title = item.get("title") or "(untitled)"
|
title = item.get("title") or "(untitled)"
|
||||||
snippet = item.get("snippet", "")
|
snippet = item.get("snippet", "")
|
||||||
url = item.get("url", "")
|
url = item.get("url", "")
|
||||||
|
if url:
|
||||||
|
url = await bot.shorten_url(url)
|
||||||
parts = [f"[{tag}] {title}"]
|
parts = [f"[{tag}] {title}"]
|
||||||
if snippet:
|
if snippet:
|
||||||
parts.append(snippet)
|
parts.append(snippet)
|
||||||
|
|||||||
@@ -272,6 +272,8 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
|
|||||||
for item in shown:
|
for item in shown:
|
||||||
title = _truncate(item["title"]) if item["title"] else "(no title)"
|
title = _truncate(item["title"]) if item["title"] else "(no title)"
|
||||||
link = item["link"]
|
link = item["link"]
|
||||||
|
if link:
|
||||||
|
link = await bot.shorten_url(link)
|
||||||
date = item.get("date", "")
|
date = item.get("date", "")
|
||||||
line = f"[{name}] {title}"
|
line = f"[{name}] {title}"
|
||||||
if date:
|
if date:
|
||||||
|
|||||||
@@ -394,6 +394,8 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
|
|||||||
for item in shown:
|
for item in shown:
|
||||||
title = _truncate(item["title"]) if item["title"] else "(no title)"
|
title = _truncate(item["title"]) if item["title"] else "(no title)"
|
||||||
link = item["link"]
|
link = item["link"]
|
||||||
|
if link:
|
||||||
|
link = await bot.shorten_url(link)
|
||||||
# Build metadata suffix
|
# Build metadata suffix
|
||||||
parts = []
|
parts = []
|
||||||
dur = durations.get(item["id"], 0)
|
dur = durations.get(item["id"], 0)
|
||||||
|
|||||||
@@ -510,6 +510,17 @@ class Bot:
|
|||||||
"""Send a CTCP ACTION (/me) to a target."""
|
"""Send a CTCP ACTION (/me) to a target."""
|
||||||
await self.send(target, f"\x01ACTION {text}\x01")
|
await self.send(target, f"\x01ACTION {text}\x01")
|
||||||
|
|
||||||
|
async def shorten_url(self, url: str) -> str:
|
||||||
|
"""Shorten a URL via FlaskPaste. Returns original on failure."""
|
||||||
|
fp = self.registry._modules.get("flaskpaste")
|
||||||
|
if not fp:
|
||||||
|
return url
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
return await loop.run_in_executor(None, fp.shorten_url, self, url)
|
||||||
|
except Exception:
|
||||||
|
return url
|
||||||
|
|
||||||
async def join(self, channel: str) -> None:
|
async def join(self, channel: str) -> None:
|
||||||
"""Join an IRC channel."""
|
"""Join an IRC channel."""
|
||||||
await self.conn.send(format_msg("JOIN", channel))
|
await self.conn.send(format_msg("JOIN", channel))
|
||||||
|
|||||||
212
tests/test_flaskpaste.py
Normal file
212
tests/test_flaskpaste.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""Tests for the FlaskPaste plugin and Bot.shorten_url method."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from derp.irc import Message
|
||||||
|
|
||||||
|
# plugins/ is not a Python package -- load the module from file path
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"plugins.flaskpaste",
|
||||||
|
Path(__file__).resolve().parent.parent / "plugins" / "flaskpaste.py",
|
||||||
|
)
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules[_spec.name] = _mod
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
|
||||||
|
from plugins.flaskpaste import ( # noqa: E402
|
||||||
|
cmd_paste,
|
||||||
|
cmd_shorten,
|
||||||
|
create_paste,
|
||||||
|
shorten_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
class _FakeState:
|
||||||
|
"""In-memory stand-in for bot.state."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._store: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
def get(self, plugin: str, key: str, default: str | None = None) -> str | None:
|
||||||
|
return self._store.get(plugin, {}).get(key, default)
|
||||||
|
|
||||||
|
def set(self, plugin: str, key: str, value: str) -> None:
|
||||||
|
self._store.setdefault(plugin, {})[key] = value
|
||||||
|
|
||||||
|
def delete(self, plugin: str, key: str) -> bool:
|
||||||
|
try:
|
||||||
|
del self._store[plugin][key]
|
||||||
|
return True
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def keys(self, plugin: str) -> list[str]:
|
||||||
|
return sorted(self._store.get(plugin, {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRegistry:
|
||||||
|
"""Minimal registry stand-in."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._modules: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBot:
|
||||||
|
"""Minimal bot stand-in that captures sent/replied messages."""
|
||||||
|
|
||||||
|
def __init__(self, *, admin: bool = False):
|
||||||
|
self.sent: list[tuple[str, str]] = []
|
||||||
|
self.replied: list[str] = []
|
||||||
|
self.state = _FakeState()
|
||||||
|
self.registry = _FakeRegistry()
|
||||||
|
self._admin = admin
|
||||||
|
self.config: dict = {}
|
||||||
|
|
||||||
|
async def send(self, target: str, text: str) -> None:
|
||||||
|
self.sent.append((target, text))
|
||||||
|
|
||||||
|
async def reply(self, message, text: str) -> None:
|
||||||
|
self.replied.append(text)
|
||||||
|
|
||||||
|
def _is_admin(self, message) -> bool:
|
||||||
|
return self._admin
|
||||||
|
|
||||||
|
|
||||||
|
def _msg(text: str, nick: str = "alice", target: str = "#test") -> Message:
|
||||||
|
"""Create a channel PRIVMSG."""
|
||||||
|
return Message(
|
||||||
|
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
||||||
|
command="PRIVMSG", params=[target, text], tags={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCmdShorten
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCmdShorten:
|
||||||
|
def test_missing_url(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_shorten(bot, _msg("!shorten")))
|
||||||
|
assert "Usage:" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_invalid_scheme(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_shorten(bot, _msg("!shorten ftp://example.com")))
|
||||||
|
assert "http://" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_success(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
with patch.object(
|
||||||
|
_mod, "_shorten_url",
|
||||||
|
return_value="https://paste.mymx.me/s/abc123",
|
||||||
|
):
|
||||||
|
await cmd_shorten(bot, _msg("!shorten https://example.com/long"))
|
||||||
|
assert "paste.mymx.me/s/abc123" in bot.replied[0]
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
def test_failure(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
with patch.object(
|
||||||
|
_mod, "_shorten_url",
|
||||||
|
side_effect=ConnectionError("down"),
|
||||||
|
):
|
||||||
|
await cmd_shorten(bot, _msg("!shorten https://example.com/long"))
|
||||||
|
assert "shorten failed" in bot.replied[0]
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCmdPaste
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCmdPaste:
|
||||||
|
def test_missing_text(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_paste(bot, _msg("!paste")))
|
||||||
|
assert "Usage:" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_success(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
with patch.object(
|
||||||
|
_mod, "_create_paste",
|
||||||
|
return_value="https://paste.mymx.me/xyz789",
|
||||||
|
):
|
||||||
|
await cmd_paste(bot, _msg("!paste hello world"))
|
||||||
|
assert "paste.mymx.me/xyz789" in bot.replied[0]
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
def test_failure(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
with patch.object(
|
||||||
|
_mod, "_create_paste",
|
||||||
|
side_effect=ConnectionError("down"),
|
||||||
|
):
|
||||||
|
await cmd_paste(bot, _msg("!paste hello world"))
|
||||||
|
assert "paste failed" in bot.replied[0]
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestShortenUrlHelper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestShortenUrlHelper:
|
||||||
|
def test_returns_short_url(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
with patch.object(
|
||||||
|
_mod, "_shorten_url",
|
||||||
|
return_value="https://paste.mymx.me/s/short",
|
||||||
|
):
|
||||||
|
result = shorten_url(bot, "https://example.com/long")
|
||||||
|
assert result == "https://paste.mymx.me/s/short"
|
||||||
|
|
||||||
|
def test_returns_original_on_error(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
with patch.object(
|
||||||
|
_mod, "_shorten_url",
|
||||||
|
side_effect=ConnectionError("fail"),
|
||||||
|
):
|
||||||
|
result = shorten_url(bot, "https://example.com/long")
|
||||||
|
assert result == "https://example.com/long"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCreatePasteHelper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCreatePasteHelper:
|
||||||
|
def test_returns_paste_url(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
with patch.object(
|
||||||
|
_mod, "_create_paste",
|
||||||
|
return_value="https://paste.mymx.me/abc",
|
||||||
|
):
|
||||||
|
result = create_paste(bot, "hello")
|
||||||
|
assert result == "https://paste.mymx.me/abc"
|
||||||
|
|
||||||
|
def test_returns_none_on_error(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
with patch.object(
|
||||||
|
_mod, "_create_paste",
|
||||||
|
side_effect=ConnectionError("fail"),
|
||||||
|
):
|
||||||
|
result = create_paste(bot, "hello")
|
||||||
|
assert result is None
|
||||||
@@ -140,6 +140,9 @@ class _FakeBot:
|
|||||||
async def reply(self, message, text: str) -> None:
|
async def reply(self, message, text: str) -> None:
|
||||||
self.replied.append(text)
|
self.replied.append(text)
|
||||||
|
|
||||||
|
async def shorten_url(self, url: str) -> str:
|
||||||
|
return url
|
||||||
|
|
||||||
def _is_admin(self, message) -> bool:
|
def _is_admin(self, message) -> bool:
|
||||||
return self._admin
|
return self._admin
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ class _FakeBot:
|
|||||||
async def reply(self, message, text: str) -> None:
|
async def reply(self, message, text: str) -> None:
|
||||||
self.replied.append(text)
|
self.replied.append(text)
|
||||||
|
|
||||||
|
async def shorten_url(self, url: str) -> str:
|
||||||
|
return url
|
||||||
|
|
||||||
def _is_admin(self, message) -> bool:
|
def _is_admin(self, message) -> bool:
|
||||||
return self._admin
|
return self._admin
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ class _FakeBot:
|
|||||||
async def reply(self, message, text: str) -> None:
|
async def reply(self, message, text: str) -> None:
|
||||||
self.replied.append(text)
|
self.replied.append(text)
|
||||||
|
|
||||||
|
async def shorten_url(self, url: str) -> str:
|
||||||
|
return url
|
||||||
|
|
||||||
def _is_admin(self, message) -> bool:
|
def _is_admin(self, message) -> bool:
|
||||||
return self._admin
|
return self._admin
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user