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
+29
View File
@@ -0,0 +1,29 @@
name: Audit
on:
schedule:
- cron: "0 0 * * 1"
push:
paths: [uv.lock]
pull_request:
paths: [uv.lock]
jobs:
audit:
name: Dependencies
runs-on: logaldeveloper-archlinux
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --frozen
- name: Audit dependencies with pip-audit
run: uv run pip-audit --skip-editable
+46
View File
@@ -0,0 +1,46 @@
name: CD
on:
push:
branches: [master]
tags: ["v*"]
jobs:
build:
name: Build
runs-on: logaldeveloper-archlinux
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --frozen
- name: Compute version
id: version
run: |
version=$(uv run hatch version)
echo "version=$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Write version into maubot.yaml
run: |
sed -i "s/^version: .*/version: ${{ steps.version.outputs.version }}/" maubot.yaml
grep "^version:" maubot.yaml
- name: Build plugin
run: uv run mbc build -o owncastsentry-v${{ steps.version.outputs.version }}.mbp
- name: Upload plugin artifact
uses: https://github.com/christopherhx/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2 # v4
with:
name: owncastsentry-v${{ steps.version.outputs.version }}
path: owncastsentry-v${{ steps.version.outputs.version }}.mbp
+82
View File
@@ -0,0 +1,82 @@
name: CI
on:
push:
pull_request:
jobs:
formatting:
name: Formatting
runs-on: logaldeveloper-archlinux
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --frozen
- name: Check formatting with Ruff
run: uv run ruff format --check --diff .
linting:
name: Linting
runs-on: logaldeveloper-archlinux
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --frozen
- name: Check linting with Ruff
run: uv run ruff check .
type-checking:
name: Type Checking
runs-on: logaldeveloper-archlinux
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --frozen
- name: Check types with Mypy
run: uv run mypy owncastsentry/
spelling:
name: Spelling
runs-on: logaldeveloper-archlinux
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --frozen
- name: Check spelling with codespell
run: uv run codespell
+2
View File
@@ -1,3 +1,5 @@
__pycache__/
*.py[cod]
*$py.class
.venv/
owncastsentry/_version.py
+27 -1
View File
@@ -1,6 +1,7 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
@@ -174,3 +175,28 @@
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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
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.
+44 -70
View File
@@ -1,24 +1,36 @@
# 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.
from typing import Type
"""OwncastSentry maubot plugin."""
from maubot import Plugin, MessageEvent
from typing import TYPE_CHECKING
from maubot import MessageEvent, Plugin # type: ignore[attr-defined]
from maubot.handlers import command
from mautrix.util.async_db import UpgradeTable
from mautrix.util.config import BaseProxyConfig
from .migrations import get_upgrade_table
from .owncast_client import OwncastClient
from .database import StreamRepository, SubscriptionRepository
from .notification_service import NotificationService
from .stream_monitor import StreamMonitor
from .commands import CommandHandler
from .config import Config
from .database import StreamRepository, SubscriptionRepository
from .health_checker import HealthChecker
from .migrations import get_upgrade_table
from .notification_service import NotificationService
from .owncast_client import OwncastClient
from .stream_monitor import StreamMonitor
if TYPE_CHECKING:
from mautrix.util.async_db import Database, UpgradeTable
from mautrix.util.config import BaseProxyConfig
class OwncastSentry(Plugin):
@@ -26,38 +38,31 @@ class OwncastSentry(Plugin):
@classmethod
def get_db_upgrade_table(cls) -> UpgradeTable | None:
"""
Helper method for telling Maubot about our database migrations.
:return: An UpgradeTable with our registered migrations.
"""
"""Return the database upgrade table for Maubot."""
return get_upgrade_table()
@classmethod
def get_config_class(cls) -> Type[BaseProxyConfig]:
"""
Helper method for telling Maubot about our configuration class.
:return: The Config class.
"""
def get_config_class(cls) -> type[BaseProxyConfig]:
"""Return the configuration class for Maubot."""
return Config
async def start(self) -> None:
"""
Method called by Maubot upon startup of the instance.
Initializes all services and registers a recurring task every minute to update the state of all subscribed streams.
"""Initialize all services and register recurring tasks.
:return: Nothing.
Registers a recurring task every minute to update the state of
all subscribed streams.
"""
# Load configuration
self.config.load_and_update()
config: Config = self.config # type: ignore[assignment]
config.load_and_update()
db: Database = self.database # type: ignore[assignment]
# Initialize the Owncast API client
self.owncast_client = OwncastClient(self.log)
self.owncast_client = OwncastClient(self.log, str(self.loader.meta.version))
# Initialize repositories
self.stream_repo = StreamRepository(self.database)
self.subscription_repo = SubscriptionRepository(self.database)
self.stream_repo = StreamRepository(db)
self.subscription_repo = SubscriptionRepository(db)
# Initialize notification service
self.notification_service = NotificationService(
@@ -74,7 +79,7 @@ class OwncastSentry(Plugin):
# Initialize health checker
self.health_checker = HealthChecker(
self.database,
db,
self.owncast_client,
self.log,
)
@@ -91,12 +96,7 @@ class OwncastSentry(Plugin):
self.sched.run_periodically(60, self._update_all_stream_states)
async def _update_all_stream_states(self) -> None:
"""
Wrapper method for updating all stream states.
Fetches list of subscribed domains, delegates to StreamMonitor, and performs health check.
:return: Nothing.
"""
"""Update all stream states and perform health check."""
# Get list of all stream domains with active subscriptions
subscribed_domains = await self.subscription_repo.get_all_subscribed_domains()
@@ -104,60 +104,34 @@ class OwncastSentry(Plugin):
update_result = await self.stream_monitor.update_all_streams(subscribed_domains)
# Perform health check
config: Config = self.config # type: ignore[assignment]
await self.health_checker.perform_health_check(
update_result,
self.config.health_check_endpoint,
config.health_check_endpoint,
)
@command.new(help="Subscribes to a new Owncast stream.")
@command.argument("url")
async def subscribe(self, evt: MessageEvent, url: str) -> None:
"""
Command handler that delegates to CommandHandler.
: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.
"""
"""Delegate subscribe command to CommandHandler."""
await self.command_handler.subscribe(evt, url)
@command.new(help="Unsubscribes from an Owncast stream.")
@command.argument("url")
async def unsubscribe(self, evt: MessageEvent, url: str) -> None:
"""
Command handler that delegates to CommandHandler.
: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.
"""
"""Delegate unsubscribe command to CommandHandler."""
await self.command_handler.unsubscribe(evt, url)
@command.new(help="Lists all stream subscriptions in this room.")
async def subscriptions(self, evt: MessageEvent) -> None:
"""
Command handler that delegates to CommandHandler.
:param evt: MessageEvent of the message calling the command.
:return: Nothing.
"""
"""Delegate subscriptions command to CommandHandler."""
await self.command_handler.subscriptions(evt)
@command.new(help="Lists currently live streams in this room.")
async def live(self, evt: MessageEvent) -> None:
"""
Command handler that delegates to CommandHandler.
:param evt: MessageEvent of the message calling the command.
:return: Nothing.
"""
"""Delegate live command to CommandHandler."""
await self.command_handler.live(evt)
async def stop(self) -> None:
"""
Method called by Maubot upon shutdown of the instance.
Closes the HTTP session.
:return: Nothing.
"""
"""Clean up resources by closing the HTTP session."""
await self.owncast_client.close()
+101 -79
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.
# 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}"
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 ''}"
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)
+15 -11
View File
@@ -1,8 +1,18 @@
# 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.
"""Configuration module for the OwncastSentry plugin."""
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
@@ -11,19 +21,13 @@ class Config(BaseProxyConfig):
"""Configuration class for OwncastSentry plugin."""
def do_update(self, helper: ConfigUpdateHelper) -> None:
"""
Update configuration with user-provided values.
"""Update configuration with user-provided values.
:param helper: ConfigUpdateHelper for copying values.
:return: Nothing.
"""
helper.copy("health_check_endpoint")
@property
def health_check_endpoint(self) -> str:
"""
Get the health check endpoint URL.
:return: The configured endpoint URL or empty string if not set.
"""
return self["health_check_endpoint"]
"""Return the configured health check endpoint URL."""
return self["health_check_endpoint"] # type: ignore[no-any-return]
+78 -88
View File
@@ -1,60 +1,66 @@
# 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.
from typing import Optional, List
from mautrix.util.async_db import Database
"""Database repository classes for OwncastSentry."""
from typing import TYPE_CHECKING
from .models import StreamState
if TYPE_CHECKING:
from mautrix.util.async_db import Database
class StreamRepository:
"""Repository for managing stream data in the database."""
def __init__(self, database: Database):
"""
Initialize the stream repository.
"""Initialize the stream repository.
:param database: The maubot database instance
:param database: The maubot database instance.
"""
self.db = database
async def get_by_domain(self, domain: str) -> Optional[StreamState]:
"""
Get a stream's state by domain.
async def get_by_domain(self, domain: str) -> StreamState | None:
"""Get a stream's state by domain.
:param domain: The stream domain
:return: StreamState if found, None otherwise
:param domain: The stream domain.
:return: StreamState if found, None otherwise.
"""
query = "SELECT * FROM streams WHERE domain=$1"
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
row = await conn.fetchrow(query, domain)
return StreamState.from_db_row(row) if row else None
async def create(self, domain: str) -> None:
"""
Create a new stream entry in the database.
"""Create a new stream entry in the database.
:param domain: The stream domain
:return: Nothing
:param domain: The stream domain.
"""
query = "INSERT INTO streams (domain) VALUES ($1)"
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.execute(query, domain)
async def update(self, state: StreamState) -> None:
"""
Update a stream's state in the database.
"""Update a stream's state in the database.
:param state: The StreamState to save
:return: Nothing
:param state: The StreamState to save.
"""
query = """UPDATE streams
SET name=$1, title=$2, last_connect_time=$3, last_disconnect_time=$4
WHERE domain=$5"""
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.execute(
query,
state.name,
@@ -65,50 +71,43 @@ class StreamRepository:
)
async def exists(self, domain: str) -> bool:
"""
Check if a stream exists in the database.
"""Check if a stream exists in the database.
:param domain: The stream domain
:return: True if exists, False otherwise
:param domain: The stream domain.
:return: True if exists, False otherwise.
"""
result = await self.get_by_domain(domain)
return result is not None
async def increment_failure_counter(self, domain: str) -> None:
"""
Increment the failure counter for a stream by 1.
"""Increment the failure counter for a stream by 1.
:param domain: The stream domain
:return: Nothing
:param domain: The stream domain.
"""
query = """UPDATE streams
SET failure_counter = failure_counter + 1
WHERE domain=$1"""
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.execute(query, domain)
async def reset_failure_counter(self, domain: str) -> None:
"""
Reset the failure counter for a stream to 0.
"""Reset the failure counter for a stream to 0.
:param domain: The stream domain
:return: Nothing
:param domain: The stream domain.
"""
query = """UPDATE streams
SET failure_counter = 0
WHERE domain=$1"""
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.execute(query, domain)
async def delete(self, domain: str) -> None:
"""
Delete a stream record from the database.
"""Delete a stream record from the database.
:param domain: The stream domain
:return: Nothing
:param domain: The stream domain.
"""
query = "DELETE FROM streams WHERE domain=$1"
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.execute(query, domain)
@@ -116,94 +115,85 @@ class SubscriptionRepository:
"""Repository for managing stream subscriptions in the database."""
def __init__(self, database: Database):
"""
Initialize the subscription repository.
"""Initialize the subscription repository.
:param database: The maubot database instance
:param database: The maubot database instance.
"""
self.db = database
async def add(self, domain: str, room_id: str) -> None:
"""
Add a subscription for a room to a stream.
"""Add a subscription for a room to a stream.
:param domain: The stream domain
:param room_id: The Matrix room ID
:return: Nothing
:raises: sqlite3.IntegrityError if subscription already exists
:param domain: The stream domain.
:param room_id: The Matrix room ID.
:raises sqlite3.IntegrityError: If subscription already exists.
"""
query = "INSERT INTO subscriptions (stream_domain, room_id) VALUES ($1, $2)"
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.execute(query, domain, room_id)
async def remove(self, domain: str, room_id: str) -> int:
"""
Remove a subscription for a room from a stream.
"""Remove a subscription for a room from a stream.
:param domain: The stream domain
:param room_id: The Matrix room ID
:return: Number of rows deleted (0 or 1)
:param domain: The stream domain.
:param room_id: The Matrix room ID.
:return: Number of rows deleted (0 or 1).
"""
query = "DELETE FROM subscriptions WHERE stream_domain=$1 AND room_id=$2"
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
result = await conn.execute(query, domain, room_id)
return result.rowcount
return int(result.rowcount)
async def get_subscribed_rooms(self, domain: str) -> List[str]:
"""
Get all room IDs subscribed to a stream.
async def get_subscribed_rooms(self, domain: str) -> list[str]:
"""Get all room IDs subscribed to a stream.
:param domain: The stream domain
:return: List of room IDs
:param domain: The stream domain.
:return: List of room IDs.
"""
query = "SELECT room_id FROM subscriptions WHERE stream_domain=$1"
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
results = await conn.fetch(query, domain)
return [row["room_id"] for row in results]
async def get_subscribed_streams_for_room(self, room_id: str) -> List[str]:
"""
Get all stream domains that a room is subscribed to.
async def get_subscribed_streams_for_room(self, room_id: str) -> list[str]:
"""Get all stream domains that a room is subscribed to.
:param room_id: The Matrix room ID
:return: List of stream domains
:param room_id: The Matrix room ID.
:return: List of stream domains.
"""
query = "SELECT stream_domain FROM subscriptions WHERE room_id=$1"
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
results = await conn.fetch(query, room_id)
return [row["stream_domain"] for row in results]
async def get_all_subscribed_domains(self) -> List[str]:
"""
Get all unique stream domains that have at least one subscription.
async def get_all_subscribed_domains(self) -> list[str]:
"""Get all unique stream domains that have at least one subscription.
:return: List of stream domains
:return: List of stream domains.
"""
query = "SELECT DISTINCT stream_domain FROM subscriptions"
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
results = await conn.fetch(query)
return [row["stream_domain"] for row in results]
async def count_by_domain(self, domain: str) -> int:
"""
Count the number of subscriptions for a given stream domain.
"""Count the number of subscriptions for a given stream domain.
:param domain: The stream domain
:return: Number of subscriptions
:param domain: The stream domain.
:return: Number of subscriptions.
"""
query = "SELECT COUNT(*) FROM subscriptions WHERE stream_domain=$1"
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
result = await conn.fetchrow(query, domain)
return result[0]
return int(result[0])
async def delete_all_for_domain(self, domain: str) -> int:
"""
Delete all subscriptions for a given stream domain.
"""Delete all subscriptions for a given stream domain.
:param domain: The stream domain
:return: Number of subscriptions deleted
:param domain: The stream domain.
:return: Number of subscriptions deleted.
"""
query = "DELETE FROM subscriptions WHERE stream_domain=$1"
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
result = await conn.execute(query, domain)
return result.rowcount
return int(result.rowcount)
+26 -20
View File
@@ -1,11 +1,24 @@
# 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.
"""Health checking service for OwncastSentry."""
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import logging
from mautrix.util.async_db import Database
@@ -22,8 +35,7 @@ class UpdateResult:
@property
def http_healthy(self) -> bool:
"""
Determine HTTP health based on update results.
"""Determine HTTP health based on update results.
HTTP is considered healthy if:
- No streams are subscribed (nothing to check), OR
@@ -45,8 +57,7 @@ class HealthStatus:
@property
def is_healthy(self) -> bool:
"""
Check if all health components are healthy.
"""Check if all health components are healthy.
:return: True if all checks pass.
"""
@@ -62,25 +73,23 @@ class HealthChecker:
owncast_client: OwncastClient,
logger: logging.Logger,
):
"""
Initialize the health checker.
"""Initialize the health checker.
:param database: The maubot database instance.
:param owncast_client: Client for making HTTP requests.
:param logger: Logger instance.
:param logger: Logger instance for debugging.
"""
self.db = database
self.owncast_client = owncast_client
self.log = logger
async def check_database(self) -> bool:
"""
Check if the database is functioning by executing a simple query.
"""Check if the database is functioning by executing a simple query.
:return: True if database is healthy, False otherwise.
"""
try:
async with self.db.acquire() as conn:
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.fetchval("SELECT 1")
return True
except Exception as e:
@@ -92,12 +101,10 @@ class HealthChecker:
update_result: UpdateResult,
endpoint: str,
) -> None:
"""
Perform health check and report to configured endpoint if all healthy.
"""Perform health check and report to configured endpoint if all healthy.
:param update_result: Result of the stream update cycle.
:param endpoint: Health check endpoint URL (empty string to skip reporting).
:return: Nothing.
"""
# Check database health
database_healthy = await self.check_database()
@@ -135,11 +142,9 @@ class HealthChecker:
await self._send_health_report(endpoint)
async def _send_health_report(self, endpoint: str) -> None:
"""
Send a GET request to the health check endpoint.
"""Send a GET request to the health check endpoint.
:param endpoint: The endpoint URL.
:return: Nothing.
"""
try:
async with self.owncast_client.session.get(
@@ -151,7 +156,8 @@ class HealthChecker:
)
else:
self.log.warning(
f"Health check endpoint returned non-success status: {response.status}"
"Health check endpoint returned "
f"non-success status: {response.status}"
)
except Exception as e:
self.log.warning(f"Failed to report health check to endpoint: {e}")
+33 -26
View File
@@ -1,22 +1,31 @@
# 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.
from mautrix.util.async_db import UpgradeTable, Connection
"""Database migration definitions for OwncastSentry."""
from mautrix.util.async_db import Connection, UpgradeTable
upgrade_table = UpgradeTable()
@upgrade_table.register(description="Initial revision")
@upgrade_table.register(description="Initial revision") # type: ignore[arg-type, call-arg, untyped-decorator]
async def upgrade_v1(conn: Connection) -> None:
"""
Runs migrations to upgrade database schema to verison 1 format.
Version 1 is the initial format of the database.
"""Create the initial database schema.
Creates the streams and subscriptions tables.
:param conn: A connection to run the v1 database migration on.
:return: Nothing.
"""
await conn.execute(
"""CREATE TABLE "streams" (
@@ -38,14 +47,16 @@ async def upgrade_v1(conn: Connection) -> None:
)
@upgrade_table.register(description="Fix stream_domain column type from INTEGER to TEXT")
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
description="Fix stream_domain column type from INTEGER to TEXT"
)
async def upgrade_v2(conn: Connection) -> None:
"""
Runs migrations to upgrade database schema to version 2 format.
Version 2 fixes the stream_domain column type in subscriptions table from INTEGER to TEXT.
"""Upgrade database schema to version 2 format.
Fixes the stream_domain column type in the subscriptions table
from INTEGER to TEXT.
:param conn: A connection to run the v2 database migration on.
:return: Nothing.
"""
# Create new subscriptions table with correct schema
await conn.execute(
@@ -62,21 +73,21 @@ async def upgrade_v2(conn: Connection) -> None:
SELECT stream_domain, room_id FROM subscriptions"""
)
# Drop the old table
# Drop the old table and rename new table to original name
await conn.execute("DROP TABLE subscriptions")
# Rename new table to original name
await conn.execute("ALTER TABLE subscriptions_new RENAME TO subscriptions")
@upgrade_table.register(description="Add failure_counter column for backoff and auto-cleanup")
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
description="Add failure_counter column for backoff and auto-cleanup"
)
async def upgrade_v3(conn: Connection) -> None:
"""
Runs migrations to upgrade database schema to version 3 format.
Version 3 adds the failure_counter column to track connection failures for backoff and auto-cleanup.
"""Upgrade database schema to version 3 format.
Adds the failure_counter column to track connection failures
for backoff and auto-cleanup.
:param conn: A connection to run the v3 database migration on.
:return: Nothing.
"""
# Add failure_counter column with default value of 0
await conn.execute(
@@ -85,9 +96,5 @@ async def upgrade_v3(conn: Connection) -> None:
def get_upgrade_table() -> UpgradeTable:
"""
Helper function for retrieving the upgrade table.
:return: The upgrade table with registered migrations.
"""
"""Return the upgrade table with registered migrations."""
return upgrade_table
+38 -33
View File
@@ -1,12 +1,22 @@
# 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.
from dataclasses import dataclass
"""Data models for OwncastSentry."""
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, List
from typing import Any
from .utils import (
MAX_INSTANCE_TITLE_LENGTH,
@@ -30,30 +40,32 @@ class StreamState:
"""Represents the state of an Owncast stream."""
domain: str
name: Optional[str] = None
title: Optional[str] = None
last_connect_time: Optional[str] = None
last_disconnect_time: Optional[str] = None
name: str | None = None
title: str | None = None
last_connect_time: str | None = None
last_disconnect_time: str | None = None
failure_counter: int = 0
@property
def status(self) -> StreamStatus:
"""Returns the stream status considering failure_counter."""
"""Derive stream status from failure count and connect times.
Returns UNKNOWN if failures exceed the threshold, ONLINE if a
connect time is present, or OFFLINE otherwise.
"""
if self.failure_counter > UNKNOWN_STATUS_THRESHOLD:
return StreamStatus.UNKNOWN
elif self.last_connect_time is not None:
if self.last_connect_time is not None:
return StreamStatus.ONLINE
else:
return StreamStatus.OFFLINE
@classmethod
def from_api_response(cls, response: dict, domain: str) -> "StreamState":
"""
Creates a StreamState from an API response.
def from_api_response(cls, response: dict[str, Any], domain: str) -> StreamState:
"""Create a StreamState from an API response.
:param response: API response as a dictionary (camelCase keys)
:param domain: The stream domain
:return: StreamState instance
:param response: API response as a dictionary (camelCase keys).
:param domain: The stream domain.
:return: StreamState instance.
"""
return cls(
domain=domain,
@@ -63,12 +75,11 @@ class StreamState:
)
@classmethod
def from_db_row(cls, row: dict) -> "StreamState":
"""
Creates a StreamState from a database row.
def from_db_row(cls, row: dict[str, Any]) -> StreamState:
"""Create a StreamState from a database row.
:param row: Database row as a dictionary
:return: StreamState instance
:param row: Database row as a dictionary.
:return: StreamState instance.
"""
return cls(
domain=row["domain"],
@@ -85,20 +96,14 @@ class StreamConfig:
"""Represents the configuration of an Owncast stream."""
name: str = ""
tags: List[str] = None
def __post_init__(self):
"""Initialize default values after dataclass initialization."""
if self.tags is None:
self.tags = []
tags: list[str] = field(default_factory=list)
@classmethod
def from_api_response(cls, response: dict) -> "StreamConfig":
"""
Creates a StreamConfig from an API response.
def from_api_response(cls, response: dict[str, Any]) -> StreamConfig:
"""Create a StreamConfig from an API response.
:param response: API response as a dictionary
:return: StreamConfig instance
:param response: API response as a dictionary.
:return: StreamConfig instance.
"""
# Truncate instance name to max length
name = truncate(response.get("name", ""), MAX_INSTANCE_TITLE_LENGTH)
+93 -78
View File
@@ -1,54 +1,71 @@
# 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.
"""Notification service for sending Matrix messages."""
import time
import asyncio
from typing import List
import time
from typing import TYPE_CHECKING, Any
from mautrix.types import TextMessageEventContent, MessageType
from mautrix.types import MessageType, TextMessageEventContent
from .utils import SECONDS_BETWEEN_NOTIFICATIONS, sanitize_for_plain_text
if TYPE_CHECKING:
import logging
from .database import SubscriptionRepository
from .utils import SECONDS_BETWEEN_NOTIFICATIONS, sanitize_for_plain_text
class NotificationService:
"""Service for sending Matrix notifications about stream events."""
def __init__(self, client, subscription_repo: SubscriptionRepository, logger):
"""
Initialize the notification service.
def __init__(
self,
client: Any,
subscription_repo: SubscriptionRepository,
logger: logging.Logger,
) -> None:
"""Initialize the notification service.
:param client: The Matrix client for sending messages
:param subscription_repo: Repository for managing subscriptions
:param logger: Logger instance
:param client: The Matrix client for sending messages.
:param subscription_repo: Repository for managing subscriptions.
:param logger: Logger instance for debugging.
"""
self.client = client
self.subscription_repo = subscription_repo
self.log = logger
# Cache for tracking when notifications were last sent
self.notification_timers_cache = {}
self.notification_timers_cache: dict[str, float] = {}
async def notify_stream_live(
self,
domain: str,
name: str,
title: str,
tags: List[str],
tags: list[str],
*,
title_change: bool = False,
) -> None:
"""
Sends notifications to rooms with subscriptions to the provided stream domain.
"""Send notifications to rooms subscribed to a stream.
:param domain: The domain of the stream to send notifications for.
:param name: The name of the stream to include in the message.
:param title: The title of the stream to include in the message.
:param domain: The stream domain to send notifications for.
:param name: The stream name to include in the message.
:param title: The stream title to include in the message.
:param tags: List of stream tags to include in the message.
:param title_change: Whether or not this is for a stream changing its title rather than going live.
:return: Nothing.
:param title_change: Whether this is a title change notification.
"""
# Has enough time passed since the last notification was sent?
if not self._can_notify(domain):
@@ -56,7 +73,10 @@ class NotificationService:
time.time() - self.notification_timers_cache[domain]
)
self.log.info(
f"[{domain}] Not sending notifications. Only {seconds_since_last} of required {SECONDS_BETWEEN_NOTIFICATIONS} seconds has passed since last notification."
f"[{domain}] Not sending notifications. Only "
f"{seconds_since_last} of required "
f"{SECONDS_BETWEEN_NOTIFICATIONS} seconds have "
f"passed since last notification."
)
return
@@ -74,10 +94,9 @@ class NotificationService:
failed_notifications = 0
# Send notifications to all subscribed rooms in parallel
# IMPROVEMENT: Parallel notification delivery with asyncio.gather (was a TODO in original code)
tasks = []
for room_id in room_ids:
tasks.append(self._send_notification(room_id, body_text, domain))
tasks = [
self._send_notification(room_id, body_text, domain) for room_id in room_ids
]
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -91,42 +110,42 @@ class NotificationService:
# Log completion
notification_type = "title change" if title_change else "going live"
self.log.info(
f"[{domain}] Completed sending {notification_type} notifications! {successful_notifications} succeeded, {failed_notifications} failed."
f"[{domain}] Completed sending {notification_type} "
f"notifications! {successful_notifications} succeeded, "
f"{failed_notifications} failed."
)
async def _send_notification(
self, room_id: str, body_text: str, domain: str
) -> None:
"""
Send a notification to a single room.
"""Send a notification to a single room.
:param room_id: The Matrix room ID to send to
:param body_text: The message body text
:param domain: The stream domain (for logging)
:return: Nothing
:raises: Exception if sending fails
:param room_id: The Matrix room ID to send to.
:param body_text: The message body text.
:param domain: The stream domain (for logging).
:raises Exception: If sending fails.
"""
try:
content = TextMessageEventContent(msgtype=MessageType.TEXT, body=body_text)
await self.client.send_message(room_id, content)
except Exception as exception:
self.log.warning(
f"[{domain}] Failed to send notification message to room [{room_id}]: {exception}"
f"[{domain}] Failed to send notification "
f"message to room [{room_id}]: {exception}"
)
raise
def _format_message(
self, name: str, title: str, domain: str, tags: List[str], title_change: bool
self, name: str, title: str, domain: str, tags: list[str], title_change: bool
) -> str:
"""
Format the notification message body.
"""Format the notification message body.
:param name: The stream name
:param title: The stream title
:param domain: The stream domain
:param tags: List of stream tags
:param title_change: Whether this is a title change notification
:return: Formatted message body
:param name: The stream name.
:param title: The stream title.
:param domain: The stream domain.
:param tags: List of stream tags.
:param title_change: Whether this is a title change notification.
:return: Formatted message body.
"""
# Use name if available, fallback to domain
stream_name = name if name else domain
@@ -151,7 +170,7 @@ class NotificationService:
safe_tags = []
for tag in tags:
safe_tag = sanitize_for_plain_text(tag)
if safe_tag and not safe_tag.startswith('.'):
if safe_tag and not safe_tag.startswith("."):
safe_tags.append(safe_tag)
if safe_tags:
@@ -161,49 +180,45 @@ class NotificationService:
return body_text
def _can_notify(self, domain: str) -> bool:
"""
Check if enough time has passed to send another notification.
"""Check if enough time has passed to send another notification.
:param domain: The stream domain
:return: True if notification can be sent, False otherwise
:param domain: The stream domain.
:return: True if notification can be sent, False otherwise.
"""
if domain not in self.notification_timers_cache:
return True
seconds_since_last = round(time.time() - self.notification_timers_cache[domain])
return seconds_since_last >= SECONDS_BETWEEN_NOTIFICATIONS
return bool(seconds_since_last >= SECONDS_BETWEEN_NOTIFICATIONS)
def _record_notification(self, domain: str) -> None:
"""
Record that a notification was sent at the current time.
"""Record that a notification was sent at the current time.
:param domain: The stream domain
:return: Nothing
:param domain: The stream domain.
"""
self.notification_timers_cache[domain] = time.time()
async def send_cleanup_warning(self, domain: str) -> None:
"""
Send 83-day warning notification to all subscribed rooms.
"""Send cleanup warning notification to all subscribed rooms.
:param domain: The stream domain
:return: Nothing
:param domain: The stream domain.
"""
# Get all subscribed rooms
room_ids = await self.subscription_repo.get_subscribed_rooms(domain)
# Build the warning message
body_text = (
f"⚠️ Warning: Subscription Cleanup Scheduled\n\n"
f"The Owncast instance at {domain} has been unreachable for 83 days. "
f"If it remains unreachable for 7 more days (90 days total), this "
f"subscription will be automatically removed."
"⚠️ Warning: Subscription Cleanup Scheduled\n\n"
f"The Owncast instance at {domain} has been "
f"unreachable for 83 days. If it remains unreachable "
f"for 7 more days (90 days total), this subscription "
f"will be automatically removed."
)
# Send to all rooms in parallel
tasks = []
for room_id in room_ids:
tasks.append(self._send_notification(room_id, body_text, domain))
tasks = [
self._send_notification(room_id, body_text, domain) for room_id in room_ids
]
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -216,28 +231,27 @@ class NotificationService:
)
async def send_cleanup_deletion(self, domain: str) -> None:
"""
Send 90-day deletion notification to all subscribed rooms.
"""Send cleanup deletion notification to all subscribed rooms.
:param domain: The stream domain
:return: Nothing
:param domain: The stream domain.
"""
# Get all subscribed rooms
room_ids = await self.subscription_repo.get_subscribed_rooms(domain)
# Build the deletion message
body_text = (
f"🗑️ Subscription Automatically Removed\n\n"
f"The Owncast instance at {domain} has been unreachable for 90 days "
f"and has been automatically removed from subscriptions in this room.\n\n"
f"If the instance comes online again and you want to resubscribe, "
f"run `!subscribe {domain}`."
"🗑️ Subscription Automatically Removed\n\n"
f"The Owncast instance at {domain} has been "
f"unreachable for 90 days and has been automatically "
f"removed from subscriptions in this room.\n\n"
f"If the instance comes online again and you want to "
f"resubscribe, run `!subscribe {domain}`."
)
# Send to all rooms in parallel
tasks = []
for room_id in room_ids:
tasks.append(self._send_notification(room_id, body_text, domain))
tasks = [
self._send_notification(room_id, body_text, domain) for room_id in room_ids
]
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -246,5 +260,6 @@ class NotificationService:
failed = sum(1 for r in results if isinstance(r, Exception))
self.log.info(
f"[{domain}] Sent cleanup deletion notice to {successful} rooms ({failed} failed)."
f"[{domain}] Sent cleanup deletion notice to "
f"{successful} rooms ({failed} failed)."
)
+63 -32
View File
@@ -1,47 +1,68 @@
# 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.
"""HTTP client for querying Owncast instance APIs."""
import json
from typing import TYPE_CHECKING
import aiohttp
import json
from typing import Optional
if TYPE_CHECKING:
import logging
from .models import StreamConfig, StreamState
from .utils import OWNCAST_STATUS_PATH, OWNCAST_CONFIG_PATH, USER_AGENT
from .utils import OWNCAST_CONFIG_PATH, OWNCAST_STATUS_PATH, user_agent
class OwncastClient:
"""HTTP client for communicating with Owncast instances."""
def __init__(self, logger):
"""
Initialize the Owncast client with an HTTP session.
def __init__(self, logger: logging.Logger, version: str) -> None:
"""Initialize the Owncast client with an HTTP session.
:param logger: Logger instance for debugging
:param version: Plugin version string for the User-Agent header
"""
self.log = logger
# Set up HTTP session configuration
headers = {"User-Agent": USER_AGENT}
headers = {"User-Agent": user_agent(version)}
cookie_jar = aiohttp.DummyCookieJar()
connector = aiohttp.TCPConnector(
use_dns_cache=False, limit=1000, limit_per_host=1, keepalive_timeout=120
use_dns_cache=False,
limit=1000,
limit_per_host=1,
keepalive_timeout=120,
)
timeout = aiohttp.ClientTimeout(sock_connect=5, sock_read=5)
self.session = aiohttp.ClientSession(
headers=headers, cookie_jar=cookie_jar, timeout=timeout, connector=connector
headers=headers,
cookie_jar=cookie_jar,
timeout=timeout,
connector=connector,
)
async def get_stream_state(self, domain: str) -> Optional[StreamState]:
"""
Get the current stream state for a given domain.
HTTPS on port 443 is assumed, no other protocols or ports are supported.
async def get_stream_state(self, domain: str) -> StreamState | None:
"""Get the current stream state for a given domain.
HTTPS on port 443 is assumed, no other protocols or ports
are supported.
:param domain: The domain (not URL) where the stream is hosted.
:return: A StreamState with stream state if available, None if an error occurred.
:return: A StreamState if available, None on error.
"""
self.log.debug(f"[{domain}] Fetching current stream state...")
status_url = "https://" + domain + OWNCAST_STATUS_PATH
@@ -60,20 +81,24 @@ class OwncastClient:
# Check the response code is success
if response.status != 200:
self.log.warning(
f"[{domain}] Response to request on {OWNCAST_STATUS_PATH} was not 200, got {response.status} instead."
f"[{domain}] Response to request on "
f"{OWNCAST_STATUS_PATH} was not 200, "
f"got {response.status} instead."
)
return None
# Try and interpret the response as JSON
# Try to interpret the response as JSON
try:
new_state = json.loads(await response.read())
except Exception as e:
self.log.warning(
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as could not be interpreted as JSON: {e}"
f"[{domain}] Rejecting response to request on "
f"{OWNCAST_STATUS_PATH} as could not be "
f"interpreted as JSON: {e}"
)
return None
# Validate the response to ensure it contains all the basic info needed to function
# Validate the response contains all basic info needed
required_fields = [
"lastConnectTime",
"lastDisconnectTime",
@@ -83,19 +108,22 @@ class OwncastClient:
for field in required_fields:
if field not in new_state:
self.log.warning(
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as it does not have {field} parameter."
f"[{domain}] Rejecting response to request "
f"on {OWNCAST_STATUS_PATH} as it does not "
f"have {field} field."
)
return None
return StreamState.from_api_response(new_state, domain)
async def get_stream_config(self, domain: str) -> Optional[StreamConfig]:
"""
Get the current stream config for a given domain.
HTTPS on port 443 is assumed, no other protocols or ports are supported.
async def get_stream_config(self, domain: str) -> StreamConfig | None:
"""Get the current stream config for a given domain.
HTTPS on port 443 is assumed, no other protocols or ports
are supported.
:param domain: The domain (not URL) where the stream is hosted.
:return: A StreamConfig with the stream's configuration, or None if fetch failed.
:return: A StreamConfig, or None if fetch failed.
"""
self.log.debug(f"[{domain}] Fetching current stream config...")
config_url = "https://" + domain + OWNCAST_CONFIG_PATH
@@ -114,25 +142,28 @@ class OwncastClient:
# Check the response code is success
if response.status != 200:
self.log.warning(
f"[{domain}] Response to request on {OWNCAST_CONFIG_PATH} was not 200, got {response.status} instead."
f"[{domain}] Response to request on "
f"{OWNCAST_CONFIG_PATH} was not 200, "
f"got {response.status} instead."
)
return None
# Try and interpret the response as JSON
# Try to interpret the response as JSON
try:
config = json.loads(await response.read())
except Exception as e:
self.log.warning(
f"[{domain}] Rejecting response to request on {OWNCAST_CONFIG_PATH} as could not be interpreted as JSON: {e}"
f"[{domain}] Rejecting response to request on "
f"{OWNCAST_CONFIG_PATH} as could not be "
f"interpreted as JSON: {e}"
)
return None
# Create StreamConfig with validated fields
# Create StreamConfig from response (fields are truncated to max lengths)
return StreamConfig.from_api_response(config)
async def validate_instance(self, domain: str) -> bool:
"""
Validate that a domain is a valid Owncast instance.
"""Validate that a domain is a valid Owncast instance.
:param domain: The domain to validate
:return: True if valid Owncast instance, False otherwise
+98 -64
View File
@@ -1,23 +1,39 @@
# 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.
"""Stream monitoring service for OwncastSentry."""
import asyncio
import time
from typing import TYPE_CHECKING
from .owncast_client import OwncastClient
from .database import StreamRepository, SubscriptionRepository
from .notification_service import NotificationService
from .database import SubscriptionRepository
from .health_checker import UpdateResult
from .models import StreamState
from .utils import (
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
CLEANUP_WARNING_THRESHOLD,
CLEANUP_DELETE_THRESHOLD,
CLEANUP_WARNING_THRESHOLD,
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
should_query_stream,
)
from .health_checker import UpdateResult
if TYPE_CHECKING:
import logging
from .database import StreamRepository
from .notification_service import NotificationService
from .owncast_client import OwncastClient
class StreamMonitor:
@@ -28,15 +44,14 @@ class StreamMonitor:
owncast_client: OwncastClient,
stream_repo: StreamRepository,
notification_service: NotificationService,
logger,
):
"""
Initialize the stream monitor.
logger: logging.Logger,
) -> None:
"""Initialize the stream monitor.
:param owncast_client: Client for making API calls to Owncast instances
:param stream_repo: Repository for stream data
:param notification_service: Service for sending notifications
:param logger: Logger instance
:param owncast_client: Client for making API calls to Owncast instances.
:param stream_repo: Repository for stream data.
:param notification_service: Service for sending notifications.
:param logger: Logger instance for debugging.
"""
self.owncast_client = owncast_client
self.stream_repo = stream_repo
@@ -44,12 +59,13 @@ class StreamMonitor:
self.log = logger
# Cache for tracking when streams last went offline
self.offline_timer_cache = {}
self.offline_timer_cache: dict[str, float] = {}
async def update_all_streams(self, subscribed_domains: list[str]) -> UpdateResult:
"""
Checks the status of all streams with active subscriptions.
Updates for all streams are performed asynchronously, with the method returning when the slowest update completes.
"""Check the status of all streams with active subscriptions.
Updates for all streams are performed asynchronously, with the
method returning when the slowest update completes.
:param subscribed_domains: List of stream domains to update.
:return: UpdateResult with success/failure counts.
@@ -58,10 +74,11 @@ class StreamMonitor:
total_streams = len(subscribed_domains)
# Build a list of async tasks which update the state for each stream domain
tasks = []
for domain in subscribed_domains:
tasks.append(asyncio.create_task(self.update_stream(domain)))
# Build a list of async tasks for each stream domain
tasks = [
asyncio.create_task(self.update_stream(domain))
for domain in subscribed_domains
]
# Run the tasks in parallel and collect results
results = await asyncio.gather(*tasks)
@@ -82,12 +99,14 @@ class StreamMonitor:
)
async def update_stream(self, domain: str) -> bool:
"""
Updates the state of a given stream domain and sends notifications to subscribed Matrix rooms if it goes live.
Implements progressive backoff for connection failures and auto-cleanup for dead instances.
"""Update the state of a stream and send notifications as needed.
Sends notifications to subscribed Matrix rooms if a stream goes
live. Implements progressive backoff for connection failures and
auto-cleanup for dead instances.
:param domain: The domain of the stream to update.
:return: True if the stream check succeeded (or was skipped due to backoff), False on connection failure.
:return: True if check succeeded or was skipped, False on failure.
"""
# Fetch the current stream state from database to check failure_counter
old_state = await self.stream_repo.get_by_domain(domain)
@@ -98,21 +117,28 @@ class StreamMonitor:
# Skip this cycle, increment counter to track time passage
await self.stream_repo.increment_failure_counter(domain)
self.log.debug(
f"[{domain}] Skipping query due to backoff (counter={failure_counter + 1})"
f"[{domain}] Skipping query due to backoff "
f"(counter={failure_counter + 1})"
)
# Check cleanup thresholds even when skipping query
await self._check_cleanup_thresholds(domain, failure_counter + 1)
# Backoff is expected behavior, not a failure
return True
# A flag indicating whether this is the first state update of a brand-new stream to avoid sending notifications if its already live.
# Defensive check: old_state should always exist here since
# the stream is in the DB
if old_state is None:
return False
# Flag: first state update of a brand-new stream to avoid
# sending notifications if it's already live.
first_update = False
# A flag indicating whether to update the stream's state in the database.
# Used to avoid writing to the database when a stream's state hasn't changed at all.
# Flag: whether to update the stream's state in the database.
# Used to avoid writes when state hasn't changed at all.
update_database = False
# Holds the stream's latest configuration, if fetched and deemed necessary during the update process.
# The stream's latest configuration, if fetched during update.
stream_config = None
# Fetch the latest stream state from the server
@@ -132,13 +158,13 @@ class StreamMonitor:
# Fetch succeeded! Reset failure counter
await self.stream_repo.reset_failure_counter(domain)
# Fix possible race conditions with timers
# Initialize timer cache entries to prevent KeyError on first access
if domain not in self.offline_timer_cache:
self.offline_timer_cache[domain] = 0
if domain not in self.notification_service.notification_timers_cache:
self.notification_service.notification_timers_cache[domain] = 0
# Does the last known stream state not have a value for the last connect and disconnect time?
# Does the last known stream state lack connect/disconnect?
if (
old_state.last_connect_time is None
and old_state.last_disconnect_time is None
@@ -147,7 +173,7 @@ class StreamMonitor:
update_database = True
first_update = True
# Does the latest stream state have a last connect time and the old state not have one?
# Does the new state have a connect time but the old one not?
if (
new_state.last_connect_time is not None
and old_state.last_connect_time is None
@@ -158,47 +184,56 @@ class StreamMonitor:
self.log.info(f"[{domain}] Stream is now live!")
# Calculate how many seconds since the stream last went offline
# Calculate seconds since the stream last went offline
seconds_since_last_offline = round(
time.time() - self.offline_timer_cache[domain]
)
# Have we queried this stream before? (In other words, is this not the first state update ever?)
# Have we queried this stream before?
if not first_update:
# Use fallback values if config fetch failed
stream_name = stream_config.name if stream_config else domain
stream_tags = stream_config.tags if stream_config else []
# Yes. Has this stream been offline for a short amount of time?
# Has this stream been offline for a short time?
if seconds_since_last_offline < TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN:
# Yes. Did the stream title change?
# Did the stream title change?
if old_state.title != new_state.title:
# Yes. The stream was only down for a short time, send a special notification indicating the stream changed its name.
# Stream was briefly down; send title
# change notification.
await self.notification_service.notify_stream_live(
domain,
stream_name,
new_state.title,
new_state.title or "",
stream_tags,
title_change=True,
)
else:
# No. The stream was only down for a short time and didn't change its title. Don't send a notification.
# Briefly offline, no title change. Skip.
self.log.info(
f"[{domain}] Not sending notifications. Stream was only offline for {seconds_since_last_offline} of {TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN} seconds and did not change its title."
f"[{domain}] Not sending "
f"notifications. Stream was only "
f"offline for "
f"{seconds_since_last_offline} of "
f"{TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN}"
f" seconds and did not change its "
f"title."
)
else:
# This stream has been offline for a while. Send a normal notification.
# Offline for a while. Send a normal notification.
await self.notification_service.notify_stream_live(
domain,
stream_name,
new_state.title,
new_state.title or "",
stream_tags,
title_change=False,
)
else:
# No, this is the first time we're querying
self.log.info(
f"[{domain}] Not sending notifications. This is the first state update for this stream."
f"[{domain}] Not sending notifications. "
f"This is the first state update for "
f"this stream."
)
if (
@@ -215,19 +250,17 @@ class StreamMonitor:
stream_name = stream_config.name if stream_config else domain
stream_tags = stream_config.tags if stream_config else []
# This is a fun case to account for... Let's try and explain this.
# Was the last time this stream sent a notification before it last went offline?
# Was the last notification sent before the stream
# last went offline? If so, send a regular go-live
# instead of a title change to avoid confusion.
if (
self.offline_timer_cache[domain]
> self.notification_service.notification_timers_cache[domain]
):
# Yes. Send a regular go live notification.
# Why? A title change notification could be confusing to users in this case.
# How? If a stream goes offline before its next allowed notification, it'll get rate limited. If it then changes its title, this part of the code will send a title change notification. This can be a little confusing, so override to a normal go live notification in this case.
await self.notification_service.notify_stream_live(
domain,
stream_name,
new_state.title,
new_state.title or "",
stream_tags,
title_change=False,
)
@@ -236,12 +269,12 @@ class StreamMonitor:
await self.notification_service.notify_stream_live(
domain,
stream_name,
new_state.title,
new_state.title or "",
stream_tags,
title_change=True,
)
# Does the latest stream state no longer have a last connect time but the old state does?
# Did the stream go offline (old had connect, new doesn't)?
elif (
new_state.last_connect_time is None
and old_state.last_connect_time is not None
@@ -254,9 +287,9 @@ class StreamMonitor:
else:
self.log.info(f"[{domain}] Stream is now offline.")
# Update the database with the current stream state, if needed.
# Update the database with current stream state, if needed.
if update_database:
# Ensure we have the stream config before updating the database
# Ensure we have the stream config before updating
if stream_config is None:
stream_config = await self.owncast_client.get_stream_config(domain)
@@ -281,12 +314,10 @@ class StreamMonitor:
return True
async def _check_cleanup_thresholds(self, domain: str, counter: int) -> None:
"""
Check if a domain has hit cleanup warning or deletion thresholds.
"""Check if a domain hit cleanup warning or deletion thresholds.
:param domain: The domain to check
:param counter: The current failure counter value
:return: Nothing
:param domain: The domain to check.
:param counter: The current failure counter value.
"""
# Check for 83-day warning threshold
if counter == CLEANUP_WARNING_THRESHOLD:
@@ -298,7 +329,8 @@ class StreamMonitor:
# Check for 90-day deletion threshold
if counter >= CLEANUP_DELETE_THRESHOLD:
self.log.warning(
f"[{domain}] Reached 90-day deletion threshold. Removing all subscriptions."
f"[{domain}] Reached 90-day deletion threshold."
f" Removing all subscriptions."
)
# Send deletion notification
await self.notification_service.send_cleanup_deletion(domain)
@@ -311,5 +343,7 @@ class StreamMonitor:
await self.stream_repo.delete(domain)
self.log.info(
f"[{domain}] Cleanup complete. Deleted {deleted_count} subscriptions and stream record."
f"[{domain}] Cleanup complete. "
f"Deleted {deleted_count} subscriptions "
f"and stream record."
)
+92 -73
View File
@@ -1,8 +1,18 @@
# 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.
"""Utility functions and constants for OwncastSentry."""
import re
from urllib.parse import urlparse
@@ -13,23 +23,34 @@ OWNCAST_STATUS_PATH = "/api/status"
# Path to GetWebConfig API call on Owncast instances
OWNCAST_CONFIG_PATH = "/api/config"
# User agent to send with all HTTP requests.
USER_AGENT = (
"OwncastSentry/1.1.0 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)"
def user_agent(version: str) -> str:
"""Build the User-Agent header string for HTTP requests.
:param version: The plugin version string.
:return: A formatted User-Agent string.
"""
return (
f"OwncastSentry/{version}"
" (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)"
)
# Hard minimum amount of time between when notifications can be sent for a stream. Prevents spamming notifications for glitchy or malicious streams.
# Hard minimum amount of time between when notifications can be sent
# for a stream. Prevents spamming notifications for glitchy or
# malicious streams.
SECONDS_BETWEEN_NOTIFICATIONS = 20 * 60 # 20 minutes in seconds
# I'm not sure the best way to name or explain this variable, so let's just say what uses it:
#
# After a stream goes offline, a timer is started. Then, ...
# - If a stream comes back online with the same title within this time, no notification is sent.
# - If a stream comes back online with a different title, a rename notification is sent.
# - If this time period passes entirely and a stream comes back online after, it's treated as regular going live.
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN = 7 * 60 # 7 minutes in seconds
# - If a stream comes back online with the same title within this
# time, no notification is sent.
# - If a stream comes back online with a different title, a rename
# notification is sent.
# - If this time period passes entirely and a stream comes back
# online after, it's treated as regular going live.
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN = 7 * 60 # 7 min in seconds
# Counter thresholds for auto-cleanup (based on 60-second polling intervals)
# Counter thresholds for auto-cleanup (60-second polling intervals)
CLEANUP_WARNING_THRESHOLD = 83 * 24 * 60 # 119,520 cycles = 83 days
CLEANUP_DELETE_THRESHOLD = 90 * 24 * 60 # 129,600 cycles = 90 days
@@ -37,40 +58,44 @@ CLEANUP_DELETE_THRESHOLD = 90 * 24 * 60 # 129,600 cycles = 90 days
UNKNOWN_STATUS_THRESHOLD = 15
# Maximum field lengths based on Owncast's configuration
# Source: https://github.com/owncast/owncast/blob/master/web/utils/config-constants.tsx
# Source: https://github.com/owncast/owncast/blob/master/
# web/utils/config-constants.tsx
MAX_INSTANCE_TITLE_LENGTH = 255 # Server Name (line 81)
MAX_STREAM_TITLE_LENGTH = 100 # Stream Title (line 91)
MAX_TAG_LENGTH = 24 # Per tag (line 208)
def should_query_stream(failure_counter: int) -> bool:
"""
Determine if a stream should be queried based on its failure counter.
Implements progressive backoff: 60s (5min) -> 2min (5min) -> 3min (5min) -> 5min (15min) -> 15min.
"""Determine if a stream should be queried based on failure count.
:param failure_counter: The current failure counter value
:return: True if the stream should be queried this cycle, False otherwise
Implements progressive backoff with increasing intervals:
- Counters 0-4: every 60s (first 5 minutes)
- Counters 5-9: every 2min (next 5 minutes)
- Counters 10-14: every 3min (next 5 minutes)
- Counters 15-29: every 5min (next 15 minutes)
- Counters 30+: every 15min
:param failure_counter: The current failure counter value.
:return: True if the stream should be queried this cycle.
"""
if failure_counter <= 4:
# Query every 60s for first 5 minutes (counters 0-4)
return True
elif failure_counter <= 9:
if failure_counter <= 9:
# Query every 2 minutes for next 5 minutes (counters 5-9)
return (failure_counter * 60) % 120 == 0
elif failure_counter <= 14:
if failure_counter <= 14:
# Query every 3 minutes for next 5 minutes (counters 10-14)
return (failure_counter * 60) % 180 == 0
elif failure_counter <= 29:
if failure_counter <= 29:
# Query every 5 minutes for next 15 minutes (counters 15-29)
return (failure_counter * 60) % 300 == 0
else:
# Query every 15 minutes after 30 minutes (counter 30+)
return (failure_counter * 60) % 900 == 0
def domainify(url: str) -> str:
"""
Extract and sanitize a domain from user input.
"""Extract and sanitize a domain from user input.
Handles URLs, bare domains, and email-style input (user@domain).
Only allows valid domain characters (alphanumeric, hyphens, periods).
@@ -83,22 +108,21 @@ def domainify(url: str) -> str:
url = url.split("@")[-1]
# Prepend // if no scheme so urlparse treats input as netloc
if not url.startswith(('http://', 'https://', '//')):
url = '//' + url
if not url.startswith(("http://", "https://", "//")):
url = "//" + url
parsed = urlparse(url)
domain = (parsed.netloc or parsed.path).lower()
# Strip port and path
domain = domain.split(':')[0].split('/')[0]
domain = domain.split(":")[0].split("/")[0]
# Allow only valid domain characters
return re.sub(r'[^a-z0-9.-]', '', domain).strip('.-')
return re.sub(r"[^a-z0-9.-]", "", domain).strip(".-")
def truncate(text: str, max_length: int) -> str:
"""
Truncate text to a maximum length.
"""Truncate text to a maximum length.
:param text: The text to truncate
:param max_length: Maximum allowed length
@@ -110,8 +134,7 @@ def truncate(text: str, max_length: int) -> str:
def escape_markdown(text: str) -> str:
"""
Escape Markdown special characters to prevent injection attacks.
"""Escape Markdown special characters to prevent injection attacks.
This function sanitizes untrusted external input (like stream names and titles)
before embedding them in Markdown-formatted messages. It prevents malicious
@@ -127,27 +150,27 @@ def escape_markdown(text: str) -> str:
# Covers: formatting (*_~`), links ([]()), headings (#), lists (-+),
# blockquotes (>), code blocks (```), and other special characters
special_chars = {
'\\': '\\\\', # Backslash must be first to avoid double-escaping
'*': '\\*',
'_': '\\_',
'[': '\\[',
']': '\\]',
'(': '\\(',
')': '\\)',
'~': '\\~',
'`': '\\`',
'#': '\\#',
'+': '\\+',
'-': '\\-',
'=': '\\=',
'|': '\\|',
'{': '\\{',
'}': '\\}',
'.': '\\.',
'!': '\\!',
'<': '\\<',
'>': '\\>',
'&': '\\&',
"\\": "\\\\", # Backslash must be first to avoid double-escaping
"*": "\\*",
"_": "\\_",
"[": "\\[",
"]": "\\]",
"(": "\\(",
")": "\\)",
"~": "\\~",
"`": "\\`",
"#": "\\#",
"+": "\\+",
"-": "\\-",
"=": "\\=",
"|": "\\|",
"{": "\\{",
"}": "\\}",
".": "\\.",
"!": "\\!",
"<": "\\<",
">": "\\>",
"&": "\\&",
}
escaped_text = text
@@ -158,11 +181,11 @@ def escape_markdown(text: str) -> str:
def sanitize_for_plain_text(text: str) -> str:
"""
Sanitize text for plain text rendering.
"""Sanitize text for plain text rendering.
Removes newlines and normalizes whitespace without escaping special characters.
Use this for plain text notifications where escaping would show literal backslashes.
Remove newlines and normalize whitespace without escaping
special characters. Use this for plain text notifications where
escaping would show literal backslashes.
:param text: The text to sanitize
:return: Sanitized text
@@ -171,23 +194,21 @@ def sanitize_for_plain_text(text: str) -> str:
return text
# Remove newlines and carriage returns to prevent multi-line injection
sanitized = text.replace('\n', ' ').replace('\r', ' ')
sanitized = text.replace("\n", " ").replace("\r", " ")
# Collapse multiple spaces into single space
sanitized = ' '.join(sanitized.split())
return sanitized
return " ".join(sanitized.split())
def sanitize_for_markdown(text: str) -> str:
"""
Sanitize text for safe Markdown rendering.
"""Sanitize text for safe Markdown rendering.
Removes newlines, normalizes whitespace, and escapes Markdown special characters.
Use this for any untrusted external content before embedding in Markdown messages.
Remove newlines, normalize whitespace, and escape Markdown special
characters. Use this for any untrusted external content before
embedding in Markdown messages.
Note: This function does not truncate. Size limits should be enforced at the
model layer (e.g., in from_api_response methods).
Note: This function does not truncate. Size limits should be
enforced at the model layer (e.g., in from_api_response methods).
:param text: The text to sanitize
:return: Sanitized and escaped text safe for Markdown rendering
@@ -196,12 +217,10 @@ def sanitize_for_markdown(text: str) -> str:
return text
# Remove newlines and carriage returns to prevent multi-line injection
sanitized = text.replace('\n', ' ').replace('\r', ' ')
sanitized = text.replace("\n", " ").replace("\r", " ")
# Collapse multiple spaces into single space
sanitized = ' '.join(sanitized.split())
sanitized = " ".join(sanitized.split())
# Escape Markdown special characters
sanitized = escape_markdown(sanitized)
return sanitized
return escape_markdown(sanitized)
+96
View File
@@ -0,0 +1,96 @@
[project]
name = "owncastsentry"
dynamic = ["version"]
description = "A maubot plugin that monitors Owncast streams and sends Matrix notifications"
authors = [
{ name = "Logan Fick" },
]
license = "Apache-2.0"
requires-python = ">=3.14"
dependencies = [
"maubot[encryption]",
]
[project.urls]
Repository = "https://git.logal.dev/LogalDeveloper/OwncastSentry"
[build-system]
requires = ["hatchling>=1.28.0", "hatch-vcs>=0.5.0"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"codespell>=2.4.2",
"hatch>=1.16.5",
"mypy>=1.19.1",
"pip-audit>=2.10.0",
"ruff>=0.15.5",
]
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "owncastsentry/_version.py"
[tool.mypy]
python_version = "3.14"
strict = true
warn_unreachable = true
explicit_package_bases = true
exclude = ["owncastsentry/_version\\.py"]
[tool.ruff]
target-version = "py314"
extend-exclude = ["owncastsentry/_version.py"] # auto-generated by hatch-vcs
[tool.ruff.lint]
select = [
# Core
"F", # Pyflakes
"E", # pycodestyle errors
"W", # pycodestyle warnings
"N", # pep8-naming
"D", # pydocstyle
"I", # isort
"ICN", # flake8-import-conventions
# Correctness & bugs
"B", # flake8-bugbear
"ASYNC", # flake8-async
"DTZ", # flake8-datetimez
"RSE", # flake8-raise
"RET", # flake8-return
"A", # flake8-builtins
"PIE", # flake8-pie
# Modernization & simplification
"UP", # pyupgrade
"SIM", # flake8-simplify
"C4", # flake8-comprehensions
"FLY", # flynt (f-string conversion)
"PTH", # flake8-use-pathlib
# Performance
"PERF", # Perflint
# Security
"S", # flake8-bandit
# Code hygiene
"T10", # flake8-debugger
"T20", # flake8-print
"ERA", # eradicate
"PGH", # pygrep-hooks
"TC", # flake8-type-checking
# Ruff-specific
"RUF", # Ruff-specific rules
]
ignore = [
"D203", # incompatible with D211 (no blank line before class docstring)
"D213", # incompatible with D212 (summary on first line)
]
[tool.codespell]
skip = "uv.lock"
Generated
+1486
View File
File diff suppressed because it is too large Load Diff