32 Commits
Author SHA1 Message Date
LogalDeveloper 9be932c0c7 Replaced health checker with Prometheus metrics service.
Audit / Dependencies (push) Failing after 8s
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 14s
CI / Spelling (push) Successful in 13s
2026-03-25 08:54:38 -04:00
LogalDeveloper 295a8c90a9 Improved code quality with more idiomatic Python patterns.
CD / Build (push) Successful in 7s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 20s
CI / Spelling (push) Successful in 32s
2026-03-24 11:02:13 -04:00
LogalDeveloper 653c27c97c Updated dependencies.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 22s
CI / Type Checking (push) Successful in 13s
CI / Spelling (push) Successful in 9s
Audit / Dependencies (push) Successful in 9s
2026-03-19 09:25:30 -04:00
LogalDeveloper 1112f7c0cd Added compressed plugin builds and artifact checksums to CD workflow.
Audit / Dependencies (push) Successful in 8s
CD / Build (push) Successful in 7s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 10s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
2026-03-14 17:15:53 -04:00
LogalDeveloper 90aa6afcc6 Added None guards to satisfy mypy strict union-attr checks.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 10s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
2026-03-14 16:51:37 -04:00
LogalDeveloper a01ad9440c Aligned mypy scope with config and removed unused ruff exclusion.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 10s
CI / Type Checking (push) Failing after 8s
CI / Spelling (push) Successful in 5s
2026-03-14 16:44:55 -04:00
LogalDeveloper 8931db33e8 Removed unreachable defensive checks and added tests for edge case display states.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Failing after 9s
CI / Spelling (push) Successful in 6s
2026-03-14 12:25:07 -04:00
LogalDeveloper 99b257b90a Added comprehensive test suite with CI workflow.
Audit / Dependencies (push) Successful in 8s
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s
2026-03-14 12:15:33 -04:00
LogalDeveloper 58993ff5ae Derived hardcoded day counts in cleanup messages from threshold constants.
CD / Build (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 5s
2026-03-12 21:24:59 -04:00
LogalDeveloper 440b2130f4 Removed redundant length check on tags list.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 5s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
2026-03-12 21:18:13 -04:00
LogalDeveloper 1429e2de11 Wrapped HTTP responses in async context managers to prevent connection leaks.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 5s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 5s
2026-03-12 15:43:03 -04:00
LogalDeveloper afa1584ec5 Fixed Ruff formatting issue introduced in previous commit.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s
2026-03-11 16:12:12 -04:00
LogalDeveloper a70fe972c4 Replaced direct notification timer cache access with a public method.
CD / Build (push) Successful in 9s
CI / Formatting (push) Failing after 5s
CI / Linting (push) Successful in 6s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
2026-03-11 15:49:51 -04:00
LogalDeveloper b8447e190c Fixed stream monitor instantiating its own subscription repository.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s
2026-03-11 15:36:43 -04:00
LogalDeveloper 282b16b51c Simplified progressive backoff arithmetic for readability.
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
2026-03-11 15:09:39 -04:00
LogalDeveloper d05d73eddc 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.
2026-03-11 14:37:56 -04:00
LogalDeveloper 314e1bf399 Added configurable health check endpoint for external uptime monitoring integration. 2026-01-15 21:59:44 -05:00
LogalDeveloper 8c772ab19e Fixed markdown escape characters appearing literally in plain text notifications. 2026-01-08 18:50:55 -05:00
LogalDeveloper 548f90f0c0 Moved inline imports to module level. 2026-01-07 13:21:27 -05:00
LogalDeveloper f62764a2b2 Fixed unreachable streams reporting stale online status indefinitely. 2026-01-07 11:33:55 -05:00
LogalDeveloper dc0df47257 Added comprehensive sanitization and refactored to use typed API response models. 2026-01-07 11:30:59 -05:00
LogalDeveloper 35086cb751 Added progressive backoff and auto-cleanup for unreachable Owncast instances. (Closes #2 and closes #3) 2026-01-06 18:16:37 -05:00
LogalDeveloper b6beef0e48 Fixed subscriptions table using wrong data type for domain. 2026-01-06 16:00:35 -05:00
LogalDeveloper c6430a4110 Added commands for listing all subscriptions and currently live streams. (Closes #1) 2026-01-06 15:27:29 -05:00
LogalDeveloper 7c10d15dd6 Refactored plugin into multi-file package structure. 2026-01-06 13:33:23 -05:00
LogalDeveloper b177114540 Removed Gitea actions workflows. 2026-01-06 11:36:27 -05:00
LogalDeveloper 1d35ee6d7c Converted line endings to Unix style.
Build Maubot Plugin Artifact / Build (push) Successful in 4s
Lint Source Code / Lint (push) Failing after 12s
2026-01-06 11:34:50 -05:00
LogalDeveloper 764fab9879 Fixed newly discovered streams never sending notifications.
Build Maubot Plugin Artifact / Build (push) Successful in 3s
Lint Source Code / Lint (push) Failing after 11s
2025-05-04 08:44:37 -04:00
LogalDeveloper 0659f70e1a Added notifications for stream title changes along with more advanced rate limit logic.
Build Maubot Plugin Artifact / Build (push) Successful in 3s
Lint Source Code / Lint (push) Successful in 8s
The rate limit logic has been updated to account for streams going down temporarily and factor in when the stream title changes. I think I've accounted for most of the ways these events can happen, but there's probably still a few race conditions left over. It runs well enough in the development environment, it's time to collect some data in production.
2025-04-12 17:29:40 -04:00
LogalDeveloper ee61ea8562 Added rate limiting to notifications to limit them to once per 20 minutes per stream.
Build Maubot Plugin Artifact / Build (push) Successful in 3s
Lint Source Code / Lint (push) Successful in 8s
There's also a small amount of preliminary work included in this commit for a new type of notification when streams change their title in the middle of a session.
2025-04-12 15:43:05 -04:00
LogalDeveloper b0868c5bd4 Updated version number in plugin metadata for v1.0.3 release.
Build Maubot Plugin Artifact / Build (push) Successful in 3s
Lint Source Code / Lint (push) Successful in 8s
2025-04-05 23:09:29 -04:00
LogalDeveloper 1c6720cc10 Fixed regression for notifications containing extra lines for streams with tags configured.
Build Maubot Plugin Artifact / Build (push) Successful in 3s
Lint Source Code / Lint (push) Successful in 8s
I'm not sure how this made it through from the development environment to the Git repository. Evidently I messed it up somehow and didn't notice.
2025-04-05 19:36:40 -04:00
33 changed files with 7531 additions and 566 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
-20
View File
@@ -1,20 +0,0 @@
name: Build Maubot Plugin Artifact
on: [push]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build artifact
run: zip -v -9 OwncastSentry-v${{env.GITHUB_SHA}}.mbp LICENSE.txt maubot.yaml owncastsentry.py
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: Maubot Plugin
path: OwncastSentry-v${{env.GITHUB_SHA}}.mbp
+52
View File
@@ -0,0 +1,52 @@
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" >> "$GITEA_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: 7z a -tzip -mx=9 -xr!__pycache__ owncastsentry-v${{ steps.version.outputs.version }}.mbp owncastsentry/ maubot.yaml base-config.yaml LICENSE.txt
- name: Print artifact checksums
run: |
echo "MD5: $(md5sum owncastsentry-v${{ steps.version.outputs.version }}.mbp)"
echo "SHA1: $(sha1sum owncastsentry-v${{ steps.version.outputs.version }}.mbp)"
echo "SHA256: $(sha256sum 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
+104
View File
@@ -0,0 +1,104 @@
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 .
tests:
name: Tests
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: Run unit tests with Pytest
run: uv run pytest -v --cov --cov-report=
- name: Report code coverage
run: uv run coverage report
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 .
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
-19
View File
@@ -1,19 +0,0 @@
name: Lint Source Code
on: [push]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python environment
uses: actions/setup-python@v5
with:
python-version: 3.13
- name: Run linter
uses: psf/black@stable
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
*$py.class
.venv/
owncastsentry/_version.py
.coverage
+27 -1
View File
@@ -1,6 +1,7 @@
Apache License Apache License
Version 2.0, January 2004 Version 2.0, January 2004
https://www.apache.org/licenses/ http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
@@ -174,3 +175,28 @@
of your accepting any such warranty or additional liability. of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS 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.
+4
View File
@@ -0,0 +1,4 @@
# Prometheus metrics endpoint.
# When enabled, a /metrics endpoint is exposed via the maubot webapp.
# Disabled by default.
metrics_enabled: false
+7 -1
View File
@@ -1,9 +1,15 @@
maubot: 0.1.0 maubot: 0.1.0
id: dev.logal.owncastsentry id: dev.logal.owncastsentry
version: 1.0.2 version: 1.1.0
license: Apache-2.0 license: Apache-2.0
modules: modules:
- owncastsentry - owncastsentry
main_class: OwncastSentry main_class: OwncastSentry
database: true database: true
database_type: asyncpg database_type: asyncpg
config: true
webapp: true
dependencies:
- prometheus_client>=0.24.1
extra_files:
- base-config.yaml
-517
View File
@@ -1,517 +0,0 @@
# Copyright 2025 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
#
# 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.
import sqlite3
import aiohttp
import json
import asyncio
from maubot import Plugin, MessageEvent
from maubot.handlers import command
from mautrix.types import TextMessageEventContent, MessageType
from mautrix.util.async_db import UpgradeTable, Connection
from urllib.parse import urlparse
# Path to the GetStatus API call on Owncast instances
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.0.2 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)"
)
# ===== DATABASE MIGRATIONS =====
upgrade_table = UpgradeTable()
@upgrade_table.register(description="Initial revision")
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.
:param conn: A connection to run the v1 database migration on.
:return: Nothing.
"""
await conn.execute(
"""CREATE TABLE "streams" (
"domain" TEXT NOT NULL UNIQUE,
"name" TEXT,
"title" TEXT,
"last_connect_time" TEXT,
"last_disconnect_time" TEXT,
PRIMARY KEY("domain")
)"""
)
await conn.execute(
"""CREATE TABLE "subscriptions" (
"stream_domain" INTEGER NOT NULL,
"room_id" TEXT NOT NULL,
UNIQUE("room_id","stream_domain")
)"""
)
# ===== MAIN BOT CLASS =====
class OwncastSentry(Plugin):
# Helper variables for setting up a special HTTP ClientSession for monitoring streams.
headers = {
"User-Agent": USER_AGENT
} # Override User Agent to special static value to identify to servers our purpose.
cookie_jar = aiohttp.DummyCookieJar() # Ignore cookies.
baseconnctor = aiohttp.TCPConnector(
use_dns_cache=False, limit=1000, limit_per_host=1, keepalive_timeout=120
) # Set up timeouts to keep one long-lived connection open per monitored stream.
timeouts = aiohttp.ClientTimeout(
sock_connect=5, sock_read=5
) # Tighten up timeouts for faster responses to users in case of broken servers.
# Final HTTP ClientSession for all requests to Owncast instances.
session = aiohttp.ClientSession(
headers=headers, cookie_jar=cookie_jar, timeout=timeouts, connector=baseconnctor
)
@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 upgrade_table
async def start(self) -> None:
"""
Method called by Maubot upon startup of the instance.
Registers a recurring task every minute to update the state of all subscribed streams.
:return: Nothing.
"""
self.sched.run_periodically(60, self.update_all_stream_states)
@command.new(help="Subscribes to a new Owncast stream.")
@command.argument("url")
async def subscribe(self, evt: MessageEvent, url: str) -> None:
"""
"!subscribe" command handler for users to subscribe a room to a given 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.
"""
# Convert the user input to only a domain.
stream_domain = self.domainify(url)
# How many subscriptions already exist for this domain?
query = "SELECT COUNT(*) FROM subscriptions WHERE stream_domain=$1"
async with self.database.acquire() as connection:
result = await connection.fetchrow(query, stream_domain)
if result[0] == 0:
# There are 0 subscriptions, we need to validate this domain is an Owncast stream.
# Attempt to fetch the stream state from this domain.
stream_state = await self.get_stream_state(stream_domain)
if len(stream_state) == 0:
# The stream state fetch returned nothing. Probably not an Owncast stream.
await evt.reply(
"The URL you supplied does not appear to be a valid Owncast instance. You may have specified an invalid domain, or the instance is offline."
)
return
# Try to add a new subscription for the requested stream domain in the room the command was executed in.
try:
query = "INSERT INTO subscriptions (stream_domain, room_id) VALUES ($1, $2)"
async with self.database.acquire() as connection:
await connection.execute(query, stream_domain, evt.room_id)
except sqlite3.IntegrityError as exception:
# Something weird happened... Was it due to attempting to insert 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.
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}: {e}"
)
raise exception
# The subscription was successfully added! Try to add a placeholder row for the stream's state in the streams table.
try:
query = "INSERT INTO streams (domain) VALUES ($1)"
async with self.database.acquire() as connection:
await connection.execute(query, stream_domain)
# The insert was successful, so this is the first time we're 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?
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: {e}"
)
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.
self.log.info(f"[{stream_domain}] Subscription added for room {evt.room_id}.")
await evt.reply(
"Subscription added! This room will receive notifications when "
+ stream_domain
+ " goes live."
)
@command.new(help="Unsubscribes from an Owncast stream.")
@command.argument("url")
async def unsubscribe(self, evt: MessageEvent, url: str) -> None:
"""
"!unsubscribe" command handler for users to unsubscribe a room from a given 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.
"""
# Convert the user input to only a domain.
stream_domain = self.domainify(url)
# Attempt to delete the requested subscription from the database.
query = "DELETE FROM subscriptions WHERE stream_domain=$1 AND room_id=$2"
async with self.database.acquire() as connection:
result = await connection.execute(query, stream_domain, evt.room_id)
# Did it work?
if result.rowcount == 1:
# Yes, one row was deleted. Tell the user.
self.log.info(
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
+ "."
)
elif result.rowcount == 0:
# 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 "
+ result.rowcount
+ " instead. Something very bad may have happened!!!!"
)
async def update_all_stream_states(self) -> None:
"""
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.
:return: Nothing.
"""
self.log.debug("Updating all stream states...")
# Make a list of all stream domains with an active subscription.
query = "SELECT DISTINCT stream_domain FROM subscriptions"
async with self.database.acquire() as connection:
results = await connection.fetch(query)
# Build a list of async tasks which update the state for each stream domain fetched.
tasks = []
for row in results:
domain = row["stream_domain"]
tasks.append(asyncio.create_task(self.update_stream_state(domain)))
# Run the tasks in parallel.
await asyncio.gather(*tasks)
self.log.debug("Update complete.")
async def update_stream_state(self, domain: str) -> None:
"""
Updates the state of a given stream domain and sends notifications to subscribed Matrix rooms if it goes live.
:param domain: The domain of the stream to update.
:return: Nothing.
"""
# A flag indicating whether to send a notification.
# Used for the first state update of a brand-new stream to avoid sending notifications if its already live.
send_notifications = True
# Fetch the latest stream state from the server.
new_state = await self.get_stream_state(domain)
# Skip the update if the fetch failed for any reason.
if new_state == {}:
return
# Fetch the last known stream state from the database.
query = "SELECT last_connect_time, last_disconnect_time FROM streams WHERE domain=$1"
async with self.database.acquire() as connection:
old_state = await connection.fetchrow(query, domain)
# Does the last known stream state not have a value for the last connect and disconnect time?
if (
old_state["last_connect_time"] is None
and old_state["last_disconnect_time"] is None
):
# Yes, this is the first update. Don't send any notifications.
send_notifications = False
# Does the latest stream state have a last connect time and the old state not have one?
if (
new_state["lastConnectTime"] is not None
and old_state["last_connect_time"] is None
):
# Yes! This stream is now live. Send notifications and log it, if allowed.
if send_notifications:
self.log.info(
f"[{domain}] Stream is now live! Notifying subscribed rooms..."
)
await self.notify_rooms_of_stream_online(
domain, new_state["streamTitle"]
)
else:
self.log.info(
f"[{domain}] Stream is live, but performed first update. WIll not notify subscribed rooms."
)
# Does the latest stream state no longer have a last connect time but the old state does?
elif (
new_state["lastConnectTime"] is None
and old_state["last_connect_time"] is not None
):
# Yep. This stream is now offline. Log it.
if send_notifications:
self.log.info(f"[{domain}] Stream is now offline.")
else:
self.log.info(f"[{domain}] Stream is offline.")
# Update the database with the current stream state.
# TODO: Only update the database if a change actually occurred. This is probably generating useless writes.
update_query = "UPDATE streams SET name=$1, last_connect_time=$2, last_disconnect_time=$3 WHERE domain=$4"
async with self.database.acquire() as connection:
await connection.execute(
update_query,
new_state["streamTitle"],
new_state["lastConnectTime"],
new_state["lastDisconnectTime"],
domain,
)
# All done.
self.log.debug(f"[{domain}] State update completed.")
async def notify_rooms_of_stream_online(self, domain: str, title: str) -> None:
"""
Sends notifications to rooms with subscriptions to the provided stream domain.
:param domain: The domain of the stream to send notifications for.
:param title: The title of the stream to include in the message.
:return: Nothing.
"""
# Get a list of room IDs with active subscriptions to the stream domain.
query = "SELECT room_id FROM subscriptions WHERE stream_domain=$1"
async with self.database.acquire() as connection:
results = await connection.fetch(query, domain)
# Set up some counters for a high level statistics.
successful_notifications = 0
failed_notifications = 0
stream_config = await self.get_stream_config(domain)
# Build the message body text.
if "name" in stream_config:
body_text = "🎥 " + stream_config["name"] + " is now live!"
else:
# Turns out it is possible to set an empty name for an Owncast stream if you know how.
# We'll account for that... Just in case.
body_text = "🎥 " + domain + " is now live!"
# Streams can have no title. If there is none, don't even mention it.
if title != "":
body_text += "\nStream Title: " + title
body_text += "\n\nTo tune in, visit: https://" + domain + "/\n\n"
if "tags" in stream_config and len(stream_config["tags"]) > 0:
body_text += "\n\n"
body_text += " ".join("#" + tag for tag in stream_config["tags"])
# Iterate over the subscribed rooms and try to send a message to each.
# TODO: This should probably be made async.
for rows in results:
room_id = rows["room_id"]
try:
# Try and send a message.
content = TextMessageEventContent(
msgtype=MessageType.TEXT, body=body_text
)
await self.client.send_message(room_id, content)
# It worked! Increment the counter.
successful_notifications += 1
except Exception as exception:
# Something didn't work. Log it and move on to the next one.
self.log.warning(
f"[{domain}] Failed to send notification message to room [{room_id}]: {exception}"
)
failed_notifications += 1
# All done!
self.log.info(
f"[{domain}] Completed sending notifications! {successful_notifications} succeeded, {failed_notifications} failed."
)
# ========== HELPER METHODS ==========
async def get_stream_state(self, domain):
"""
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 dictionary containing the lastConnectTime, lastDisconnectTime, streamTitle, and online values if available. An empty dictionary if an error occurred.
"""
self.log.debug(f"[{domain}] Fetching current stream state...")
# Build a URL to the status API in Owncast. (https://owncast.online/api/latest/#tag/Internal/operation/GetStatus)
# Only use HTTPS, even if the user specified something else.
status_url = "https://" + domain + OWNCAST_STATUS_PATH
# Make a request to the endpoint.
try:
response = await self.session.request(
"GET", status_url, allow_redirects=False
)
except Exception as e:
self.log.warning(
f"[{domain}] Error making GET request to {OWNCAST_STATUS_PATH}: {e}"
)
return {}
# Check the response code is success
# TODO: Handle 429 rate limiting?
if response.status != 200:
self.log.warning(
f"[{domain}] Response to request on {OWNCAST_STATUS_PATH} was not 200, got {response.status} instead."
)
return {}
# Try and 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}"
)
return {}
# Validate the response to ensure it contains all the basic info needed to function.
if "lastConnectTime" not in new_state:
self.log.warning(
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as it does not have last connect time parameter."
)
return {}
elif "lastDisconnectTime" not in new_state:
self.log.warning(
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as it does not have last disconnect time parameter."
)
return {}
elif "streamTitle" not in new_state:
self.log.warning(
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as it does not have stream title parameter."
)
return {}
elif "online" not in new_state:
self.log.warning(
f"[{domain}] Rejecting response to request on {OWNCAST_STATUS_PATH} as it does not have online status parameter."
)
return {}
return new_state
async def get_stream_config(self, domain):
"""
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 dictionary containing the stream's configuration.
"""
self.log.debug(f"[{domain}] Fetching current stream config...")
# Build a URL to the config API in Owncast. (https://owncast.online/api/latest/#tag/Internal/operation/GetWebConfig)
# Only use HTTPS, even if the user specified something else.
status_url = "https://" + domain + OWNCAST_CONFIG_PATH
# Make a request to the endpoint.
try:
response = await self.session.request(
"GET", status_url, allow_redirects=False
)
except Exception as e:
self.log.warning(
f"[{domain}] Error making GET request to {OWNCAST_STATUS_PATH}: {e}"
)
return {}
# Check the response code is success
# TODO: Handle 429 rate limiting?
if response.status != 200:
self.log.warning(
f"[{domain}] Response to request on {OWNCAST_STATUS_PATH} was not 200, got {response.status} instead."
)
return {}
# Try and 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_STATUS_PATH} as could not be interpreted as JSON: {e}"
)
return {}
return config
def domainify(self, url) -> str:
"""
Take a given URL and convert it to just the domain.
:param url:
:return:
"""
# Take whatever input the user provided and try to turn it into just the domain.
# Examples:
# "stream.logal.dev" -> "stream.logal.dev"
# "https://stream.logal.dev" -> "stream.logal.dev"
# "stream.logal.dev/embed/chat/readwrite" -> "stream.logal.dev"
# "https://stream.logal.dev/abcdefghijklmno/123456789" -> "stream.logal.dev"
# "notify@stream.logal.dev" -> "stream.logal.dev"
parsed_url = urlparse(url)
domain = (parsed_url.netloc or parsed_url.path).lower()
if "@" in domain:
return domain.split("@")[-1]
return domain
+178
View File
@@ -0,0 +1,178 @@
# 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
#
# 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.
"""OwncastSentry maubot plugin."""
from typing import TYPE_CHECKING
from aiohttp import web
from maubot import MessageEvent, Plugin # type: ignore[attr-defined]
from maubot.handlers import command
from prometheus_client.exposition import choose_encoder
from .commands import CommandHandler
from .config import Config
from .database import StreamRepository, SubscriptionRepository
from .metrics import ErrorSource, MetricsService
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):
"""Main plugin class for OwncastSentry."""
@classmethod
def get_db_upgrade_table(cls) -> UpgradeTable | None:
"""Return the database upgrade table for Maubot."""
return get_upgrade_table()
@classmethod
def get_config_class(cls) -> type[BaseProxyConfig]:
"""Return the configuration class for Maubot."""
return Config
async def start(self) -> None:
"""Initialize all services and register recurring tasks.
Registers a recurring task every minute to update the state of
all subscribed streams.
"""
# Load configuration
config: Config = self.config # type: ignore[assignment]
config.load_and_update()
db: Database = self.database # type: ignore[assignment]
# Initialize metrics service and register web endpoint (if enabled)
self.metrics_service = MetricsService()
self.metrics_service.set_build_info(str(self.loader.meta.version))
if config.metrics_enabled and self.webapp is not None:
self.webapp.add_route(
method="GET", path="/metrics", handler=self._metrics_endpoint
)
# Initialize the Owncast API client
self.owncast_client = OwncastClient(
self.log, str(self.loader.meta.version), metrics=self.metrics_service
)
self.metrics_service.register_open_connections_gauge(
lambda: self.owncast_client.open_connection_count
)
# Initialize repositories
self.stream_repo = StreamRepository(db)
self.subscription_repo = SubscriptionRepository(db)
# Initialize notification service
self.notification_service = NotificationService(
self.client,
self.subscription_repo,
self.log,
metrics=self.metrics_service,
)
# Initialize stream monitor
self.stream_monitor = StreamMonitor(
self.owncast_client,
self.stream_repo,
self.subscription_repo,
self.notification_service,
self.log,
metrics=self.metrics_service,
)
# Initialize command handler
self.command_handler = CommandHandler(
self.owncast_client,
self.stream_repo,
self.subscription_repo,
self.log,
)
# Schedule periodic stream state updates every 60 seconds
self.sched.run_periodically(60, self._update_all_stream_states)
async def _update_all_stream_states(self) -> None:
"""Update all stream states."""
try:
# Get list of all stream domains with active subscriptions
subscribed_domains = (
await self.subscription_repo.get_all_subscribed_domains()
)
# Delegate to stream monitor
await self.stream_monitor.update_all_streams(subscribed_domains)
except Exception:
self.metrics_service.record_error(ErrorSource.SCHEDULER_LOOP)
self.log.exception("Unhandled exception in scheduler loop.")
@command.new(help="Subscribes to a new Owncast stream.")
@command.argument("url")
async def subscribe(self, evt: MessageEvent, url: str) -> None:
"""Delegate subscribe command to CommandHandler."""
try:
await self.command_handler.subscribe(evt, url)
except Exception:
self.metrics_service.record_error(ErrorSource.COMMAND)
self.log.exception("Unhandled exception in subscribe command.")
await evt.reply("An unexpected error occurred. Please try again later.")
@command.new(help="Unsubscribes from an Owncast stream.")
@command.argument("url")
async def unsubscribe(self, evt: MessageEvent, url: str) -> None:
"""Delegate unsubscribe command to CommandHandler."""
try:
await self.command_handler.unsubscribe(evt, url)
except Exception:
self.metrics_service.record_error(ErrorSource.COMMAND)
self.log.exception("Unhandled exception in unsubscribe command.")
await evt.reply("An unexpected error occurred. Please try again later.")
@command.new(help="Lists all stream subscriptions in this room.")
async def subscriptions(self, evt: MessageEvent) -> None:
"""Delegate subscriptions command to CommandHandler."""
try:
await self.command_handler.subscriptions(evt)
except Exception:
self.metrics_service.record_error(ErrorSource.COMMAND)
self.log.exception("Unhandled exception in subscriptions command.")
await evt.reply("An unexpected error occurred. Please try again later.")
@command.new(help="Lists currently live streams in this room.")
async def live(self, evt: MessageEvent) -> None:
"""Delegate live command to CommandHandler."""
try:
await self.command_handler.live(evt)
except Exception:
self.metrics_service.record_error(ErrorSource.COMMAND)
self.log.exception("Unhandled exception in live command.")
await evt.reply("An unexpected error occurred. Please try again later.")
async def _metrics_endpoint(self, request: web.Request) -> web.Response:
"""Serve Prometheus metrics."""
accept = request.headers.get("Accept", "")
encoder, content_type = choose_encoder(accept)
output = encoder(self.metrics_service.registry)
response = web.Response(body=output)
response.headers["Content-Type"] = content_type
return response
async def stop(self) -> None:
"""Clean up resources by closing the HTTP session."""
await self.owncast_client.close()
+290
View File
@@ -0,0 +1,290 @@
# 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
#
# 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 UTC, datetime
from typing import TYPE_CHECKING
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 managing stream subscriptions."""
def __init__(
self,
owncast_client: OwncastClient,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
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 for debugging.
"""
self.owncast_client = owncast_client
self.stream_repo = stream_repo
self.subscription_repo = subscription_repo
self.log = logger
async def subscribe(self, evt: MessageEvent, url: str) -> None:
"""Subscribe a room to a stream's notifications.
:param evt: MessageEvent of the message calling the command.
:param url: User supplied URL to a stream to subscribe to.
"""
# Convert the user input to only a domain
stream_domain = domainify(url)
# How many subscriptions already exist for this domain?
subscription_count = await self.subscription_repo.count_by_domain(stream_domain)
if subscription_count == 0:
# No subscriptions; validate this is an Owncast stream.
is_valid = await self.owncast_client.validate_instance(stream_domain)
if not is_valid:
# Fetch returned nothing. Probably not Owncast.
await evt.reply(
"The URL you supplied does not appear to "
"be a valid Owncast instance. You may have "
"specified an invalid domain, or the "
"instance is offline."
)
return
# Try to add a new subscription for this stream in this room
try:
await self.subscription_repo.add(stream_domain, evt.room_id)
except sqlite3.IntegrityError:
# Room is already subscribed.
await evt.reply(
f"This room is already subscribed to notifications for {stream_domain}."
)
return
# Try to add a placeholder row for the stream's state.
try:
await self.stream_repo.create(stream_domain)
# First time seeing this stream. Log it.
self.log.info(f"[{stream_domain}] Discovered new stream!")
except sqlite3.IntegrityError:
# Adding rows for known streams is expected.
pass
# All went well! Tell the user.
self.log.info(f"[{stream_domain}] Subscription added for room {evt.room_id}.")
await evt.reply(
f"Subscription added! This room will receive "
f"notifications when {stream_domain} goes live."
)
async def unsubscribe(self, evt: MessageEvent, url: str) -> None:
"""Unsubscribe a room from a stream's notifications.
:param evt: MessageEvent of the message calling the command.
:param url: User supplied URL to a stream to unsubscribe from.
"""
# Convert the user input to only a domain
stream_domain = domainify(url)
# Attempt to delete the requested subscription
result = await self.subscription_repo.remove(stream_domain, evt.room_id)
# Did it work?
if result == 1:
# Yes, one row was deleted. Tell the user.
self.log.info(
f"[{stream_domain}] Subscription removed for room {evt.room_id}."
)
await evt.reply(
f"Subscription removed! This room will no "
f"longer receive notifications for {stream_domain}."
)
else:
# No, nothing changed. Tell the user.
await evt.reply(
"This room is already not subscribed to "
f"notifications for {stream_domain}."
)
def _format_duration(self, timestamp_str: str) -> str:
"""Calculate and format the duration from a timestamp to now.
:param timestamp_str: ISO 8601 timestamp string.
:return: Formatted duration string (e.g., "1 hour", "2 days").
"""
try:
timestamp = datetime.fromisoformat(timestamp_str)
now = datetime.now(UTC)
delta = now - timestamp
seconds = int(delta.total_seconds())
if seconds < 60:
return f"{seconds} second{'s' if seconds != 1 else ''}"
if seconds < 3600:
minutes = seconds // 60
return f"{minutes} minute{'s' if minutes != 1 else ''}"
if seconds < 86400:
hours = seconds // 3600
return f"{hours} hour{'s' if hours != 1 else ''}"
days = seconds // 86400
return f"{days} day{'s' if days != 1 else ''}"
except ValueError:
return "unknown duration"
async def subscriptions(self, evt: MessageEvent) -> None:
"""List all stream subscriptions in the current room.
:param evt: MessageEvent of the message calling the command.
"""
# Get all stream domains this room is subscribed to
subscribed_domains = (
await self.subscription_repo.get_subscribed_streams_for_room(evt.room_id)
)
# Check if there are no subscriptions
if not subscribed_domains:
await evt.reply(
"This room is not subscribed to any Owncast "
"instances.\n\nTo subscribe to an Owncast "
"instance, use `!subscribe <domain>`",
markdown=True,
)
return
# Build the response message body as Markdown
count = len(subscribed_domains)
parts = [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:
continue
# Determine stream name (use domain as fallback)
stream_name = stream_state.name or domain
safe_stream_name = sanitize_for_markdown(stream_name)
# Start building this stream's entry with stream name as main bullet
parts.append(f"- **{safe_stream_name}** \n")
# Add title if stream is online (as a sub-bullet)
if stream_state.status == StreamStatus.ONLINE and stream_state.title:
safe_title = sanitize_for_markdown(stream_state.title)
parts.append(f" - Title: {safe_title} \n")
# Determine status and duration (as a sub-bullet)
match stream_state.status:
case StreamStatus.ONLINE if stream_state.last_connect_time:
duration = self._format_duration(stream_state.last_connect_time)
parts.append(f" - Status: Online for {duration} \n")
case StreamStatus.UNKNOWN:
parts.append(" - Status: Unknown (instance unreachable) \n")
case StreamStatus.OFFLINE if stream_state.last_disconnect_time:
duration = self._format_duration(stream_state.last_disconnect_time)
parts.append(f" - Status: Offline for {duration} \n")
case StreamStatus.OFFLINE:
parts.append(" - Status: Offline \n")
# Add stream link (as a sub-bullet)
parts.append(f" - Link: https://{domain}\n\n")
# Add help text for unsubscribing
parts.append(
"\nTo unsubscribe from any of these Owncast "
"instances, use `!unsubscribe <domain>`"
)
# Send the response as Markdown
await evt.reply("".join(parts), markdown=True)
async def live(self, evt: MessageEvent) -> None:
"""List currently live streams in the current room.
:param evt: MessageEvent of the message calling the command.
"""
# Get all stream domains this room is subscribed to
subscribed_domains = (
await self.subscription_repo.get_subscribed_streams_for_room(evt.room_id)
)
# Check if there are no subscriptions
if not subscribed_domains:
await evt.reply(
"This room is not subscribed to any Owncast "
"instances.\n\nTo subscribe to an Owncast "
"instance, use `!subscribe <domain>`",
markdown=True,
)
return
# Filter for only live streams (exclude unknown status)
live_streams = []
for domain in subscribed_domains:
stream_state = await self.stream_repo.get_by_domain(domain)
if stream_state and stream_state.status == StreamStatus.ONLINE:
live_streams.append((domain, stream_state))
# Check if there are no live streams
if not live_streams:
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
count = len(live_streams)
parts = [f"**Live Owncast instances ({count}):**\n\n"]
for domain, stream_state in live_streams:
# Determine stream name (use domain as fallback)
stream_name = stream_state.name or domain
safe_stream_name = sanitize_for_markdown(stream_name)
# Start building this stream's entry with stream name as main bullet
parts.append(f"- **{safe_stream_name}** \n")
# Add title (should be present for live streams)
if stream_state.title:
safe_title = sanitize_for_markdown(stream_state.title)
parts.append(f" - Title: {safe_title} \n")
# Add status with duration
if stream_state.last_connect_time:
duration = self._format_duration(stream_state.last_connect_time)
parts.append(f" - Online for {duration} \n")
# Add stream link
parts.append(f" - Link: https://{domain}\n\n")
# Send the response as Markdown
await evt.reply("".join(parts).rstrip(), markdown=True)
+33
View File
@@ -0,0 +1,33 @@
# 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
#
# 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
class Config(BaseProxyConfig):
"""Configuration class for OwncastSentry plugin."""
def do_update(self, helper: ConfigUpdateHelper) -> None:
"""Update configuration with user-provided values.
:param helper: ConfigUpdateHelper for copying values.
"""
helper.copy("metrics_enabled")
@property
def metrics_enabled(self) -> bool:
"""Return whether the Prometheus metrics endpoint is enabled."""
return self["metrics_enabled"] # type: ignore[no-any-return]
+199
View File
@@ -0,0 +1,199 @@
# 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
#
# 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.
"""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.
:param database: The maubot database instance.
"""
self.db = database
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.
"""
query = "SELECT * FROM streams WHERE domain=$1"
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.
:param domain: The stream domain.
"""
query = "INSERT INTO streams (domain) VALUES ($1)"
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.
: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: # type: ignore[var-annotated]
await conn.execute(
query,
state.name,
state.title,
state.last_connect_time,
state.last_disconnect_time,
state.domain,
)
async def exists(self, domain: str) -> bool:
"""Check if a stream exists in the database.
: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.
:param domain: The stream domain.
"""
query = """UPDATE streams
SET failure_counter = failure_counter + 1
WHERE domain=$1"""
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.
:param domain: The stream domain.
"""
query = """UPDATE streams
SET failure_counter = 0
WHERE domain=$1"""
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.
:param domain: The stream domain.
"""
query = "DELETE FROM streams WHERE domain=$1"
async with self.db.acquire() as conn: # type: ignore[var-annotated]
await conn.execute(query, domain)
class SubscriptionRepository:
"""Repository for managing stream subscriptions in the database."""
def __init__(self, database: Database):
"""Initialize the subscription repository.
: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.
: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: # 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.
: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: # type: ignore[var-annotated]
result = await conn.execute(query, domain, room_id)
return int(result.rowcount)
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.
"""
query = "SELECT room_id FROM subscriptions WHERE stream_domain=$1"
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.
: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: # 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.
:return: List of stream domains.
"""
query = "SELECT DISTINCT stream_domain FROM subscriptions"
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.
: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: # type: ignore[var-annotated]
result = await conn.fetchrow(query, domain)
return int(result[0])
async def delete_all_for_domain(self, domain: str) -> int:
"""Delete all subscriptions for a given stream domain.
: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: # type: ignore[var-annotated]
result = await conn.execute(query, domain)
return int(result.rowcount)
+234
View File
@@ -0,0 +1,234 @@
# 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
#
# 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.
"""Prometheus metrics service for OwncastSentry."""
import time
from contextlib import contextmanager, suppress
from enum import StrEnum
from typing import TYPE_CHECKING
from prometheus_client import CollectorRegistry, Counter, Gauge, Info
from .models import StreamStatus
class NotificationType(StrEnum):
"""Notification type labels for the delivery counter."""
LIVE = "live"
TITLE_CHANGE = "title_change"
CLEANUP_WARNING = "cleanup_warning"
CLEANUP_DELETION = "cleanup_deletion"
class ErrorSource(StrEnum):
"""Error source labels for the error counter."""
SCHEDULER_LOOP = "scheduler_loop"
COMMAND = "command"
if TYPE_CHECKING:
from collections.abc import Callable, Generator
# Mapping from StreamStatus enum to numeric gauge values
_STATUS_VALUES: dict[StreamStatus, float] = {
StreamStatus.ONLINE: 1.0,
StreamStatus.OFFLINE: 0.0,
StreamStatus.UNKNOWN: -1.0,
}
class _ResponseTimer:
"""Timer that only records an observation when explicitly marked successful."""
__slots__ = ("_domain", "_gauge", "_should_observe", "_start")
def __init__(self, gauge: Gauge, domain: str) -> None:
self._gauge = gauge
self._domain = domain
self._start = time.monotonic()
self._should_observe = False
def success(self) -> None:
"""Mark the request as successful so the duration is recorded."""
self._should_observe = True
def _finalize(self) -> None:
if self._should_observe:
self._gauge.labels(domain=self._domain).set(
max(time.monotonic() - self._start, 0)
)
else:
with suppress(KeyError):
self._gauge.remove(self._domain)
class MetricsService:
"""Manages Prometheus metrics with an isolated registry."""
def __init__(self) -> None:
"""Initialize metrics with a custom collector registry."""
self.registry = CollectorRegistry()
self.notification_delivery_total = Counter(
"owncastsentry_notification_delivery_total",
"Total notification delivery attempts to individual rooms",
["type", "result"],
registry=self.registry,
)
self.stream_status = Gauge(
"owncastsentry_stream_status",
"Current stream status (1=online, 0=offline, -1=unknown)",
["domain"],
registry=self.registry,
)
self.stream_subscriptions = Gauge(
"owncastsentry_stream_subscriptions",
"Number of room subscriptions per stream domain",
["domain"],
registry=self.registry,
)
self.check_failures = Gauge(
"owncastsentry_check_failures",
"Consecutive check failure count per stream domain",
["domain"],
registry=self.registry,
)
self.api_response_seconds = Gauge(
"owncastsentry_api_response_seconds",
"Last successful HTTP response time in seconds per stream domain",
["domain"],
registry=self.registry,
)
self.build_info = Info(
"owncastsentry",
"OwncastSentry build information",
registry=self.registry,
)
self.open_connections = Gauge(
"owncastsentry_http_connections_open",
"Total number of open HTTP connections (idle and active)",
registry=self.registry,
)
self.errors_total = Counter(
"owncastsentry_errors_total",
"Internal errors by source",
["source"],
registry=self.registry,
)
# Initialize all known label combinations so they start at 0
for notification_type in NotificationType:
for result in ("success", "failure"):
self.notification_delivery_total.labels(
type=notification_type, result=result
)
for source in ErrorSource:
self.errors_total.labels(source=source)
def record_delivery(
self,
notification_type: NotificationType,
*,
successful: int = 0,
failed: int = 0,
) -> None:
"""Record notification delivery results.
:param notification_type: The type of notification delivered.
:param successful: Number of successful room deliveries.
:param failed: Number of failed room deliveries.
"""
self.notification_delivery_total.labels(
type=notification_type, result="success"
).inc(successful)
self.notification_delivery_total.labels(
type=notification_type, result="failure"
).inc(failed)
def set_stream_status(self, domain: str, status: StreamStatus) -> None:
"""Set the status gauge for a stream domain.
:param domain: The stream domain.
:param status: The current stream status.
"""
self.stream_status.labels(domain=domain).set(_STATUS_VALUES[status])
def set_check_failures(self, domain: str, count: int) -> None:
"""Set the consecutive failure count for a stream domain.
:param domain: The stream domain.
:param count: The current failure counter value.
"""
self.check_failures.labels(domain=domain).set(count)
def set_subscription_count(self, domain: str, count: int) -> None:
"""Set the subscription count for a stream domain.
:param domain: The stream domain.
:param count: The number of room subscriptions.
"""
self.stream_subscriptions.labels(domain=domain).set(count)
@contextmanager
def response_timer(self, domain: str) -> Generator[_ResponseTimer]:
"""Return a context manager that times an HTTP request.
Call ``timer.success()`` inside the block to record the duration.
If ``success()`` is never called, nothing is recorded.
:param domain: The stream domain being queried.
"""
timer = _ResponseTimer(self.api_response_seconds, domain)
try:
yield timer
finally:
timer._finalize()
def set_build_info(self, version: str) -> None:
"""Set the build version info metric.
:param version: The plugin version string.
"""
self.build_info.info({"version": version})
def register_open_connections_gauge(self, callback: Callable[[], float]) -> None:
"""Register a gauge that reads open connection count on scrape.
:param callback: Function returning the current open count.
"""
self.open_connections.set_function(callback)
def record_error(self, source: ErrorSource) -> None:
"""Increment the internal error counter.
:param source: The source of the error.
"""
self.errors_total.labels(source=source).inc()
def remove_stream(self, domain: str) -> None:
"""Remove a stream's gauge labels after cleanup deletion.
:param domain: The stream domain to remove.
"""
with suppress(KeyError):
self.stream_status.remove(domain)
with suppress(KeyError):
self.check_failures.remove(domain)
with suppress(KeyError):
self.stream_subscriptions.remove(domain)
with suppress(KeyError):
self.api_response_seconds.remove(domain)
+100
View File
@@ -0,0 +1,100 @@
# 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
#
# 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.
"""Database migration definitions for OwncastSentry."""
from mautrix.util.async_db import Connection, UpgradeTable
upgrade_table = UpgradeTable()
@upgrade_table.register(description="Initial revision") # type: ignore[arg-type, call-arg, untyped-decorator]
async def upgrade_v1(conn: Connection) -> None:
"""Create the initial database schema.
Creates the streams and subscriptions tables.
:param conn: A connection to run the v1 database migration on.
"""
await conn.execute(
"""CREATE TABLE "streams" (
"domain" TEXT NOT NULL UNIQUE,
"name" TEXT,
"title" TEXT,
"last_connect_time" TEXT,
"last_disconnect_time" TEXT,
PRIMARY KEY("domain")
)"""
)
await conn.execute(
"""CREATE TABLE "subscriptions" (
"stream_domain" INTEGER NOT NULL,
"room_id" TEXT NOT NULL,
UNIQUE("room_id","stream_domain")
)"""
)
@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:
"""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.
"""
# Create new subscriptions table with correct schema
await conn.execute(
"""CREATE TABLE "subscriptions_new" (
"stream_domain" TEXT NOT NULL,
"room_id" TEXT NOT NULL,
UNIQUE("room_id","stream_domain")
)"""
)
# Copy all existing data from old table to new table
await conn.execute(
"""INSERT INTO subscriptions_new (stream_domain, room_id)
SELECT stream_domain, room_id FROM subscriptions"""
)
# Drop the old table and rename new table to original name
await conn.execute("DROP TABLE subscriptions")
await conn.execute("ALTER TABLE subscriptions_new RENAME TO subscriptions")
@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:
"""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.
"""
# Add failure_counter column with default value of 0
await conn.execute(
"""ALTER TABLE streams ADD COLUMN failure_counter INTEGER DEFAULT 0"""
)
def get_upgrade_table() -> UpgradeTable:
"""Return the upgrade table with registered migrations."""
return upgrade_table
+124
View File
@@ -0,0 +1,124 @@
# 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
#
# 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.
"""Data models for OwncastSentry."""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from .utils import (
MAX_INSTANCE_TITLE_LENGTH,
MAX_STREAM_TITLE_LENGTH,
MAX_TAG_LENGTH,
UNKNOWN_STATUS_THRESHOLD,
truncate,
)
class StreamStatus(Enum):
"""Represents the status of a stream."""
ONLINE = "online"
OFFLINE = "offline"
UNKNOWN = "unknown"
@dataclass
class StreamState:
"""Represents the state of an Owncast stream."""
domain: str
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:
"""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
if self.last_connect_time is not None:
return StreamStatus.ONLINE
return StreamStatus.OFFLINE
@classmethod
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.
"""
return cls(
domain=domain,
title=truncate(response.get("streamTitle", ""), MAX_STREAM_TITLE_LENGTH),
last_connect_time=response.get("lastConnectTime"),
last_disconnect_time=response.get("lastDisconnectTime"),
)
@classmethod
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.
"""
return cls(
domain=row["domain"],
name=row["name"],
title=row["title"],
last_connect_time=row["last_connect_time"],
last_disconnect_time=row["last_disconnect_time"],
failure_counter=row["failure_counter"],
)
@dataclass
class UpdateResult:
"""Result of a stream update cycle."""
total_streams: int
successful_checks: int
failed_checks: int
@dataclass
class StreamConfig:
"""Represents the configuration of an Owncast stream."""
name: str = ""
tags: list[str] = field(default_factory=list)
@classmethod
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.
"""
# Truncate instance name to max length
name = truncate(response.get("name", ""), MAX_INSTANCE_TITLE_LENGTH)
# Truncate each tag to max length
raw_tags = response.get("tags", [])
tags = [truncate(tag, MAX_TAG_LENGTH) for tag in raw_tags]
return cls(name=name, tags=tags)
+272
View File
@@ -0,0 +1,272 @@
# 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
#
# 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 asyncio
import time
from typing import TYPE_CHECKING, Any
from mautrix.types import MessageType, TextMessageEventContent
from .metrics import NotificationType
from .utils import (
CLEANUP_DELETE_DAYS,
CLEANUP_WARNING_DAYS,
SECONDS_BETWEEN_NOTIFICATIONS,
sanitize_for_plain_text,
)
if TYPE_CHECKING:
import logging
from .database import SubscriptionRepository
from .metrics import MetricsService
class NotificationService:
"""Service for sending Matrix notifications about stream events."""
def __init__(
self,
client: Any,
subscription_repo: SubscriptionRepository,
logger: logging.Logger,
metrics: MetricsService,
) -> None:
"""Initialize the notification service.
:param client: The Matrix client for sending messages.
:param subscription_repo: Repository for managing subscriptions.
:param logger: Logger instance for debugging.
:param metrics: Metrics service for recording counters.
"""
self.client = client
self.subscription_repo = subscription_repo
self.log = logger
self.metrics = metrics
# Cache for tracking when notifications were last sent
self.notification_timers_cache: dict[str, float] = {}
async def notify_stream_live(
self,
domain: str,
name: str,
title: str,
tags: list[str],
*,
title_change: bool = False,
) -> None:
"""Send notifications to rooms subscribed to a stream.
: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 this is a title change notification.
"""
# Has enough time passed since the last notification was sent?
if not self._can_notify(domain):
seconds_since_last = round(
time.time() - self.notification_timers_cache[domain]
)
self.log.info(
f"[{domain}] Not sending notifications. Only "
f"{seconds_since_last} of required "
f"{SECONDS_BETWEEN_NOTIFICATIONS} seconds have "
f"passed since last notification."
)
return
# Record that we're sending a notification now
self._record_notification(domain)
# Build the notification message
body_text = self._format_message(name, title, domain, tags, title_change)
# Send notifications to all subscribed rooms in parallel
successful, failed = await self._broadcast_to_rooms(domain, body_text)
# Log completion
notification_type = "title change" if title_change else "going live"
self.log.info(
f"[{domain}] Completed sending {notification_type} "
f"notifications! {successful} succeeded, "
f"{failed} failed."
)
self.metrics.record_delivery(
NotificationType.TITLE_CHANGE if title_change else NotificationType.LIVE,
successful=successful,
failed=failed,
)
async def _send_notification(
self, room_id: str, body_text: str, domain: str
) -> None:
"""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).
: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 "
f"message to room [{room_id}]: {exception}"
)
raise
def _format_message(
self, name: str, title: str, domain: str, tags: list[str], title_change: bool
) -> str:
"""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.
"""
# Use name if available, fallback to domain
stream_name = name or domain
safe_stream_name = sanitize_for_plain_text(stream_name)
# Choose message based on notification type
if title_change:
parts = [f"📝 {safe_stream_name} has changed its stream title!"]
else:
parts = [f"🎥 {safe_stream_name} is now live!"]
# Add title if present
if title:
safe_title = sanitize_for_plain_text(title)
parts.append(f"\nStream Title: {safe_title}")
# Add stream URL
parts.append(f"\n\nTo tune in, visit: https://{domain}/")
# Add tags if present
if tags:
safe_tags = [
safe_tag
for tag in tags
if (safe_tag := sanitize_for_plain_text(tag))
and not safe_tag.startswith(".")
]
if safe_tags:
parts.append(f"\n\n{' '.join(f'#{tag}' for tag in safe_tags)}")
return "".join(parts)
def get_last_notification_time(self, domain: str) -> float:
"""Get the timestamp of the last notification sent for a domain.
:param domain: The stream domain.
:return: Unix timestamp of last notification, or 0 if never notified.
"""
return self.notification_timers_cache.get(domain, 0)
def _can_notify(self, domain: str) -> bool:
"""Check if enough time has passed to send another notification.
: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.monotonic() - self.notification_timers_cache[domain]
)
return seconds_since_last >= SECONDS_BETWEEN_NOTIFICATIONS
def _record_notification(self, domain: str) -> None:
"""Record that a notification was sent at the current time.
:param domain: The stream domain.
"""
self.notification_timers_cache[domain] = time.monotonic()
async def _broadcast_to_rooms(self, domain: str, body_text: str) -> tuple[int, int]:
"""Send a message to all rooms subscribed to a domain.
:param domain: The stream domain.
:param body_text: The message body text.
:return: Tuple of (successful, failed) counts.
"""
room_ids = await self.subscription_repo.get_subscribed_rooms(domain)
tasks = [
self._send_notification(room_id, body_text, domain) for room_id in room_ids
]
results = await asyncio.gather(*tasks, return_exceptions=True)
failed = sum(1 for r in results if isinstance(r, Exception))
successful = len(results) - failed
return successful, failed
async def send_cleanup_warning(self, domain: str) -> None:
"""Send cleanup warning notification to all subscribed rooms.
:param domain: The stream domain.
"""
remaining_days = CLEANUP_DELETE_DAYS - CLEANUP_WARNING_DAYS
body_text = (
"⚠️ Warning: Subscription Cleanup Scheduled\n\n"
f"The Owncast instance at {domain} has been "
f"unreachable for {CLEANUP_WARNING_DAYS} days. If it remains "
f"unreachable for {remaining_days} more days "
f"({CLEANUP_DELETE_DAYS} days total), this subscription "
f"will be automatically removed."
)
successful, failed = await self._broadcast_to_rooms(domain, body_text)
self.log.info(
f"[{domain}] Sent cleanup warning to {successful} rooms ({failed} failed)."
)
self.metrics.record_delivery(
NotificationType.CLEANUP_WARNING, successful=successful, failed=failed
)
async def send_cleanup_deletion(self, domain: str) -> None:
"""Send cleanup deletion notification to all subscribed rooms.
:param domain: The stream domain.
"""
body_text = (
"🗑️ Subscription Automatically Removed\n\n"
f"The Owncast instance at {domain} has been "
f"unreachable for {CLEANUP_DELETE_DAYS} days and has been "
f"automatically removed from subscriptions in this "
f"room.\n\n"
f"If the instance comes online again and you want to "
f"resubscribe, run `!subscribe {domain}`."
)
successful, failed = await self._broadcast_to_rooms(domain, body_text)
self.log.info(
f"[{domain}] Sent cleanup deletion notice to "
f"{successful} rooms ({failed} failed)."
)
self.metrics.record_delivery(
NotificationType.CLEANUP_DELETION, successful=successful, failed=failed
)
+170
View File
@@ -0,0 +1,170 @@
# 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
#
# 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."""
from typing import TYPE_CHECKING, Any
import aiohttp
from .models import StreamConfig, StreamState
from .utils import (
OWNCAST_CONFIG_PATH,
OWNCAST_STATUS_PATH,
REQUIRED_STATUS_FIELDS,
user_agent,
)
if TYPE_CHECKING:
import logging
from .metrics import MetricsService
class OwncastClient:
"""HTTP client for communicating with Owncast instances."""
def __init__(
self,
logger: logging.Logger,
version: str,
metrics: MetricsService,
) -> 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
:param metrics: Metrics service for recording response times.
"""
self.log = logger
self.metrics = metrics
# Set up HTTP session configuration
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,
)
timeout = aiohttp.ClientTimeout(sock_connect=5, sock_read=5)
self.session = aiohttp.ClientSession(
headers=headers,
cookie_jar=cookie_jar,
timeout=timeout,
connector=connector,
)
async def _fetch_json(self, domain: str, path: str) -> dict[str, Any] | None:
"""Fetch and parse JSON from an Owncast API endpoint.
:param domain: The domain to query.
:param path: The API path to request.
:return: Parsed JSON response, or None on error.
"""
url = f"https://{domain}{path}"
try:
async with self.session.get(url, allow_redirects=False) as response:
if response.status != 200:
self.log.warning(
f"[{domain}] Response to request on "
f"{path} was not 200, "
f"got {response.status} instead."
)
return None
try:
result: dict[str, Any] = await response.json()
return result
except (ValueError, aiohttp.ContentTypeError) as e:
self.log.warning(
f"[{domain}] Rejecting response to request on "
f"{path} as could not be "
f"interpreted as JSON: {e}"
)
return None
except (aiohttp.ClientError, TimeoutError, OSError) as e:
self.log.warning(f"[{domain}] Error making GET request to {path}: {e}")
return None
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 if available, None on error.
"""
self.log.debug(f"[{domain}] Fetching current stream state...")
with self.metrics.response_timer(domain) as timer:
new_state = await self._fetch_json(domain, OWNCAST_STATUS_PATH)
if new_state is None:
return None
# Validate the response contains all basic info needed
missing = REQUIRED_STATUS_FIELDS - new_state.keys()
if missing:
self.log.warning(
f"[{domain}] Rejecting response to request on "
f"{OWNCAST_STATUS_PATH} as it is missing "
f"fields: {', '.join(sorted(missing))}"
)
return None
timer.success()
return StreamState.from_api_response(new_state, domain)
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, or None if fetch failed.
"""
self.log.debug(f"[{domain}] Fetching current stream config...")
with self.metrics.response_timer(domain) as timer:
config = await self._fetch_json(domain, OWNCAST_CONFIG_PATH)
if config is None:
return None
timer.success()
return StreamConfig.from_api_response(config)
async def validate_instance(self, domain: str) -> bool:
"""Validate that a domain is a valid Owncast instance.
:param domain: The domain to validate
:return: True if valid Owncast instance, False otherwise
"""
state = await self.get_stream_state(domain)
return state is not None
@property
def open_connection_count(self) -> int:
"""Return the total number of open HTTP connections."""
connector = self.session.connector
if connector is None:
return 0
idle = sum(len(conns) for conns in connector._conns.values())
active = len(connector._acquired)
return idle + active
async def close(self) -> None:
"""Close the HTTP session."""
await self.session.close()
+359
View File
@@ -0,0 +1,359 @@
# 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
#
# 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 .models import StreamState, StreamStatus, UpdateResult
from .utils import (
CLEANUP_DELETE_THRESHOLD,
CLEANUP_WARNING_THRESHOLD,
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
should_query_stream,
)
if TYPE_CHECKING:
import logging
from .database import StreamRepository, SubscriptionRepository
from .metrics import MetricsService
from .notification_service import NotificationService
from .owncast_client import OwncastClient
class StreamMonitor:
"""Monitors Owncast streams and detects state changes."""
def __init__(
self,
owncast_client: OwncastClient,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
notification_service: NotificationService,
logger: logging.Logger,
metrics: MetricsService,
) -> None:
"""Initialize the stream monitor.
: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 notification_service: Service for sending notifications.
:param logger: Logger instance for debugging.
:param metrics: Metrics service for recording Prometheus metrics.
"""
self.owncast_client = owncast_client
self.stream_repo = stream_repo
self.subscription_repo = subscription_repo
self.notification_service = notification_service
self.log = logger
self.metrics = metrics
# Cache for tracking when streams last went offline
self.offline_timer_cache: dict[str, float] = {}
async def update_all_streams(self, subscribed_domains: list[str]) -> UpdateResult:
"""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.
"""
self.log.debug("Updating all stream states...")
total_streams = len(subscribed_domains)
# Build a list of async tasks for each stream domain
# Run all stream updates in parallel and collect results
results = await asyncio.gather(
*(self.update_stream(domain) for domain in subscribed_domains)
)
# Count successes and failures
successful_checks = results.count(True)
failed_checks = results.count(False)
self.log.debug(
f"Update complete. {successful_checks}/{total_streams} succeeded, "
f"{failed_checks} failed."
)
for domain in subscribed_domains:
count = await self.subscription_repo.count_by_domain(domain)
self.metrics.set_subscription_count(domain, count)
return UpdateResult(
total_streams=total_streams,
successful_checks=successful_checks,
failed_checks=failed_checks,
)
async def update_stream(self, domain: str) -> bool:
"""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 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)
failure_counter = old_state.failure_counter if old_state else 0
if old_state is None:
return True
# Check if we should query this stream based on backoff schedule
if not should_query_stream(failure_counter):
# 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 "
f"(counter={failure_counter + 1})"
)
# Check cleanup thresholds even when skipping query
await self._check_cleanup_thresholds(domain, failure_counter + 1)
updated_state = await self.stream_repo.get_by_domain(domain)
if updated_state is not None:
self.metrics.set_stream_status(domain, updated_state.status)
self.metrics.set_check_failures(domain, failure_counter + 1)
# Backoff is expected behavior, not a failure
return True
# Flag: first state update of a brand-new stream to avoid
# sending notifications if it's already live.
first_update = False
# 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
# The stream's latest configuration, if fetched during update.
stream_config = None
# Fetch the latest stream state from the server
new_state = await self.owncast_client.get_stream_state(domain)
# If the fetch failed, increment failure counter and skip the update
if new_state is None:
await self.stream_repo.increment_failure_counter(domain)
self.log.warning(
f"[{domain}] Connection failure (counter={failure_counter + 1})"
)
# Check cleanup thresholds after connection failure
await self._check_cleanup_thresholds(domain, failure_counter + 1)
updated_state = await self.stream_repo.get_by_domain(domain)
if updated_state is not None:
self.metrics.set_stream_status(domain, updated_state.status)
self.metrics.set_check_failures(domain, failure_counter + 1)
# Actual connection failure
return False
# Fetch succeeded! Reset failure counter
await self.stream_repo.reset_failure_counter(domain)
self.metrics.set_check_failures(domain, 0)
# Initialize timer cache entries to prevent KeyError on first access
self.offline_timer_cache.setdefault(domain, 0)
# 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
):
# Yes, this is the first update. Don't send any notifications.
update_database = True
first_update = True
# 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
):
# Yes! This stream is now live.
update_database = True
stream_config = await self.owncast_client.get_stream_config(domain)
self.log.info(f"[{domain}] Stream is now live!")
# Calculate seconds since the stream last went offline
seconds_since_last_offline = round(
time.monotonic() - self.offline_timer_cache[domain]
)
# 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 []
# Has this stream been offline for a short time?
if seconds_since_last_offline < TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN:
# Did the stream title change?
if old_state.title != new_state.title:
# Stream was briefly down; send title
# change notification.
await self.notification_service.notify_stream_live(
domain,
stream_name,
new_state.title or "",
stream_tags,
title_change=True,
)
else:
# Briefly offline, no title change. Skip.
self.log.info(
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:
# Offline for a while. Send a normal notification.
await self.notification_service.notify_stream_live(
domain,
stream_name,
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. "
f"This is the first state update for "
f"this stream."
)
if (
new_state.last_connect_time is not None
and old_state.last_connect_time is not None
):
# Did the stream title change mid-session?
if old_state.title != new_state.title:
self.log.info(f"[{domain}] Stream title was changed!")
update_database = True
stream_config = await self.owncast_client.get_stream_config(domain)
# 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 []
# 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.get_last_notification_time(domain):
await self.notification_service.notify_stream_live(
domain,
stream_name,
new_state.title or "",
stream_tags,
title_change=False,
)
else:
# No. Send a normal title change notification.
await self.notification_service.notify_stream_live(
domain,
stream_name,
new_state.title or "",
stream_tags,
title_change=True,
)
# 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
):
# Yep. This stream is now offline. Log it.
update_database = True
self.offline_timer_cache[domain] = time.monotonic()
self.log.info(f"[{domain}] Stream is now offline.")
# Update the database with current stream state, if needed.
if update_database:
# Ensure we have the stream config before updating
if stream_config is None:
stream_config = await self.owncast_client.get_stream_config(domain)
# Use fallback value if config fetch failed
stream_name = stream_config.name if stream_config else ""
self.log.debug(f"[{domain}] Updating stream state in database...")
# Create updated state object (title already truncated in new_state)
updated_state = StreamState(
domain=domain,
name=stream_name,
title=new_state.title,
last_connect_time=new_state.last_connect_time,
last_disconnect_time=new_state.last_disconnect_time,
)
await self.stream_repo.update(updated_state)
# All done.
self.log.debug(f"[{domain}] State update completed.")
if new_state.last_connect_time is not None:
self.metrics.set_stream_status(domain, StreamStatus.ONLINE)
else:
self.metrics.set_stream_status(domain, StreamStatus.OFFLINE)
return True
async def _check_cleanup_thresholds(self, domain: str, counter: int) -> None:
"""Check if a domain hit cleanup warning or deletion thresholds.
:param domain: The domain to check.
:param counter: The current failure counter value.
"""
# Check for 83-day warning threshold
if counter == CLEANUP_WARNING_THRESHOLD:
self.log.warning(
f"[{domain}] Reached 83-day warning threshold. Sending cleanup warning."
)
await self.notification_service.send_cleanup_warning(domain)
# Check for 90-day deletion threshold
if counter >= CLEANUP_DELETE_THRESHOLD:
self.log.warning(
f"[{domain}] Reached 90-day deletion threshold."
f" Removing all subscriptions."
)
# Send deletion notification
await self.notification_service.send_cleanup_deletion(domain)
# Delete all subscriptions for this domain
deleted_count = await self.subscription_repo.delete_all_for_domain(domain)
# Delete the stream record
await self.stream_repo.delete(domain)
self.log.info(
f"[{domain}] Cleanup complete. "
f"Deleted {deleted_count} subscriptions "
f"and stream record."
)
self.metrics.remove_stream(domain)
+205
View File
@@ -0,0 +1,205 @@
# 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
#
# 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
# Path to the GetStatus API call on Owncast instances
OWNCAST_STATUS_PATH = "/api/status"
# Path to GetWebConfig API call on Owncast instances
OWNCAST_CONFIG_PATH = "/api/config"
# Fields that must be present in an Owncast status API response
REQUIRED_STATUS_FIELDS = frozenset(
{
"lastConnectTime",
"lastDisconnectTime",
"streamTitle",
"online",
}
)
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.
SECONDS_BETWEEN_NOTIFICATIONS = 20 * 60 # 20 minutes in seconds
# 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 min in seconds
# Auto-cleanup timing (days of continuous unreachability)
CLEANUP_WARNING_DAYS = 83
CLEANUP_DELETE_DAYS = 90
# Counter thresholds derived from days (60-second polling intervals)
CLEANUP_WARNING_THRESHOLD = CLEANUP_WARNING_DAYS * 24 * 60
CLEANUP_DELETE_THRESHOLD = CLEANUP_DELETE_DAYS * 24 * 60
# Failure counter threshold for treating stream status as "unknown"
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
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 failure count.
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 cycle for first 5 minutes (counters 0-4)
return True
if failure_counter <= 9:
# Query every 2nd cycle for next 5 minutes (counters 5-9)
return failure_counter % 2 == 0
if failure_counter <= 14:
# Query every 3rd cycle for next 5 minutes (counters 10-14)
return failure_counter % 3 == 0
if failure_counter <= 29:
# Query every 5th cycle for next 15 minutes (counters 15-29)
return failure_counter % 5 == 0
# Query every 15th cycle after 30 minutes (counter 30+)
return failure_counter % 15 == 0
def domainify(url: str) -> str:
"""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).
:param url: URL, domain, or email-style string
:return: Sanitized domain
"""
# Handle email-style format first (e.g., "notify@stream.logal.dev")
if "@" in url:
url = url.split("@")[-1]
# Prepend // if no scheme so urlparse treats input as netloc
if not url.startswith(("http://", "https://", "//")):
url = f"//{url}"
parsed = urlparse(url)
domain = (parsed.netloc or parsed.path).lower()
# Strip port and path
domain = domain.split(":")[0].split("/")[0]
# Allow only valid domain characters
return re.sub(r"[^a-z0-9.-]", "", domain).strip(".-")
def truncate(text: str, max_length: int) -> str:
"""Truncate text to a maximum length.
:param text: The text to truncate
:param max_length: Maximum allowed length
:return: Truncated text, or original if within limit
"""
if not text or len(text) <= max_length:
return text
return text[:max_length]
_MARKDOWN_ESCAPE_TABLE = str.maketrans({c: f"\\{c}" for c in r"\*_[]()~`#+-=|{}.!<>&"})
def escape_markdown(text: str) -> str:
"""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
actors from injecting arbitrary Markdown/HTML content.
:param text: The text to escape
:return: The escaped text safe for Markdown rendering
"""
if not text:
return text
return text.translate(_MARKDOWN_ESCAPE_TABLE)
def sanitize_for_plain_text(text: str) -> str:
"""Sanitize text for plain text rendering.
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
"""
if not text:
return text
# Remove newlines and carriage returns to prevent multi-line injection
sanitized = text.replace("\n", " ").replace("\r", " ")
# Collapse multiple spaces into single space
return " ".join(sanitized.split())
def sanitize_for_markdown(text: str) -> str:
"""Sanitize text for safe Markdown rendering.
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).
:param text: The text to sanitize
:return: Sanitized and escaped text safe for Markdown rendering
"""
if not text:
return text
return escape_markdown(sanitize_for_plain_text(text))
+117
View File
@@ -0,0 +1,117 @@
[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 = ["prometheus_client>=0.24.1"]
[project.urls]
Repository = "https://git.logal.dev/LogalDeveloper/OwncastSentry"
[build-system]
requires = ["hatchling>=1.29.0", "hatch-vcs>=0.5.0"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"maubot[encryption]>=0.6.0",
"aioresponses>=0.7.8",
"codespell>=2.4.2",
"hatch>=1.16.5",
"mypy>=1.19.1",
"pip-audit>=2.10.0",
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0",
"ruff>=0.15.6",
"time-machine>=3.2.0",
]
[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", "tests/"]
[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
# Testing
"PT", # flake8-pytest-style
# 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.pytest.ini_options]
asyncio_mode = "auto"
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"]
[tool.coverage.run]
source = ["owncastsentry"]
omit = ["owncastsentry/_version.py"]
[tool.coverage.report]
show_missing = true
skip_empty = true
[tool.codespell]
skip = "uv.lock"
+15
View File
@@ -0,0 +1,15 @@
# 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
#
# 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.
"""OwncastSentry test suite."""
+223
View File
@@ -0,0 +1,223 @@
# 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
#
# 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.
"""Shared test fixtures and stubs for OwncastSentry tests."""
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import pytest
from mautrix.util.async_db import Database
from prometheus_client import generate_latest
from owncastsentry import OwncastSentry
from owncastsentry.config import Config
from owncastsentry.database import StreamRepository, SubscriptionRepository
from owncastsentry.migrations import get_upgrade_table
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
from owncastsentry.metrics import MetricsService
from owncastsentry.models import StreamConfig, StreamState
def generate_metrics_output(metrics: MetricsService) -> str:
"""Generate Prometheus text format output from a MetricsService registry."""
return generate_latest(metrics.registry).decode("utf-8")
@pytest.fixture
async def database(tmp_path: Path) -> AsyncIterator[Database]:
"""Yield a real SQLite-backed mautrix Database with migrations applied."""
db_path = tmp_path / "test.db"
db = Database.create(
f"sqlite:///{db_path}",
upgrade_table=get_upgrade_table(),
)
await db.start()
yield db
await db.stop()
@pytest.fixture
def stream_repo(database: Database) -> StreamRepository:
"""Return a StreamRepository backed by the test database."""
return StreamRepository(database)
@pytest.fixture
def subscription_repo(database: Database) -> SubscriptionRepository:
"""Return a SubscriptionRepository backed by the test database."""
return SubscriptionRepository(database)
# These fixtures use names expected by the maubot.testing framework.
# They look unused but are discovered automatically by maubot's pytest plugin.
@pytest.fixture
def maubot_plugin_class():
"""Use OwncastSentry as the plugin class for maubot integration tests."""
return OwncastSentry
@pytest.fixture
def maubot_plugin_config_class():
"""Use our Config class for maubot integration tests."""
return Config
@pytest.fixture
def maubot_upgrade_table():
"""Provide the database upgrade table for maubot integration tests."""
return get_upgrade_table()
VALID_CONFIG_RESPONSE: dict[str, object] = {
"appearanceVariables": {},
"name": "LogalDeveloper's Live Stream",
"customStyles": "",
"streamTitle": "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!", # noqa: E501
"offlineMessage": "<p>HTTP/1.1 204 No Content</p>\n<p>You've reached the right place, but I'm not live right now.</p>", # noqa: E501
"logo": "/logo",
"version": "Owncast v0.2.4-linux-64bit (8e89391309dd3aa4d0db4361a2ba1e144f42f8c8)",
"extraPageContent": (
"<h1>About</h1>\n<p>Hi there! I'm Logan, a cybersecurity professional "
"with a background in Linux systems and network administration. This is my "
"little corner of the internet where I exclusively run my live streams. While "
"gaming is my most common stream topic, I occasionally stream other things "
"which interest me, such as cybersecurity challenges. If you are looking for "
"my main website, where my blog and project directory is hosted, please visit: "
'<a href="https://logal.dev/">https://logal.dev/</a></p>\n<p>Any views and '
"opinions expressed in my live streams and videos are my own and do not "
"necessarily reflect those of my employer or any affiliated organizations."
"</p>\n<h1>Following</h1>\n<p>I don\u2019t stick to a strict streaming "
"schedule, but there are a few ways to stay updated so you know when I go "
"live:</p>\n<ul>\n<li>\u2b50 <strong>Matrix</strong>: Send the message "
"<code>!subscribe stream.logal.dev</code> to "
'<a href="https://matrix.to/#/@owncastsentry:logal.dev">'
"@owncastsentry:logal.dev</a>.</li>\n<li><strong>Fediverse</strong>: Follow "
"<code>@notify@stream.logal.dev</code>.</li>\n</ul>\n<h1>Chat Commands</h1>"
"\n<p>To make things more interactive, there are several commands you can "
"send in chat to play sound effects live on stream:</p>\n<ul>\n"
"<li>!boom</li>\n<li>!bluetooth</li>\n<li>!bruh</li>\n<li>!creeper</li>\n"
"<li>!directed</li>\n<li>!fart</li>\n<li>!icetea</li>\n<li>!oof</li>\n"
"<li>!perfect</li>\n<li>!spare</li>\n<li>!thatsit</li>\n<li>!usb</li>\n"
"<li>!whocares</li>\n<li>!yoda</li>\n</ul>"
),
"summary": "Video games, cybersecurity, and more!",
"tags": [
"video games",
"chatting",
"casual",
"english",
"streaming",
"owncast",
"variety",
],
"socialHandles": None,
"externalActions": [
{
"url": "https://ko-fi.com/logaldeveloper",
"html": "",
"title": "Tip",
"description": "",
"icon": "https://stream.logal.dev/img/platformlogos/ko-fi.svg",
"color": "",
"openExternally": True,
},
{
"url": "https://tubefree.org/c/logaldeveloper_stream_archive",
"html": "",
"title": "Previous Live Stream Recordings",
"description": "",
"icon": "https://stream.logal.dev/img/platformlogos/fediverse.svg",
"color": "",
"openExternally": True,
},
],
"notifications": {
"browser": {
"publicKey": "BI9BhIY6c7nfP6ZSIu7T53Lta5sGYqDqwSpiCabZ0XQxPQOmnUHRSjInaa3HX9XmYE-bV8SFmbFk4stZy2jnJ3M", # noqa: E501
"enabled": True,
},
},
"federation": {
"account": "notify@stream.logal.dev",
"followerCount": 74,
"enabled": True,
},
"maxSocketPayloadSize": 2048,
"hideViewerCount": False,
"chatDisabled": False,
"chatSpamProtectionDisabled": False,
"nsfw": False,
"authentication": {"indieAuthEnabled": True},
}
VALID_STATUS_RESPONSE: dict[str, object] = {
"serverTime": "2026-03-13T15:16:14.354962696-04:00",
"lastConnectTime": None,
"lastDisconnectTime": "2026-03-04T21:05:32-05:00",
"versionNumber": "0.2.4",
"streamTitle": "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!", # noqa: E501
"online": False,
}
@dataclass
class _SentMessage:
"""A message recorded by _StubMatrixClient."""
room_id: str
content: Any
class _StubMatrixClient:
"""Recording stub for the Matrix client used in notification tests."""
def __init__(self) -> None:
self.sent_messages: list[_SentMessage] = []
self.should_fail_for_rooms: set[str] = set()
async def send_message(self, room_id: str, content: Any) -> None:
"""Record a sent message, or raise if room is in the fail set."""
if room_id in self.should_fail_for_rooms:
msg = f"Stubbed failure for room {room_id}"
raise RuntimeError(msg)
self.sent_messages.append(_SentMessage(room_id=room_id, content=content))
@dataclass
class _StubOwncastClient:
"""Configurable stub for the Owncast HTTP client."""
stream_state: StreamState | None = None
stream_config: StreamConfig | None = None
state_call_count: int = field(default=0, init=False)
config_call_count: int = field(default=0, init=False)
queried_domains: list[str] = field(default_factory=list, init=False)
async def get_stream_state(self, domain: str) -> StreamState | None:
"""Return the configured stream state."""
self.state_call_count += 1
self.queried_domains.append(domain)
return self.stream_state
async def get_stream_config(self, domain: str) -> StreamConfig | None:
"""Return the configured stream config."""
self.config_call_count += 1
return self.stream_config
+506
View File
@@ -0,0 +1,506 @@
# 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
#
# 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.
"""Tests for bot command handlers."""
import json
import logging
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
import pytest
import time_machine
from aioresponses import aioresponses
from owncastsentry.commands import CommandHandler
from owncastsentry.models import StreamState
from owncastsentry.utils import OWNCAST_STATUS_PATH, UNKNOWN_STATUS_THRESHOLD
from tests.conftest import VALID_STATUS_RESPONSE
def _make_command_handler() -> CommandHandler:
"""Build a CommandHandler with dummy dependencies for pure logic tests."""
return CommandHandler(
owncast_client=MagicMock(),
stream_repo=MagicMock(),
subscription_repo=MagicMock(),
logger=logging.getLogger("test"),
)
class TestFormatDuration:
"""Elapsed time calculation from ISO timestamps."""
_NOW = datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)
@pytest.mark.parametrize(
("seconds_ago", "expected"),
[
pytest.param(1, "1 second", id="singular-second"),
pytest.param(30, "30 seconds", id="plural-seconds"),
pytest.param(60, "1 minute", id="singular-minute"),
pytest.param(120, "2 minutes", id="plural-minutes"),
pytest.param(3600, "1 hour", id="singular-hour"),
pytest.param(7200, "2 hours", id="plural-hours"),
pytest.param(86400, "1 day", id="singular-day"),
pytest.param(172800, "2 days", id="plural-days"),
],
)
@time_machine.travel(_NOW)
def test_formats_duration(self, seconds_ago: int, expected: str) -> None:
"""Format a timestamp into a human-readable duration."""
handler = _make_command_handler()
timestamp = (self._NOW - timedelta(seconds=seconds_ago)).isoformat()
result = handler._format_duration(timestamp)
assert result == expected
def test_invalid_timestamp(self) -> None:
"""Return 'unknown duration' for unparsable timestamps."""
handler = _make_command_handler()
assert handler._format_duration("not-a-timestamp") == "unknown duration"
class TestSubscribeCommand:
"""Subscribe command end-to-end via maubot."""
async def test_subscribe_valid_stream(self, maubot_test_bot, maubot_plugin) -> None:
"""Subscribe to a valid Owncast stream."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
mocked.get(
status_url,
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe stream.logal.dev")
assert len(maubot_test_bot.responded) == 1
assert maubot_test_bot.responded[0].content.body == (
"Subscription added! This room will receive notifications when "
"stream.logal.dev goes live."
)
async def test_subscribe_invalid_stream(
self, maubot_test_bot, maubot_plugin
) -> None:
"""Reject subscription to an invalid Owncast instance."""
status_url = f"https://invalid.com{OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
mocked.get(status_url, status=404)
await maubot_test_bot.send("!subscribe invalid.com")
assert len(maubot_test_bot.responded) == 1
assert maubot_test_bot.responded[0].content.body == (
"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."
)
async def test_subscribe_already_subscribed(
self, maubot_test_bot, maubot_plugin
) -> None:
"""Reject duplicate subscription in the same room."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
mocked.get(
status_url,
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe stream.logal.dev")
# Second subscribe; stream already exists so validation is skipped
await maubot_test_bot.send("!subscribe stream.logal.dev")
assert len(maubot_test_bot.responded) == 2
assert maubot_test_bot.responded[1].content.body == (
"This room is already subscribed to notifications for stream.logal.dev."
)
async def test_subscribe_existing_stream_new_room(
self, maubot_test_bot, maubot_plugin
) -> None:
"""Skip instance validation when subscribing from a new room."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
mocked.get(
status_url,
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe stream.logal.dev")
# Subscribe from a different room; skips validation and should not
# query the remote instance. An empty aioresponses context will raise
# ConnectionError if any HTTP request is attempted.
with aioresponses():
await maubot_test_bot.send(
"!subscribe stream.logal.dev", room_id="!otherroom:example.com"
)
assert len(maubot_test_bot.responded) == 2
assert maubot_test_bot.responded[1].content.body == (
"Subscription added! This room will receive notifications when "
"stream.logal.dev goes live."
)
class TestUnsubscribeCommand:
"""Unsubscribe command end-to-end via maubot."""
async def test_unsubscribe_existing(self, maubot_test_bot, maubot_plugin) -> None:
"""Unsubscribe from a subscribed stream."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
mocked.get(
status_url,
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe stream.logal.dev")
await maubot_test_bot.send("!unsubscribe stream.logal.dev")
assert len(maubot_test_bot.responded) == 2
assert maubot_test_bot.responded[1].content.body == (
"Subscription removed! This room will no "
"longer receive notifications for stream.logal.dev."
)
async def test_unsubscribe_not_subscribed(
self, maubot_test_bot, maubot_plugin
) -> None:
"""Reject unsubscribe from a non-subscribed stream."""
await maubot_test_bot.send("!unsubscribe unknown.com")
assert len(maubot_test_bot.responded) == 1
assert maubot_test_bot.responded[0].content.body == (
"This room is already not subscribed to notifications for unknown.com."
)
class TestSubscriptionsCommand:
"""Subscriptions listing command end-to-end via maubot."""
async def test_no_subscriptions(self, maubot_test_bot, maubot_plugin) -> None:
"""Show help text when no subscriptions exist."""
await maubot_test_bot.send("!subscriptions")
assert len(maubot_test_bot.responded) == 1
assert maubot_test_bot.responded[0].content.body == (
"This room is not subscribed to any Owncast "
"instances.\n\nTo subscribe to an Owncast "
"instance, use `!subscribe <domain>`"
)
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_online_stream(self, maubot_test_bot, maubot_plugin) -> None:
"""Show stream details including title and duration."""
# Subscribe first
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
mocked.get(
status_url,
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe stream.logal.dev")
# Update stream state to be online with a name
await maubot_plugin.stream_repo.update(
StreamState(
domain="stream.logal.dev",
name="Test Stream",
title="Playing Games",
last_connect_time="2026-01-01T12:00:00Z",
)
)
await maubot_test_bot.send("!subscriptions")
assert len(maubot_test_bot.responded) == 2
assert maubot_test_bot.responded[1].content.body == (
"**Subscriptions for this room (1):**\n\n"
"● **Test Stream** \n"
" ○ Title: Playing Games\n"
" ○ Status: Online for 71 days\n"
" ○ Link: https://stream.logal.dev\n"
"To unsubscribe from any of these Owncast "
"instances, use `!unsubscribe <domain>`"
)
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_offline_stream(self, maubot_test_bot, maubot_plugin) -> None:
"""Show offline status for non-live streams."""
# Subscribe first
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
mocked.get(
status_url,
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe stream.logal.dev")
# Update stream state to be offline
await maubot_plugin.stream_repo.update(
StreamState(
domain="stream.logal.dev",
name="Test Stream",
last_disconnect_time="2026-01-01T10:00:00Z",
)
)
await maubot_test_bot.send("!subscriptions")
assert len(maubot_test_bot.responded) == 2
assert maubot_test_bot.responded[1].content.body == (
"**Subscriptions for this room (1):**\n\n"
"● **Test Stream** \n"
" ○ Status: Offline for 71 days\n"
" ○ Link: https://stream.logal.dev\n"
"To unsubscribe from any of these Owncast "
"instances, use `!unsubscribe <domain>`"
)
async def test_shows_offline_stream_without_disconnect_time(
self, maubot_test_bot, maubot_plugin
) -> None:
"""Show offline status without duration before first poll completes."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
mocked.get(
status_url,
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe stream.logal.dev")
# Stream row exists with no state yet - query subscriptions immediately
await maubot_test_bot.send("!subscriptions")
assert len(maubot_test_bot.responded) == 2
assert maubot_test_bot.responded[1].content.body == (
"**Subscriptions for this room (1):**\n\n"
"● **stream.logal.dev** \n"
" ○ Status: Offline\n"
" ○ Link: https://stream.logal.dev\n"
"To unsubscribe from any of these Owncast "
"instances, use `!unsubscribe <domain>`"
)
async def test_shows_unknown_stream(self, maubot_test_bot, maubot_plugin) -> None:
"""Show unknown status when instance has been unreachable."""
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
mocked.get(
status_url,
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe stream.logal.dev")
# Increment failure counter past the unknown threshold
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
await maubot_plugin.stream_repo.increment_failure_counter(
"stream.logal.dev"
)
await maubot_test_bot.send("!subscriptions")
assert len(maubot_test_bot.responded) == 2
assert maubot_test_bot.responded[1].content.body == (
"**Subscriptions for this room (1):**\n\n"
"● **stream.logal.dev** \n"
" ○ Status: Unknown (instance unreachable)\n"
" ○ Link: https://stream.logal.dev\n"
"To unsubscribe from any of these Owncast "
"instances, use `!unsubscribe <domain>`"
)
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_multiple_subscriptions(
self, maubot_test_bot, maubot_plugin
) -> None:
"""List subscriptions alphabetically with mixed statuses."""
# Subscribe in reverse alphabetical order to verify sorted output
with aioresponses() as mocked:
mocked.get(
f"https://beta.com{OWNCAST_STATUS_PATH}",
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
mocked.get(
f"https://alpha.com{OWNCAST_STATUS_PATH}",
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe beta.com")
await maubot_test_bot.send("!subscribe alpha.com")
# Set alpha online, beta offline
await maubot_plugin.stream_repo.update(
StreamState(
domain="alpha.com",
name="Alpha Stream",
title="Streaming Live",
last_connect_time="2026-03-13T10:00:00Z",
)
)
await maubot_plugin.stream_repo.update(
StreamState(
domain="beta.com",
name="Beta Stream",
last_disconnect_time="2026-03-12T18:00:00Z",
)
)
await maubot_test_bot.send("!subscriptions")
assert len(maubot_test_bot.responded) == 3
assert maubot_test_bot.responded[2].content.body == (
"**Subscriptions for this room (2):**\n\n"
"● **Alpha Stream**\n"
" \n"
" ○ Title: Streaming Live\n"
" ○ Status: Online for 2 hours\n"
" ○ Link: https://alpha.com\n"
"● **Beta Stream**\n"
" \n"
" ○ Status: Offline for 18 hours\n"
" ○ Link: https://beta.com\n"
"To unsubscribe from any of these Owncast "
"instances, use `!unsubscribe <domain>`"
)
class TestLiveCommand:
"""Live streams listing command end-to-end via maubot."""
async def test_no_subscriptions(self, maubot_test_bot, maubot_plugin) -> None:
"""Show help text when no subscriptions exist."""
await maubot_test_bot.send("!live")
assert len(maubot_test_bot.responded) == 1
assert maubot_test_bot.responded[0].content.body == (
"This room is not subscribed to any Owncast "
"instances.\n\nTo subscribe to an Owncast "
"instance, use `!subscribe <domain>`"
)
async def test_no_live_streams(self, maubot_test_bot, maubot_plugin) -> None:
"""Show 'no live' message when all streams are offline."""
# Subscribe first
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
mocked.get(
status_url,
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe stream.logal.dev")
# Update stream state to offline
await maubot_plugin.stream_repo.update(
StreamState(
domain="stream.logal.dev",
name="Test Stream",
last_disconnect_time="2026-01-01T10:00:00Z",
)
)
await maubot_test_bot.send("!live")
assert len(maubot_test_bot.responded) == 2
assert maubot_test_bot.responded[1].content.body == (
"No subscribed Owncast instances are currently "
"live.\n\nUse `!subscriptions` to list all "
"subscriptions."
)
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_live_stream(self, maubot_test_bot, maubot_plugin) -> None:
"""Show live stream with title and duration."""
# Subscribe first
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
mocked.get(
status_url,
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe stream.logal.dev")
# Update stream state to online
await maubot_plugin.stream_repo.update(
StreamState(
domain="stream.logal.dev",
name="Test Stream",
title="Playing Games",
last_connect_time="2026-01-01T12:00:00Z",
)
)
await maubot_test_bot.send("!live")
assert len(maubot_test_bot.responded) == 2
assert maubot_test_bot.responded[1].content.body == (
"**Live Owncast instances (1):**\n\n"
"● **Test Stream** \n"
" ○ Title: Playing Games\n"
" ○ Online for 71 days\n"
" ○ Link: https://stream.logal.dev"
)
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_multiple_live_streams(
self, maubot_test_bot, maubot_plugin
) -> None:
"""List live streams alphabetically with different durations."""
# Subscribe in reverse alphabetical order to verify sorted output
with aioresponses() as mocked:
mocked.get(
f"https://beta.com{OWNCAST_STATUS_PATH}",
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
mocked.get(
f"https://alpha.com{OWNCAST_STATUS_PATH}",
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await maubot_test_bot.send("!subscribe beta.com")
await maubot_test_bot.send("!subscribe alpha.com")
# Set both streams online with different connect times
await maubot_plugin.stream_repo.update(
StreamState(
domain="alpha.com",
name="Alpha Stream",
title="Morning Show",
last_connect_time="2026-03-13T10:00:00Z",
)
)
await maubot_plugin.stream_repo.update(
StreamState(
domain="beta.com",
name="Beta Stream",
title="Evening Vibes",
last_connect_time="2026-03-13T06:00:00Z",
)
)
await maubot_test_bot.send("!live")
assert len(maubot_test_bot.responded) == 3
assert maubot_test_bot.responded[2].content.body == (
"**Live Owncast instances (2):**\n\n"
"● **Alpha Stream**\n"
" \n"
" ○ Title: Morning Show\n"
" ○ Online for 2 hours\n"
" ○ Link: https://alpha.com\n"
"● **Beta Stream**\n"
" \n"
" ○ Title: Evening Vibes\n"
" ○ Online for 6 hours\n"
" ○ Link: https://beta.com"
)
+148
View File
@@ -0,0 +1,148 @@
# 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
#
# 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.
"""Tests for database repository classes."""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from owncastsentry.database import StreamRepository, SubscriptionRepository
class TestStreamExists:
"""Stream existence checks."""
async def test_returns_true_for_existing_stream(
self, stream_repo: StreamRepository
) -> None:
"""Return True when the stream exists in the database."""
await stream_repo.create("example.com")
assert await stream_repo.exists("example.com") is True
async def test_returns_false_for_missing_stream(
self, stream_repo: StreamRepository
) -> None:
"""Return False when the stream does not exist in the database."""
assert await stream_repo.exists("missing.com") is False
class TestStreamDelete:
"""Stream record deletion."""
async def test_removes_stream_record(self, stream_repo: StreamRepository) -> None:
"""Remove the stream record so get_by_domain returns None."""
await stream_repo.create("example.com")
await stream_repo.delete("example.com")
assert await stream_repo.get_by_domain("example.com") is None
class TestGetSubscribedStreamsForRoom:
"""Subscribed stream lookup by room."""
async def test_returns_all_domains_for_room(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Return all domains a room is subscribed to."""
await stream_repo.create("alpha.com")
await stream_repo.create("beta.com")
await subscription_repo.add("alpha.com", "!room1:example.com")
await subscription_repo.add("beta.com", "!room1:example.com")
result = await subscription_repo.get_subscribed_streams_for_room(
"!room1:example.com"
)
assert sorted(result) == ["alpha.com", "beta.com"]
async def test_returns_empty_list_for_unsubscribed_room(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Return an empty list when the room has no subscriptions."""
result = await subscription_repo.get_subscribed_streams_for_room(
"!nobody:example.com"
)
assert result == []
class TestGetAllSubscribedDomains:
"""Unique subscribed domain retrieval."""
async def test_returns_each_domain_once(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Return each domain once even with multiple subscriptions."""
await stream_repo.create("alpha.com")
await subscription_repo.add("alpha.com", "!room1:example.com")
await subscription_repo.add("alpha.com", "!room2:example.com")
result = await subscription_repo.get_all_subscribed_domains()
assert result == ["alpha.com"]
async def test_returns_empty_list_with_no_subscriptions(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Return an empty list when there are no subscriptions."""
result = await subscription_repo.get_all_subscribed_domains()
assert result == []
class TestCountByDomain:
"""Subscription count by domain."""
async def test_returns_correct_count(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Return the correct subscription count for a domain."""
await stream_repo.create("alpha.com")
await subscription_repo.add("alpha.com", "!room1:example.com")
await subscription_repo.add("alpha.com", "!room2:example.com")
assert await subscription_repo.count_by_domain("alpha.com") == 2
async def test_returns_zero_for_unknown_domain(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Return 0 for a domain with no subscriptions."""
assert await subscription_repo.count_by_domain("unknown.com") == 0
class TestDeleteAllForDomain:
"""Bulk subscription deletion by domain."""
async def test_deletes_all_subscriptions_and_returns_count(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Delete all subscriptions for the domain and return the count."""
await stream_repo.create("alpha.com")
await subscription_repo.add("alpha.com", "!room1:example.com")
await subscription_repo.add("alpha.com", "!room2:example.com")
deleted = await subscription_repo.delete_all_for_domain("alpha.com")
assert deleted == 2
rooms = await subscription_repo.get_subscribed_rooms("alpha.com")
assert rooms == []
async def test_returns_zero_for_unknown_domain(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Return 0 when deleting subscriptions for an unknown domain."""
assert await subscription_repo.delete_all_for_domain("unknown.com") == 0
+314
View File
@@ -0,0 +1,314 @@
# 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
#
# 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.
"""Tests for the Prometheus metrics service."""
import pytest
from owncastsentry.metrics import ErrorSource, MetricsService, NotificationType
from owncastsentry.models import StreamStatus
from tests.conftest import generate_metrics_output
class TestRecordDelivery:
"""Notification delivery counter with type and result labels."""
@pytest.mark.parametrize(
("notification_type", "successful", "failed", "expected_fragments"),
[
pytest.param(
NotificationType.LIVE,
3,
0,
['result="success",type="live"} 3.0'],
id="live-success",
),
pytest.param(
NotificationType.LIVE,
0,
2,
['result="failure",type="live"} 2.0'],
id="live-failure",
),
pytest.param(
NotificationType.TITLE_CHANGE,
1,
0,
['result="success",type="title_change"} 1.0'],
id="title-change-success",
),
pytest.param(
NotificationType.CLEANUP_WARNING,
2,
0,
['result="success",type="cleanup_warning"} 2.0'],
id="cleanup-warning-success",
),
pytest.param(
NotificationType.CLEANUP_DELETION,
1,
1,
[
'result="success",type="cleanup_deletion"} 1.0',
'result="failure",type="cleanup_deletion"} 1.0',
],
id="cleanup-deletion-mixed",
),
],
)
def test_records_delivery(
self,
notification_type: NotificationType,
successful: int,
failed: int,
expected_fragments: list[str],
) -> None:
"""Record delivery results with correct type and result labels."""
service = MetricsService()
service.record_delivery(notification_type, successful=successful, failed=failed)
output = generate_metrics_output(service)
for fragment in expected_fragments:
assert fragment in output
@pytest.mark.parametrize(
("notification_type", "result"),
[
pytest.param(t, r, id=f"{t}-{r}")
for t in NotificationType
for r in ("success", "failure")
],
)
def test_all_combinations_initialized(
self, notification_type: NotificationType, result: str
) -> None:
"""All type/result label combinations exist at zero on init."""
service = MetricsService()
output = generate_metrics_output(service)
expected = (
f"owncastsentry_notification_delivery_total"
f'{{result="{result}",type="{notification_type}"}} 0.0'
)
assert expected in output
class TestSetStreamStatus:
"""Per-stream status gauge."""
@pytest.mark.parametrize(
("status", "expected_value"),
[
pytest.param(StreamStatus.ONLINE, 1.0, id="online"),
pytest.param(StreamStatus.OFFLINE, 0.0, id="offline"),
pytest.param(StreamStatus.UNKNOWN, -1.0, id="unknown"),
],
)
def test_sets_status(self, status: StreamStatus, expected_value: float) -> None:
"""Set gauge to the correct value for each stream status."""
service = MetricsService()
service.set_stream_status("test.com", status)
output = generate_metrics_output(service)
expected = f'owncastsentry_stream_status{{domain="test.com"}} {expected_value}'
assert expected in output
class TestSetSubscriptionCount:
"""Per-stream subscription count gauge."""
def test_sets_count(self) -> None:
"""Set the subscription count for a domain."""
service = MetricsService()
service.set_subscription_count("test.com", 5)
output = generate_metrics_output(service)
assert 'owncastsentry_stream_subscriptions{domain="test.com"} 5.0' in output
def test_updates_count(self) -> None:
"""Update the subscription count for a domain."""
service = MetricsService()
service.set_subscription_count("test.com", 5)
service.set_subscription_count("test.com", 3)
output = generate_metrics_output(service)
assert 'owncastsentry_stream_subscriptions{domain="test.com"} 3.0' in output
class TestSetCheckFailures:
"""Check failure counter gauge per domain."""
def test_sets_count(self) -> None:
"""Set the failure count for a domain."""
service = MetricsService()
service.set_check_failures("fail.com", 3)
output = generate_metrics_output(service)
assert 'owncastsentry_check_failures{domain="fail.com"} 3.0' in output
def test_resets_to_zero(self) -> None:
"""Reset the failure count to zero."""
service = MetricsService()
service.set_check_failures("fail.com", 5)
service.set_check_failures("fail.com", 0)
output = generate_metrics_output(service)
assert 'owncastsentry_check_failures{domain="fail.com"} 0.0' in output
class TestResponseTimer:
"""Response time gauge via context manager."""
def test_records_on_success(self) -> None:
"""Record a response time when success() is called."""
service = MetricsService()
with service.response_timer("example.com") as timer:
timer.success()
output = generate_metrics_output(service)
assert 'owncastsentry_api_response_seconds{domain="example.com"}' in output
def test_does_not_record_without_success(self) -> None:
"""Do not record when success() is never called."""
service = MetricsService()
with service.response_timer("example.com"):
pass
output = generate_metrics_output(service)
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
def test_overwrites_previous_value(self) -> None:
"""Overwrite previous value with the latest response time."""
service = MetricsService()
with service.response_timer("example.com") as timer:
timer.success()
with service.response_timer("example.com") as timer:
timer.success()
output = generate_metrics_output(service)
# Gauge should have exactly one line for this domain, not accumulated
matches = [
line
for line in output.splitlines()
if line.startswith("owncastsentry_api_response_seconds{")
]
assert len(matches) == 1
def test_does_not_record_on_exception(self) -> None:
"""Do not record when the block raises an exception."""
service = MetricsService()
with (
pytest.raises(ValueError, match="boom"),
service.response_timer("example.com"),
):
raise ValueError("boom")
output = generate_metrics_output(service)
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
class TestRemoveStream:
"""Stale stream label cleanup."""
def test_removes_stream_label(self) -> None:
"""Remove a stream's gauge labels after cleanup deletion."""
service = MetricsService()
service.set_stream_status("gone.com", StreamStatus.OFFLINE)
service.set_subscription_count("gone.com", 2)
assert 'domain="gone.com"' in generate_metrics_output(service)
service.remove_stream("gone.com")
assert 'domain="gone.com"' not in generate_metrics_output(service)
def test_remove_nonexistent_is_noop(self) -> None:
"""Removing a nonexistent stream does not raise."""
service = MetricsService()
service.remove_stream("never.com")
class TestRegisterOpenConnectionsGauge:
"""Callback-based open connection gauge."""
def test_reads_value_from_callback(self) -> None:
"""Read the open connection count from the callback at scrape time."""
service = MetricsService()
counter = [3]
service.register_open_connections_gauge(lambda: counter[0])
output = generate_metrics_output(service)
assert "owncastsentry_http_connections_open 3.0" in output
def test_reflects_updated_value(self) -> None:
"""Reflect changes in the callback value on subsequent scrapes."""
service = MetricsService()
counter = [1]
service.register_open_connections_gauge(lambda: counter[0])
counter[0] = 5
output = generate_metrics_output(service)
assert "owncastsentry_http_connections_open 5.0" in output
class TestSetBuildInfo:
"""Build version info metric."""
def test_sets_version(self) -> None:
"""Set the build version info."""
service = MetricsService()
service.set_build_info("1.2.3")
output = generate_metrics_output(service)
assert 'owncastsentry_info{version="1.2.3"} 1.0' in output
class TestRecordError:
"""Internal error counter."""
def test_increments_counter(self) -> None:
"""Increment the error counter for a source."""
service = MetricsService()
service.record_error(ErrorSource.SCHEDULER_LOOP)
output = generate_metrics_output(service)
assert 'owncastsentry_errors_total{source="scheduler_loop"} 1.0' in output
def test_increments_multiple_sources(self) -> None:
"""Increment error counters for different sources independently."""
service = MetricsService()
service.record_error(ErrorSource.SCHEDULER_LOOP)
service.record_error(ErrorSource.COMMAND)
service.record_error(ErrorSource.COMMAND)
output = generate_metrics_output(service)
assert 'owncastsentry_errors_total{source="scheduler_loop"} 1.0' in output
assert 'owncastsentry_errors_total{source="command"} 2.0' in output
@pytest.mark.parametrize(
"source",
[pytest.param(s, id=s) for s in ErrorSource],
)
def test_all_sources_initialized(self, source: ErrorSource) -> None:
"""All known source labels exist at zero on init."""
service = MetricsService()
output = generate_metrics_output(service)
expected = f'owncastsentry_errors_total{{source="{source}"}} 0.0'
assert expected in output
class TestRegistryOutput:
"""Prometheus registry output."""
def test_returns_string(self) -> None:
"""Return a string (not bytes)."""
service = MetricsService()
output = generate_metrics_output(service)
assert isinstance(output, str)
def test_contains_help_lines(self) -> None:
"""Include HELP lines for registered metrics."""
service = MetricsService()
output = generate_metrics_output(service)
assert "# HELP owncastsentry_notification_delivery_total" in output
assert "# HELP owncastsentry_errors_total" in output
assert "# HELP owncastsentry_info" in output
def test_uses_isolated_registry(self) -> None:
"""Use a custom registry, not the global default."""
service = MetricsService()
output = generate_metrics_output(service)
assert "python_gc" not in output
assert "process_" not in output
+193
View File
@@ -0,0 +1,193 @@
# 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
#
# 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.
"""Tests for data models."""
import pytest
from owncastsentry.models import StreamConfig, StreamState, StreamStatus
from owncastsentry.utils import (
MAX_INSTANCE_TITLE_LENGTH,
MAX_STREAM_TITLE_LENGTH,
MAX_TAG_LENGTH,
UNKNOWN_STATUS_THRESHOLD,
)
class TestStreamStateStatus:
"""Stream status derivation from state fields."""
@pytest.mark.parametrize(
("failure_counter", "last_connect_time", "expected"),
[
pytest.param(
UNKNOWN_STATUS_THRESHOLD + 1,
None,
StreamStatus.UNKNOWN,
id="above-threshold-offline-returns-unknown",
),
pytest.param(
UNKNOWN_STATUS_THRESHOLD + 1,
"2026-01-01T00:00:00Z",
StreamStatus.UNKNOWN,
id="above-threshold-online-returns-unknown",
),
pytest.param(
0,
"2026-01-01T00:00:00Z",
StreamStatus.ONLINE,
id="zero-failures-with-connect-time-returns-online",
),
pytest.param(
0,
None,
StreamStatus.OFFLINE,
id="zero-failures-no-connect-time-returns-offline",
),
pytest.param(
UNKNOWN_STATUS_THRESHOLD,
"2026-01-01T00:00:00Z",
StreamStatus.ONLINE,
id="at-threshold-with-connect-time-returns-online",
),
pytest.param(
UNKNOWN_STATUS_THRESHOLD,
None,
StreamStatus.OFFLINE,
id="at-threshold-no-connect-time-returns-offline",
),
],
)
def test_status(
self,
failure_counter: int,
last_connect_time: str | None,
expected: StreamStatus,
) -> None:
"""Return the correct status based on failure counter and connect time."""
state = StreamState(
domain="example.com",
failure_counter=failure_counter,
last_connect_time=last_connect_time,
)
assert state.status is expected
class TestStreamStateFromApiResponse:
"""StreamState construction from an API response dictionary."""
def test_typical_response(self) -> None:
"""Populate all fields from a complete API response."""
response = {
"streamTitle": "My Stream",
"lastConnectTime": "2026-01-01T00:00:00Z",
"lastDisconnectTime": "2025-12-31T23:00:00Z",
}
state = StreamState.from_api_response(response, "example.com")
assert state.domain == "example.com"
assert state.title == "My Stream"
assert state.last_connect_time == "2026-01-01T00:00:00Z"
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
assert state.name is None
assert state.failure_counter == 0
def test_empty_response_defaults(self) -> None:
"""Use defaults when optional fields are missing."""
state = StreamState.from_api_response({}, "bare.example.com")
assert state.domain == "bare.example.com"
assert state.title == ""
assert state.last_connect_time is None
assert state.last_disconnect_time is None
def test_title_truncation(self) -> None:
"""Truncate the stream title to MAX_STREAM_TITLE_LENGTH."""
long_title = "A" * (MAX_STREAM_TITLE_LENGTH + 50)
response = {"streamTitle": long_title}
state = StreamState.from_api_response(response, "example.com")
assert len(state.title) == MAX_STREAM_TITLE_LENGTH
assert state.title == "A" * MAX_STREAM_TITLE_LENGTH
class TestStreamStateFromDbRow:
"""StreamState construction from a database row dictionary."""
def test_typical_row(self) -> None:
"""Populate all fields from a complete database row."""
row = {
"domain": "example.com",
"name": "Test Instance",
"title": "Live Now",
"last_connect_time": "2026-01-01T00:00:00Z",
"last_disconnect_time": "2025-12-31T23:00:00Z",
"failure_counter": 3,
}
state = StreamState.from_db_row(row)
assert state.domain == "example.com"
assert state.name == "Test Instance"
assert state.title == "Live Now"
assert state.last_connect_time == "2026-01-01T00:00:00Z"
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
assert state.failure_counter == 3
def test_row_with_none_optional_fields(self) -> None:
"""Accept None for optional fields in a database row."""
row = {
"domain": "example.com",
"name": None,
"title": None,
"last_connect_time": None,
"last_disconnect_time": None,
"failure_counter": 0,
}
state = StreamState.from_db_row(row)
assert state.domain == "example.com"
assert state.name is None
assert state.title is None
assert state.last_connect_time is None
assert state.last_disconnect_time is None
assert state.failure_counter == 0
class TestStreamConfigFromApiResponse:
"""StreamConfig construction from an API response dictionary."""
def test_typical_response(self) -> None:
"""Populate name and tags from a complete API response."""
response = {"name": "My Instance", "tags": ["gaming", "music"]}
config = StreamConfig.from_api_response(response)
assert config.name == "My Instance"
assert config.tags == ["gaming", "music"]
def test_missing_keys_defaults(self) -> None:
"""Use defaults when name and tags keys are missing."""
config = StreamConfig.from_api_response({})
assert config.name == ""
assert config.tags == []
def test_name_truncation(self) -> None:
"""Truncate the instance name to MAX_INSTANCE_TITLE_LENGTH."""
long_name = "B" * (MAX_INSTANCE_TITLE_LENGTH + 50)
response = {"name": long_name, "tags": []}
config = StreamConfig.from_api_response(response)
assert len(config.name) == MAX_INSTANCE_TITLE_LENGTH
assert config.name == "B" * MAX_INSTANCE_TITLE_LENGTH
def test_tag_truncation(self) -> None:
"""Truncate each tag to MAX_TAG_LENGTH."""
long_tag = "C" * (MAX_TAG_LENGTH + 10)
response = {"name": "", "tags": [long_tag, "short"]}
config = StreamConfig.from_api_response(response)
assert len(config.tags[0]) == MAX_TAG_LENGTH
assert config.tags[0] == "C" * MAX_TAG_LENGTH
assert config.tags[1] == "short"
+457
View File
@@ -0,0 +1,457 @@
# 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
#
# 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.
"""Tests for the notification service."""
import logging
import time
from typing import TYPE_CHECKING
import pytest
from owncastsentry.metrics import MetricsService
from owncastsentry.notification_service import NotificationService
from owncastsentry.utils import SECONDS_BETWEEN_NOTIFICATIONS
from tests.conftest import _StubMatrixClient, generate_metrics_output
if TYPE_CHECKING:
from owncastsentry.database import StreamRepository, SubscriptionRepository
def _make_service(
*,
client: _StubMatrixClient,
subscription_repo: SubscriptionRepository,
metrics: MetricsService | None = None,
) -> NotificationService:
"""Build a NotificationService with a stub client and real repo."""
return NotificationService(
client=client,
subscription_repo=subscription_repo,
logger=logging.getLogger("test"),
metrics=metrics or MetricsService(),
)
class TestCanNotify:
"""Rate-limiting logic for notification cooldowns."""
def test_first_notification_allowed(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Allow the first notification for an unseen domain."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
assert service._can_notify("example.com") is True
def test_within_cooldown_blocked(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Block notifications within the cooldown window."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
service.notification_timers_cache["example.com"] = time.monotonic()
assert service._can_notify("example.com") is False
def test_after_cooldown_allowed(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Allow notifications after the cooldown window expires."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
# Subtract an extra second to ensure the cooldown has fully elapsed
service.notification_timers_cache["example.com"] = (
time.monotonic() - SECONDS_BETWEEN_NOTIFICATIONS - 1
)
assert service._can_notify("example.com") is True
class TestGetLastNotificationTime:
"""Last notification timestamp retrieval."""
def test_returns_cached_value(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Return the cached timestamp for a known domain."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
service.notification_timers_cache["example.com"] = 12345.0
assert service.get_last_notification_time("example.com") == 12345.0
def test_returns_zero_for_unknown(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Return 0 for a domain that has never been notified."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
assert service.get_last_notification_time("unknown.com") == 0
class TestFormatMessage:
"""Notification message formatting."""
@pytest.mark.parametrize(
("name", "title", "domain", "tags", "title_change", "expected"),
[
pytest.param(
"My Stream",
"Playing Games",
"example.com",
[],
False,
"🎥 My Stream is now live!\n"
"Stream Title: Playing Games\n"
"\n"
"To tune in, visit: https://example.com/",
id="go-live-with-title",
),
pytest.param(
"My Stream",
"New Title",
"example.com",
[],
True,
"📝 My Stream has changed its stream title!\n"
"Stream Title: New Title\n"
"\n"
"To tune in, visit: https://example.com/",
id="title-change",
),
pytest.param(
"My Stream",
"",
"example.com",
[],
False,
"🎥 My Stream is now live!\n\nTo tune in, visit: https://example.com/",
id="go-live-no-title",
),
pytest.param(
"",
"Title",
"example.com",
[],
False,
"🎥 example.com is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/",
id="name-fallback-to-domain",
),
pytest.param(
"Stream",
"Title",
"example.com",
["gaming", "fun"],
False,
"🎥 Stream is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/\n"
"\n"
"#gaming #fun",
id="with-tags",
),
pytest.param(
"Stream",
"Title",
"example.com",
[".hidden", "visible"],
False,
"🎥 Stream is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/\n"
"\n"
"#visible",
id="dot-prefix-tag-filtered",
),
pytest.param(
"Stream",
"Title",
"example.com",
[".secret"],
False,
"🎥 Stream is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/",
id="all-tags-dot-prefixed",
),
],
)
def test_format_message(
self,
name: str,
title: str,
domain: str,
tags: list[str],
title_change: bool,
expected: str,
subscription_repo: SubscriptionRepository,
) -> None:
"""Format the notification message with expected content."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
result = service._format_message(name, title, domain, tags, title_change)
assert result == expected
class TestNotifyStreamLive:
"""End-to-end notification sending."""
async def test_sends_to_all_subscribed_rooms(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send notifications to every room subscribed to the domain."""
client = _StubMatrixClient()
service = _make_service(client=client, subscription_repo=subscription_repo)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room1:matrix.org")
await subscription_repo.add("example.com", "!room2:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", ["tag"])
assert len(client.sent_messages) == 2
room_ids = {msg.room_id for msg in client.sent_messages}
assert room_ids == {"!room1:matrix.org", "!room2:matrix.org"}
expected_body = (
"🎥 Stream is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/\n"
"\n"
"#tag"
)
for msg in client.sent_messages:
assert msg.content.body == expected_body
async def test_skips_when_rate_limited(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Skip sending when the domain is within the rate-limit cooldown."""
client = _StubMatrixClient()
service = _make_service(client=client, subscription_repo=subscription_repo)
service.notification_timers_cache["example.com"] = time.monotonic()
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room1:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", [])
assert len(client.sent_messages) == 0
async def test_counts_failures(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send to remaining rooms after a delivery failure."""
client = _StubMatrixClient()
client.should_fail_for_rooms.add("!bad:matrix.org")
service = _make_service(client=client, subscription_repo=subscription_repo)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!bad:matrix.org")
await subscription_repo.add("example.com", "!good:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", [])
assert len(client.sent_messages) == 1
assert client.sent_messages[0].room_id == "!good:matrix.org"
assert client.sent_messages[0].content.body == (
"🎥 Stream is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/"
)
class TestSendCleanupWarning:
"""Cleanup warning notification sending."""
async def test_sends_warning_to_all_rooms(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send cleanup warning to all subscribed rooms."""
client = _StubMatrixClient()
service = _make_service(client=client, subscription_repo=subscription_repo)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room1:matrix.org")
await service.send_cleanup_warning("example.com")
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.body == (
"⚠️ Warning: Subscription Cleanup Scheduled\n"
"\n"
"The Owncast instance at example.com has been "
"unreachable for 83 days. If it remains "
"unreachable for 7 more days "
"(90 days total), this subscription "
"will be automatically removed."
)
class TestSendCleanupDeletion:
"""Cleanup deletion notification sending."""
async def test_sends_deletion_to_all_rooms(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send cleanup deletion notice to all subscribed rooms."""
client = _StubMatrixClient()
service = _make_service(client=client, subscription_repo=subscription_repo)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room1:matrix.org")
await service.send_cleanup_deletion("example.com")
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.body == (
"🗑️ Subscription Automatically Removed\n"
"\n"
"The Owncast instance at example.com has been "
"unreachable for 90 days and has been "
"automatically removed from subscriptions in this "
"room.\n"
"\n"
"If the instance comes online again and you want to "
"resubscribe, run `!subscribe example.com`."
)
class TestNotificationMetrics:
"""Notification metrics recording."""
async def test_records_live_notification(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Record a live notification metric."""
client = _StubMatrixClient()
metrics = MetricsService()
service = _make_service(
client=client, subscription_repo=subscription_repo, metrics=metrics
)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", [])
output = generate_metrics_output(metrics)
expected = (
"owncastsentry_notification_delivery_total"
'{result="success",type="live"} 1.0'
)
assert expected in output
async def test_records_title_change_notification(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Record a title_change notification metric."""
client = _StubMatrixClient()
metrics = MetricsService()
service = _make_service(
client=client, subscription_repo=subscription_repo, metrics=metrics
)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
await service.notify_stream_live(
"example.com", "Stream", "Title", [], title_change=True
)
output = generate_metrics_output(metrics)
expected = (
"owncastsentry_notification_delivery_total"
'{result="success",type="title_change"} 1.0'
)
assert expected in output
async def test_records_cleanup_warning_notification(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Record a cleanup_warning notification metric."""
client = _StubMatrixClient()
metrics = MetricsService()
service = _make_service(
client=client, subscription_repo=subscription_repo, metrics=metrics
)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
await service.send_cleanup_warning("example.com")
output = generate_metrics_output(metrics)
expected = (
"owncastsentry_notification_delivery_total"
'{result="success",type="cleanup_warning"} 1.0'
)
assert expected in output
async def test_records_cleanup_deletion_notification(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Record a cleanup_deletion notification metric."""
client = _StubMatrixClient()
metrics = MetricsService()
service = _make_service(
client=client, subscription_repo=subscription_repo, metrics=metrics
)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
await service.send_cleanup_deletion("example.com")
output = generate_metrics_output(metrics)
expected = (
"owncastsentry_notification_delivery_total"
'{result="success",type="cleanup_deletion"} 1.0'
)
assert expected in output
async def test_no_metric_when_rate_limited(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Do not record metric when notification is rate-limited."""
client = _StubMatrixClient()
metrics = MetricsService()
service = _make_service(
client=client, subscription_repo=subscription_repo, metrics=metrics
)
service.notification_timers_cache["example.com"] = time.monotonic()
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", [])
output = generate_metrics_output(metrics)
assert 'result="success",type="live"} 0.0' in output
+280
View File
@@ -0,0 +1,280 @@
# 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
#
# 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.
"""Tests for the Owncast HTTP client."""
import json
import logging
from typing import TYPE_CHECKING
import pytest
from aioresponses import aioresponses
from owncastsentry.metrics import MetricsService
from owncastsentry.owncast_client import OwncastClient
from tests.conftest import (
VALID_CONFIG_RESPONSE,
VALID_STATUS_RESPONSE,
generate_metrics_output,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@pytest.fixture
async def owncast_client() -> AsyncIterator[OwncastClient]:
"""Create an OwncastClient and close it after the test."""
client = OwncastClient(
logger=logging.getLogger("test"),
version="0.0.0",
metrics=MetricsService(),
)
yield client
await client.close()
class TestGetStreamState:
"""Stream state retrieval from the status API."""
async def test_returns_state_on_success(
self, owncast_client: OwncastClient
) -> None:
"""Return a StreamState with correct fields on a valid 200 response."""
with aioresponses() as mocked:
mocked.get(
"https://stream.logal.dev/api/status",
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
result = await owncast_client.get_stream_state("stream.logal.dev")
assert result is not None
assert result.domain == "stream.logal.dev"
assert (
result.title
== "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!" # noqa: E501
)
assert result.last_connect_time is None
assert result.last_disconnect_time == "2026-03-04T21:05:32-05:00"
async def test_returns_none_on_missing_field(
self, owncast_client: OwncastClient
) -> None:
"""Return None when the response is missing required fields."""
incomplete = {"streamTitle": "Test Stream", "online": True}
with aioresponses() as mocked:
mocked.get(
"https://stream.logal.dev/api/status",
body=json.dumps(incomplete).encode(),
)
result = await owncast_client.get_stream_state("stream.logal.dev")
assert result is None
async def test_returns_none_on_invalid_json(
self, owncast_client: OwncastClient
) -> None:
"""Return None when the response body is not valid JSON."""
with aioresponses() as mocked:
mocked.get(
"https://stream.logal.dev/api/status",
body=b"not json",
)
result = await owncast_client.get_stream_state("stream.logal.dev")
assert result is None
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
"""Return None when the response status is not 200."""
with aioresponses() as mocked:
mocked.get(
"https://stream.logal.dev/api/status",
status=404,
)
result = await owncast_client.get_stream_state("stream.logal.dev")
assert result is None
async def test_returns_none_on_connection_error(
self, owncast_client: OwncastClient
) -> None:
"""Return None when a connection error occurs."""
with aioresponses() as mocked:
mocked.get(
"https://stream.logal.dev/api/status",
exception=ConnectionError(),
)
result = await owncast_client.get_stream_state("stream.logal.dev")
assert result is None
class TestGetStreamConfig:
"""Stream configuration retrieval from the config API."""
async def test_returns_config_on_success(
self, owncast_client: OwncastClient
) -> None:
"""Return a StreamConfig with correct fields on a valid 200 response."""
with aioresponses() as mocked:
mocked.get(
"https://stream.logal.dev/api/config",
body=json.dumps(VALID_CONFIG_RESPONSE).encode(),
)
result = await owncast_client.get_stream_config("stream.logal.dev")
assert result is not None
assert result.name == "LogalDeveloper's Live Stream"
assert result.tags == [
"video games",
"chatting",
"casual",
"english",
"streaming",
"owncast",
"variety",
]
async def test_returns_none_on_invalid_json(
self, owncast_client: OwncastClient
) -> None:
"""Return None when the response body is not valid JSON."""
with aioresponses() as mocked:
mocked.get(
"https://stream.logal.dev/api/config",
body=b"not json",
)
result = await owncast_client.get_stream_config("stream.logal.dev")
assert result is None
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
"""Return None when the response status is not 200."""
with aioresponses() as mocked:
mocked.get(
"https://stream.logal.dev/api/config",
status=500,
)
result = await owncast_client.get_stream_config("stream.logal.dev")
assert result is None
async def test_returns_none_on_connection_error(
self, owncast_client: OwncastClient
) -> None:
"""Return None when a connection error occurs."""
with aioresponses() as mocked:
mocked.get(
"https://stream.logal.dev/api/config",
exception=ConnectionError(),
)
result = await owncast_client.get_stream_config("stream.logal.dev")
assert result is None
class TestValidateInstance:
"""Owncast instance validation via the status API."""
async def test_returns_true_for_valid_instance(
self, owncast_client: OwncastClient
) -> None:
"""Return True when the status endpoint returns a valid response."""
with aioresponses() as mocked:
mocked.get(
"https://valid.com/api/status",
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
result = await owncast_client.validate_instance("valid.com")
assert result is True
async def test_returns_false_for_invalid_instance(
self, owncast_client: OwncastClient
) -> None:
"""Return False when the status endpoint returns a non-200 response."""
with aioresponses() as mocked:
mocked.get(
"https://invalid.com/api/status",
status=404,
)
result = await owncast_client.validate_instance("invalid.com")
assert result is False
class TestResponseTimeMetrics:
"""Response time histogram recording."""
async def test_records_on_success(self) -> None:
"""Record response time on a successful request."""
metrics = MetricsService()
client = OwncastClient(
logger=logging.getLogger("test"),
version="0.0.0",
metrics=metrics,
)
with aioresponses() as mocked:
mocked.get(
"https://example.com/api/status",
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
)
await client.get_stream_state("example.com")
output = generate_metrics_output(metrics)
assert 'owncastsentry_api_response_seconds{domain="example.com"}' in output
await client.close()
async def test_no_observation_on_failure(self) -> None:
"""Do not record response time when request fails."""
metrics = MetricsService()
client = OwncastClient(
logger=logging.getLogger("test"),
version="0.0.0",
metrics=metrics,
)
with aioresponses() as mocked:
mocked.get(
"https://example.com/api/status",
status=500,
)
await client.get_stream_state("example.com")
output = generate_metrics_output(metrics)
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
await client.close()
async def test_no_observation_on_connection_error(self) -> None:
"""Do not record response time on connection error."""
metrics = MetricsService()
client = OwncastClient(
logger=logging.getLogger("test"),
version="0.0.0",
metrics=metrics,
)
with aioresponses() as mocked:
mocked.get(
"https://example.com/api/status",
exception=ConnectionError(),
)
await client.get_stream_state("example.com")
output = generate_metrics_output(metrics)
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
await client.close()
class TestOpenConnectionCount:
"""Open connection count."""
async def test_zero_with_no_requests(self, owncast_client: OwncastClient) -> None:
"""Return zero when no requests have been made."""
assert owncast_client.open_connection_count == 0
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
# 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
#
# 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.
"""Tests for utility functions and constants."""
import pytest
from owncastsentry.utils import (
domainify,
escape_markdown,
sanitize_for_markdown,
sanitize_for_plain_text,
should_query_stream,
truncate,
user_agent,
)
class TestUserAgent:
"""User-Agent header construction."""
@pytest.mark.parametrize(
("version", "expected"),
[
pytest.param(
"1.2.3",
"OwncastSentry/1.2.3 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
id="semver",
),
pytest.param(
"0.0.0",
"OwncastSentry/0.0.0 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
id="zeroed",
),
pytest.param(
"1.1.1.dev10+gf0146d061.d20260313",
"OwncastSentry/1.1.1.dev10+gf0146d061.d20260313 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
id="dev-version",
),
],
)
def test_user_agent(self, version: str, expected: str) -> None:
"""Build a correctly formatted User-Agent header."""
assert user_agent(version) == expected
class TestShouldQueryStream:
"""Progressive backoff logic for stream polling."""
@pytest.mark.parametrize(
("counter", "expected"),
[
pytest.param(0, True, id="counter-0-always-query"),
pytest.param(1, True, id="counter-1-always-query"),
pytest.param(4, True, id="counter-4-always-query"),
pytest.param(5, False, id="counter-5-skip-odd"),
pytest.param(6, True, id="counter-6-query-even"),
pytest.param(9, False, id="counter-9-skip-odd"),
pytest.param(10, False, id="counter-10-skip-not-mod-3"),
pytest.param(12, True, id="counter-12-query-mod-3"),
pytest.param(14, False, id="counter-14-skip-not-mod-3"),
pytest.param(15, True, id="counter-15-query-mod-5"),
pytest.param(16, False, id="counter-16-skip-not-mod-5"),
pytest.param(20, True, id="counter-20-query-mod-5"),
pytest.param(29, False, id="counter-29-skip-not-mod-5"),
pytest.param(30, True, id="counter-30-query-mod-15"),
pytest.param(31, False, id="counter-31-skip-not-mod-15"),
pytest.param(45, True, id="counter-45-query-mod-15"),
pytest.param(100, False, id="counter-100-skip-not-mod-15"),
pytest.param(105, True, id="counter-105-query-mod-15"),
],
)
def test_backoff_tiers(self, counter: int, expected: bool) -> None:
"""Return the expected query decision for each backoff tier."""
assert should_query_stream(counter) == expected
class TestDomainify:
"""Domain extraction and sanitization from user input."""
@pytest.mark.parametrize(
("input_url", "expected"),
[
pytest.param("example.com", "example.com", id="bare-domain"),
pytest.param("https://example.com", "example.com", id="https-url"),
pytest.param("http://example.com", "example.com", id="http-url"),
pytest.param("https://example.com:8080", "example.com", id="url-with-port"),
pytest.param(
"https://example.com/path/to/page",
"example.com",
id="url-with-path",
),
pytest.param(
"user@stream.logal.dev",
"stream.logal.dev",
id="email-style",
),
pytest.param("EXAMPLE.COM", "example.com", id="uppercase"),
pytest.param("exam!ple.com", "example.com", id="special-chars-stripped"),
pytest.param(".example.com.", "example.com", id="leading-trailing-dots"),
pytest.param("-example.com-", "example.com", id="leading-trailing-hyphens"),
pytest.param(
"sub.domain.example.com",
"sub.domain.example.com",
id="subdomain",
),
],
)
def test_extracts_domain(self, input_url: str, expected: str) -> None:
"""Extract and sanitize the domain from various input formats."""
assert domainify(input_url) == expected
class TestTruncate:
"""Text truncation to a maximum length."""
@pytest.mark.parametrize(
("text", "max_length", "expected"),
[
pytest.param("hello", 10, "hello", id="under-limit"),
pytest.param("hello", 5, "hello", id="exact-limit"),
pytest.param("hello world", 5, "hello", id="over-limit"),
pytest.param("", 5, "", id="empty-string"),
],
)
def test_truncates(self, text: str, max_length: int, expected: str) -> None:
"""Truncate text that exceeds the maximum length."""
assert truncate(text, max_length) == expected
class TestEscapeMarkdown:
"""Markdown special character escaping."""
@pytest.mark.parametrize(
("input_text", "expected"),
[
pytest.param("hello", "hello", id="plain-text-unchanged"),
pytest.param("*bold*", "\\*bold\\*", id="asterisks"),
pytest.param("_italic_", "\\_italic\\_", id="underscores"),
pytest.param("[link](url)", "\\[link\\]\\(url\\)", id="link-syntax"),
pytest.param("`code`", "\\`code\\`", id="backticks"),
pytest.param("# heading", "\\# heading", id="heading"),
pytest.param("> quote", "\\> quote", id="blockquote"),
pytest.param("<html>", "\\<html\\>", id="angle-brackets"),
pytest.param("a & b", "a \\& b", id="ampersand"),
pytest.param("a\\b", "a\\\\b", id="backslash"),
pytest.param("", "", id="empty-string"),
],
)
def test_escapes_special_chars(self, input_text: str, expected: str) -> None:
"""Escape the given Markdown special character."""
assert escape_markdown(input_text) == expected
class TestSanitizeForPlainText:
"""Plain text sanitization for notifications."""
@pytest.mark.parametrize(
("input_text", "expected"),
[
pytest.param("hello world", "hello world", id="plain-text"),
pytest.param("line1\nline2", "line1 line2", id="newline-removed"),
pytest.param("line1\rline2", "line1 line2", id="carriage-return"),
pytest.param("line1\r\nline2", "line1 line2", id="crlf-removed"),
pytest.param(
"too many spaces", "too many spaces", id="spaces-collapsed"
),
pytest.param("", "", id="empty-string"),
],
)
def test_sanitizes(self, input_text: str, expected: str) -> None:
"""Sanitize the text for safe plain-text rendering."""
assert sanitize_for_plain_text(input_text) == expected
class TestSanitizeForMarkdown:
"""Markdown sanitization combining newline removal and escaping."""
def test_removes_newlines_and_escapes(self) -> None:
"""Remove newlines and escape Markdown special characters."""
result = sanitize_for_markdown("*bold*\nnew line")
assert result == "\\*bold\\* new line"
def test_empty_string(self) -> None:
"""Return empty string unchanged."""
assert sanitize_for_markdown("") == ""
Generated
+1640
View File
File diff suppressed because it is too large Load Diff