Refactored OwncastSentry internals and API validation.
CI / Formatting (push) Failing after 7s
CI / Linting (push) Successful in 12s
CI / Tests (push) Successful in 47s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 7s

This commit is contained in:
2026-05-17 14:46:44 -04:00
parent 179d087e33
commit 0620c675d9
25 changed files with 2730 additions and 1569 deletions
+112 -139
View File
@@ -14,20 +14,71 @@
"""Command handlers for OwncastSentry bot commands."""
import sqlite3
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from .models import StreamStatus
from .utils import domainify, sanitize_for_markdown
from .types import (
AlreadySubscribedError,
InvalidOwncastInstanceError,
NotSubscribedError,
StreamStatus,
)
if TYPE_CHECKING:
import logging
from maubot import MessageEvent # type: ignore[attr-defined]
from .database import StreamRepository, SubscriptionRepository
from .owncast_client import OwncastClient
from .subscription_manager import SubscriptionManager
_MARKDOWN_ESCAPE_TABLE = str.maketrans({c: f"\\{c}" for c in r"\*_[]()~`#+-=|{}.!<>&"})
def _sanitize_for_plain_text(text: str) -> str:
"""Sanitize text before Markdown escaping."""
if not text:
return text
sanitized = text.replace("\n", " ").replace("\r", " ")
return " ".join(sanitized.split())
def _escape_markdown(text: str) -> str:
"""Escape Markdown special characters in untrusted text."""
if not text:
return text
return text.translate(_MARKDOWN_ESCAPE_TABLE)
def _sanitize_for_markdown(text: str) -> str:
"""Sanitize text for safe Markdown rendering."""
if not text:
return text
return _escape_markdown(_sanitize_for_plain_text(text))
def _format_duration(timestamp_str: str, now: datetime) -> str:
"""Calculate and format the duration from a timestamp to now."""
try:
timestamp = datetime.fromisoformat(timestamp_str)
delta = now - timestamp
seconds = int(delta.total_seconds())
if seconds < 0:
return "unknown duration"
if seconds < 60:
return f"{seconds} second{'s' if seconds != 1 else ''}"
if seconds < 3600:
minutes = seconds // 60
return f"{minutes} minute{'s' if minutes != 1 else ''}"
if seconds < 86400:
hours = seconds // 3600
return f"{hours} hour{'s' if hours != 1 else ''}"
days = seconds // 86400
return f"{days} day{'s' if days != 1 else ''}"
except (TypeError, ValueError):
return "unknown duration"
class CommandHandler:
@@ -35,22 +86,13 @@ class CommandHandler:
def __init__(
self,
owncast_client: OwncastClient,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
logger: logging.Logger,
subscription_manager: SubscriptionManager,
) -> None:
"""Initialize the command handler.
:param owncast_client: Client for making API calls to Owncast instances.
:param stream_repo: Repository for stream data.
:param subscription_repo: Repository for subscription data.
:param logger: Logger instance for debugging.
:param subscription_manager: Subscription domain workflow coordinator.
"""
self.owncast_client = owncast_client
self.stream_repo = stream_repo
self.subscription_repo = subscription_repo
self.log = logger
self.subscription_manager = subscription_manager
async def subscribe(self, evt: MessageEvent, url: str) -> None:
"""Subscribe a room to a stream's notifications.
@@ -58,46 +100,22 @@ class CommandHandler:
:param evt: MessageEvent of the message calling the command.
:param url: User supplied URL to a stream to subscribe to.
"""
# Convert the user input to only a domain
stream_domain = domainify(url)
# How many subscriptions already exist for this domain?
subscription_count = await self.subscription_repo.count_by_domain(stream_domain)
if subscription_count == 0:
# No subscriptions; validate this is an Owncast stream.
is_valid = await self.owncast_client.validate_instance(stream_domain)
if not is_valid:
# Fetch returned nothing. Probably not Owncast.
await evt.reply(
"The URL you supplied does not appear to "
"be a valid Owncast instance. You may have "
"specified an invalid domain, or the "
"instance is offline."
)
return
# Try to add a new subscription for this stream in this room
try:
await self.subscription_repo.add(stream_domain, evt.room_id)
except sqlite3.IntegrityError:
# Room is already subscribed.
stream_domain = await self.subscription_manager.subscribe(evt.room_id, url)
except InvalidOwncastInstanceError:
await evt.reply(
f"This room is already subscribed to notifications for {stream_domain}."
"The URL you supplied does not appear to "
"be a valid Owncast instance. You may have "
"specified an invalid domain, or the "
"instance is offline."
)
return
except AlreadySubscribedError as e:
await evt.reply(
f"This room is already subscribed to notifications for {e.domain}."
)
return
# Try to add a placeholder row for the stream's state.
try:
await self.stream_repo.create(stream_domain)
# First time seeing this stream. Log it.
self.log.info(f"[{stream_domain}] Discovered new stream!")
except sqlite3.IntegrityError:
# Adding rows for known streams is expected.
pass
# All went well! Tell the user.
self.log.info(f"[{stream_domain}] Subscription added for room {evt.room_id}.")
await evt.reply(
f"Subscription added! This room will receive "
f"notifications when {stream_domain} goes live."
@@ -109,66 +127,32 @@ class CommandHandler:
:param evt: MessageEvent of the message calling the command.
:param url: User supplied URL to a stream to unsubscribe from.
"""
# Convert the user input to only a domain
stream_domain = domainify(url)
# Attempt to delete the requested subscription
result = await self.subscription_repo.remove(stream_domain, evt.room_id)
# Did it work?
if result == 1:
# Yes, one row was deleted. Tell the user.
self.log.info(
f"[{stream_domain}] Subscription removed for room {evt.room_id}."
try:
stream_domain = await self.subscription_manager.unsubscribe(
evt.room_id, url
)
await evt.reply(
f"Subscription removed! This room will no "
f"longer receive notifications for {stream_domain}."
)
else:
# No, nothing changed. Tell the user.
except NotSubscribedError as e:
await evt.reply(
"This room is already not subscribed to "
f"notifications for {stream_domain}."
f"notifications for {e.domain}."
)
return
def _format_duration(self, timestamp_str: str) -> str:
"""Calculate and format the duration from a timestamp to now.
:param timestamp_str: ISO 8601 timestamp string.
:return: Formatted duration string (e.g., "1 hour", "2 days").
"""
try:
timestamp = datetime.fromisoformat(timestamp_str)
now = datetime.now(UTC)
delta = now - timestamp
seconds = int(delta.total_seconds())
if seconds < 60:
return f"{seconds} second{'s' if seconds != 1 else ''}"
if seconds < 3600:
minutes = seconds // 60
return f"{minutes} minute{'s' if minutes != 1 else ''}"
if seconds < 86400:
hours = seconds // 3600
return f"{hours} hour{'s' if hours != 1 else ''}"
days = seconds // 86400
return f"{days} day{'s' if days != 1 else ''}"
except ValueError:
return "unknown duration"
await evt.reply(
f"Subscription removed! This room will no "
f"longer receive notifications for {stream_domain}."
)
async def subscriptions(self, evt: MessageEvent) -> None:
"""List all stream subscriptions in the current room.
:param evt: MessageEvent of the message calling the command.
"""
# Get all stream domains this room is subscribed to
subscribed_domains = (
await self.subscription_repo.get_subscribed_streams_for_room(evt.room_id)
subscriptions = await self.subscription_manager.list_room_subscriptions(
evt.room_id
)
# Check if there are no subscriptions
if not subscribed_domains:
if not subscriptions:
await evt.reply(
"This room is not subscribed to any Owncast "
"instances.\n\nTo subscribe to an Owncast "
@@ -178,36 +162,33 @@ class CommandHandler:
return
# Build the response message body as Markdown
count = len(subscribed_domains)
count = len(subscriptions)
parts = [f"**Subscriptions for this room ({count}):**\n\n"]
now = datetime.now(UTC)
for domain in subscribed_domains:
# Get the stream state from the database
stream_state = await self.stream_repo.get_by_domain(domain)
if stream_state is None:
continue
# Determine stream name (use domain as fallback)
for subscription in subscriptions:
domain = subscription.domain
stream_state = subscription.stream_state
stream_name = stream_state.name or domain
safe_stream_name = sanitize_for_markdown(stream_name)
safe_stream_name = _sanitize_for_markdown(stream_name)
# Start building this stream's entry with stream name as main bullet
parts.append(f"- **{safe_stream_name}** \n")
# Add title if stream is online (as a sub-bullet)
if stream_state.status == StreamStatus.ONLINE and stream_state.title:
safe_title = sanitize_for_markdown(stream_state.title)
safe_title = _sanitize_for_markdown(stream_state.title)
parts.append(f" - Title: {safe_title} \n")
# Determine status and duration (as a sub-bullet)
match stream_state.status:
case StreamStatus.ONLINE if stream_state.last_connect_time:
duration = self._format_duration(stream_state.last_connect_time)
duration = _format_duration(stream_state.last_connect_time, now)
parts.append(f" - Status: Online for {duration} \n")
case StreamStatus.UNKNOWN:
parts.append(" - Status: Unknown (instance unreachable) \n")
case StreamStatus.OFFLINE if stream_state.last_disconnect_time:
duration = self._format_duration(stream_state.last_disconnect_time)
duration = _format_duration(stream_state.last_disconnect_time, now)
parts.append(f" - Status: Offline for {duration} \n")
case StreamStatus.OFFLINE:
parts.append(" - Status: Offline \n")
@@ -229,30 +210,20 @@ class CommandHandler:
:param evt: MessageEvent of the message calling the command.
"""
# Get all stream domains this room is subscribed to
subscribed_domains = (
await self.subscription_repo.get_subscribed_streams_for_room(evt.room_id)
live_streams = await self.subscription_manager.list_live_room_subscriptions(
evt.room_id
)
# Check if there are no subscriptions
if not subscribed_domains:
await evt.reply(
"This room is not subscribed to any Owncast "
"instances.\n\nTo subscribe to an Owncast "
"instance, use `!subscribe <domain>`",
markdown=True,
)
return
# Filter for only live streams (exclude unknown status)
live_streams = []
for domain in subscribed_domains:
stream_state = await self.stream_repo.get_by_domain(domain)
if stream_state and stream_state.status == StreamStatus.ONLINE:
live_streams.append((domain, stream_state))
# Check if there are no live streams
if not live_streams:
if not await self.subscription_manager.has_room_subscriptions(evt.room_id):
await evt.reply(
"This room is not subscribed to any Owncast "
"instances.\n\nTo subscribe to an Owncast "
"instance, use `!subscribe <domain>`",
markdown=True,
)
return
await evt.reply(
"No subscribed Owncast instances are currently "
"live.\n\nUse `!subscriptions` to list all "
@@ -264,23 +235,25 @@ class CommandHandler:
# Build the response message body as Markdown
count = len(live_streams)
parts = [f"**Live Owncast instances ({count}):**\n\n"]
now = datetime.now(UTC)
for domain, stream_state in live_streams:
# Determine stream name (use domain as fallback)
for subscription in live_streams:
domain = subscription.domain
stream_state = subscription.stream_state
stream_name = stream_state.name or domain
safe_stream_name = sanitize_for_markdown(stream_name)
safe_stream_name = _sanitize_for_markdown(stream_name)
# Start building this stream's entry with stream name as main bullet
parts.append(f"- **{safe_stream_name}** \n")
# Add title (should be present for live streams)
if stream_state.title:
safe_title = sanitize_for_markdown(stream_state.title)
safe_title = _sanitize_for_markdown(stream_state.title)
parts.append(f" - Title: {safe_title} \n")
# Add status with duration
if stream_state.last_connect_time:
duration = self._format_duration(stream_state.last_connect_time)
duration = _format_duration(stream_state.last_connect_time, now)
parts.append(f" - Online for {duration} \n")
# Add stream link