Modernized codebase with tooling configuration and CI/CD workflows.
Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s

- Replaced legacy typing (Optional, List, Type, Union, Tuple) with PEP 604/585 equivalents.
- Added pyproject.toml with configurations for hatch-vcs, mypy, ruff, and codespell.
- Added CI workflows for formatting, linting, type checking, and spelling.
- Added CD workflow for building and uploading plugin artifacts on push to master and version tags.
- Added dependency auditing workflow with pip-audit.
- Added comprehensive docstrings and inline comments across all modules.
- Fixed User-Agent header using hardcoded version instead of actual plugin version.
- Fixed grammar and terminology in log messages and comments.
- Removed unreachable error handling branch in unsubscribe command.
This commit is contained in:
2026-03-11 14:37:56 -04:00
parent 314e1bf399
commit d05d73eddc
18 changed files with 2472 additions and 598 deletions
+106 -84
View File
@@ -1,37 +1,51 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Command handlers for OwncastSentry bot commands."""
import sqlite3
from datetime import datetime, timezone
from maubot import MessageEvent
from mautrix.types import TextMessageEventContent, MessageType
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from .owncast_client import OwncastClient
from .database import StreamRepository, SubscriptionRepository
from .models import StreamStatus
from .utils import domainify, sanitize_for_markdown
if TYPE_CHECKING:
import logging
from maubot import MessageEvent # type: ignore[attr-defined]
from .database import StreamRepository, SubscriptionRepository
from .owncast_client import OwncastClient
class CommandHandler:
"""Handles bot commands for subscribing to streams."""
"""Handles bot commands for managing stream subscriptions."""
def __init__(
self,
owncast_client: OwncastClient,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
logger,
):
"""
Initialize the command handler.
logger: logging.Logger,
) -> 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
: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.
"""
self.owncast_client = owncast_client
self.stream_repo = stream_repo
@@ -39,12 +53,10 @@ class CommandHandler:
self.log = logger
async def subscribe(self, evt: MessageEvent, url: str) -> None:
"""
"!subscribe" command handler for users to subscribe a room to a given stream's notifications.
"""Subscribe a room to a stream's notifications.
:param evt: MessageEvent of the message calling the command.
:param url: A string containing the user supplied URL to a stream to try and subscribe to.
:return: Nothing.
:param url: User supplied URL to a stream to subscribe to.
"""
# Convert the user input to only a domain
stream_domain = domainify(url)
@@ -53,50 +65,56 @@ class CommandHandler:
subscription_count = await self.subscription_repo.count_by_domain(stream_domain)
if subscription_count == 0:
# There are 0 subscriptions, we need to validate this domain is an Owncast stream.
# No subscriptions; validate this is an Owncast stream.
is_valid = await self.owncast_client.validate_instance(stream_domain)
if not is_valid:
# The stream state fetch returned nothing. Probably not an Owncast stream.
# 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."
"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 the requested stream domain in the room the command was executed in
# 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 as exception:
# Something weird happened... Was it due to attempting to insert a duplicate row?
# Was it a duplicate row?
if "UNIQUE constraint failed" in exception.args[0]:
# Yes, this is an expected condition. Tell the user the room is already subscribed and give up.
# Expected: room is already subscribed.
await evt.reply(
"This room is already subscribed to notifications for "
+ stream_domain
+ "."
)
return
else:
# Nope... Something unexpected happened. Give up.
self.log.error(
f"[{stream_domain}] An error occurred while attempting to add subscription in room {evt.room_id}: {exception}"
)
raise exception
# Something unexpected happened. Give up.
self.log.error(
f"[{stream_domain}] An error occurred while "
f"attempting to add subscription in room "
f"{evt.room_id}: {exception}"
)
raise exception
# The subscription was successfully added! Try to add a placeholder row for the stream's state in the streams table.
# Try to add a placeholder row for the stream's state.
try:
await self.stream_repo.create(stream_domain)
# The insert was successful, so this is the first time we're seeing this stream. Log it.
# First time seeing this stream. Log it.
self.log.info(f"[{stream_domain}] Discovered new stream!")
except sqlite3.IntegrityError as exception:
# Attempts to add rows for streams already known is an expected condition. What is anything except that?
# Adding rows for known streams is expected.
if "UNIQUE constraint failed" not in exception.args[0]:
# Something unexpected happened. Give up.
self.log.error(
f"[{stream_domain}] An error occurred while attempting to add stream information after adding subscription: {exception}"
f"[{stream_domain}] An error occurred while "
f"attempting to add stream information "
f"after adding subscription: {exception}"
)
raise exception
# All went well! We added a new subscription and (at least tried) to add a row for the stream state. Tell the user.
# All went well! Tell the user.
self.log.info(f"[{stream_domain}] Subscription added for room {evt.room_id}.")
await evt.reply(
"Subscription added! This room will receive notifications when "
@@ -105,17 +123,15 @@ class CommandHandler:
)
async def unsubscribe(self, evt: MessageEvent, url: str) -> None:
"""
"!unsubscribe" command handler for users to unsubscribe a room from a given stream's notifications.
"""Unsubscribe a room from a stream's notifications.
:param evt: MessageEvent of the message calling the command.
:param url: A string containing the user supplied URL to a stream to try and unsubscribe from.
:return: Nothing.
: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 from the database
# Attempt to delete the requested subscription
result = await self.subscription_repo.remove(stream_domain, evt.room_id)
# Did it work?
@@ -125,80 +141,74 @@ class CommandHandler:
f"[{stream_domain}] Subscription removed for room {evt.room_id}."
)
await evt.reply(
"Subscription removed! This room will no longer receive notifications for "
+ stream_domain
+ "."
"Subscription removed! This room will no "
"longer receive notifications for " + stream_domain + "."
)
elif result == 0:
else:
# No, nothing changed. Tell the user.
await evt.reply(
"This room is already not subscribed to notifications for "
+ stream_domain
+ "."
)
else:
# Somehow more than 1 (or even less than 0 ???) rows were changed... Log it!
self.log.error(
"Encountered strange situation! Expected 0 or 1 rows on DELETE query for removing subscription; got "
+ str(result)
+ " instead. Something very bad may have happened!!!!"
)
def _format_duration(self, timestamp_str: str) -> str:
"""
Calculate and format the duration from a timestamp to now.
"""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")
:param timestamp_str: ISO 8601 timestamp string.
:return: Formatted duration string (e.g., "1 hour", "2 days").
"""
try:
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
delta = now - timestamp
seconds = int(delta.total_seconds())
if seconds < 60:
return f"{seconds} second{'s' if seconds != 1 else ''}"
elif seconds < 3600:
if seconds < 3600:
minutes = seconds // 60
return f"{minutes} minute{'s' if minutes != 1 else ''}"
elif seconds < 86400:
if seconds < 86400:
hours = seconds // 3600
return f"{hours} hour{'s' if hours != 1 else ''}"
else:
days = seconds // 86400
return f"{days} day{'s' if days != 1 else ''}"
days = seconds // 86400
return f"{days} day{'s' if days != 1 else ''}"
except Exception:
return "unknown duration"
async def subscriptions(self, evt: MessageEvent) -> None:
"""
"!subscriptions" command handler for listing all stream subscriptions in the current room.
"""List all stream subscriptions in the current room.
:param evt: MessageEvent of the message calling the command.
:return: Nothing.
"""
# Get all stream domains this room is subscribed to
subscribed_domains = await self.subscription_repo.get_subscribed_streams_for_room(
evt.room_id
subscribed_domains = (
await self.subscription_repo.get_subscribed_streams_for_room(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)
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
# Build the response message body as Markdown
body_text = f"**Subscriptions for this room ({len(subscribed_domains)}):**\n\n"
count = len(subscribed_domains)
body_text = f"**Subscriptions for this room ({count}):**\n\n"
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:
# Stream exists in subscriptions but not in streams table (shouldn't happen)
# Stream in subscriptions but not streams table
body_text += f"- **{domain}** \n"
body_text += f" - Status: Unknown \n"
body_text += " - Status: Unknown \n"
body_text += f" - Link: https://{domain}\n\n"
continue
@@ -221,42 +231,48 @@ class CommandHandler:
duration = self._format_duration(stream_state.last_connect_time)
body_text += f" - Status: Online for {duration} \n"
else:
body_text += f" - Status: Online \n"
body_text += " - Status: Online \n"
elif stream_state.status == StreamStatus.UNKNOWN:
# Stream status is unknown - instance unreachable
body_text += f" - Status: Unknown (instance unreachable) \n"
body_text += " - Status: Unknown (instance unreachable) \n"
else:
# Stream is offline - use last_disconnect_time
if stream_state.last_disconnect_time:
duration = self._format_duration(stream_state.last_disconnect_time)
body_text += f" - Status: Offline for {duration} \n"
else:
body_text += f" - Status: Offline \n"
body_text += " - Status: Offline \n"
# Add stream link (as a sub-bullet)
body_text += f" - Link: https://{domain}\n\n"
# Add help text for unsubscribing
body_text += "\nTo unsubscribe from any of these Owncast instances, use `!unsubscribe <domain>`"
body_text += (
"\nTo unsubscribe from any of these Owncast "
"instances, use `!unsubscribe <domain>`"
)
# Send the response as Markdown
await evt.reply(body_text, markdown=True)
async def live(self, evt: MessageEvent) -> None:
"""
"!live" command handler for listing only currently live streams in the current room.
"""List currently live streams in the current room.
:param evt: MessageEvent of the message calling the command.
:return: Nothing.
"""
# Get all stream domains this room is subscribed to
subscribed_domains = await self.subscription_repo.get_subscribed_streams_for_room(
evt.room_id
subscribed_domains = (
await self.subscription_repo.get_subscribed_streams_for_room(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)
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)
@@ -268,11 +284,17 @@ class CommandHandler:
# Check if there are no live streams
if not live_streams:
await evt.reply("No subscribed Owncast instances are currently live.\n\nUse `!subscriptions` to list all subscriptions.", markdown=True)
await evt.reply(
"No subscribed Owncast instances are currently "
"live.\n\nUse `!subscriptions` to list all "
"subscriptions.",
markdown=True,
)
return
# Build the response message body as Markdown
body_text = f"**Live Owncast instances ({len(live_streams)}):**\n\n"
count = len(live_streams)
body_text = f"**Live Owncast instances ({count}):**\n\n"
for domain, stream_state in live_streams:
# Determine stream name (use domain as fallback)