Added centralized templating system with static asset serving.
CI / Formatting (push) Successful in 13s
CI / Linting (push) Successful in 14s
CI / Tests (Python 3.12) (push) Failing after 29s
CI / Tests (Python 3.13) (push) Failing after 29s
CI / Tests (Python 3.14) (push) Failing after 26s
CI / Type Checking (push) Failing after 25s
CI / Spelling (push) Successful in 13s

This commit is contained in:
2026-02-27 10:05:52 -05:00
parent 4e2c8b4d67
commit 410c0cd791
14 changed files with 238 additions and 152 deletions
+1 -1
Submodule docs updated: 4c6c3f2658...b26ca8b75f
+4
View File
@@ -109,6 +109,9 @@ from .routes import (
# Storage API. # Storage API.
from .storage import ModuleStorage, StorageError from .storage import ModuleStorage, StorageError
# Template rendering.
from .templates import ModuleTemplates
__all__ = [ __all__ = [
"ChatEvent", "ChatEvent",
"CommandContext", "CommandContext",
@@ -128,6 +131,7 @@ __all__ = [
"ModuleEvents", "ModuleEvents",
"ModuleRoutes", "ModuleRoutes",
"ModuleStorage", "ModuleStorage",
"ModuleTemplates",
"NameChangedEvent", "NameChangedEvent",
"OwncastAdminClient", "OwncastAdminClient",
"OwncastClient", "OwncastClient",
+19
View File
@@ -40,6 +40,7 @@ if TYPE_CHECKING:
from .owncast_admin_client import OwncastAdminClient from .owncast_admin_client import OwncastAdminClient
from .owncast_client import OwncastClient from .owncast_client import OwncastClient
from .storage import ModuleStorage from .storage import ModuleStorage
from .templates import ModuleTemplates
@dataclass @dataclass
@@ -74,6 +75,9 @@ class ModuleContext:
# Shared HTTP client for making web requests. # Shared HTTP client for making web requests.
http: HttpClient http: HttpClient
# Module-scoped Jinja2 template rendering.
templates: ModuleTemplates
# Optional admin client for the Owncast Admin API (None if admin is not enabled). # Optional admin client for the Owncast Admin API (None if admin is not enabled).
admin_client: OwncastAdminClient | None = None admin_client: OwncastAdminClient | None = None
@@ -164,6 +168,11 @@ class EventContext[E]:
"""Shared HTTP client for making web requests.""" """Shared HTTP client for making web requests."""
return self.module.http return self.module.http
@property
def templates(self) -> ModuleTemplates:
"""Module-scoped Jinja2 template rendering."""
return self.module.templates
@property @property
def admin_client(self) -> OwncastAdminClient | None: def admin_client(self) -> OwncastAdminClient | None:
"""Optional client for the Owncast Admin API (None if admin is not enabled).""" """Optional client for the Owncast Admin API (None if admin is not enabled)."""
@@ -284,6 +293,11 @@ class CommandContext:
"""Shared HTTP client for making web requests.""" """Shared HTTP client for making web requests."""
return self.module.http return self.module.http
@property
def templates(self) -> ModuleTemplates:
"""Module-scoped Jinja2 template rendering."""
return self.module.templates
@property @property
def admin_client(self) -> OwncastAdminClient | None: def admin_client(self) -> OwncastAdminClient | None:
"""Optional client for the Owncast Admin API (None if admin is not enabled).""" """Optional client for the Owncast Admin API (None if admin is not enabled)."""
@@ -353,6 +367,11 @@ class RouteContext:
"""Shared HTTP client for making web requests.""" """Shared HTTP client for making web requests."""
return self.module.http return self.module.http
@property
def templates(self) -> ModuleTemplates:
"""Module-scoped Jinja2 template rendering."""
return self.module.templates
@property @property
def admin_client(self) -> OwncastAdminClient | None: def admin_client(self) -> OwncastAdminClient | None:
"""Optional client for the Owncast Admin API (None if admin is not enabled).""" """Optional client for the Owncast Admin API (None if admin is not enabled)."""
+76
View File
@@ -0,0 +1,76 @@
# 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.
"""Jinja2 template rendering for Owlbot modules."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import jinja2
from owlbot import __version__
if TYPE_CHECKING:
from pathlib import Path
class ModuleTemplates:
"""Module-scoped Jinja2 template rendering.
Uses a ChoiceLoader that checks the module's ``templates/`` directory
first (if it exists), then falls back to Owlbot's core template
directory. This lets modules override any core template while
inheriting shared layouts like ``base.html`` by default.
"""
def __init__(self, module_dir: Path, core_template_dir: Path) -> None:
"""Initialize the template environment for a module.
:param module_dir: Root directory of the module (contains ``templates/``
subdirectory if the module ships its own templates).
:param core_template_dir: Owlbot's shared template directory
(``owlbot/templates/``).
"""
loaders: list[jinja2.BaseLoader] = []
module_template_dir = module_dir / "templates"
if module_template_dir.is_dir():
loaders.append(jinja2.FileSystemLoader(module_template_dir))
loaders.append(jinja2.FileSystemLoader(core_template_dir))
self._env = jinja2.Environment(
loader=jinja2.ChoiceLoader(loaders),
autoescape=True,
)
self._env.globals["owlbot_version"] = __version__
def render(self, template_name: str, **context: Any) -> str:
"""Load and render a template by name.
:param template_name: Name of the template file (e.g. ``"list.html"``).
:param context: Variables to pass to the template.
:return: The rendered template string.
"""
template = self._env.get_template(template_name)
return template.render(**context)
@property
def env(self) -> jinja2.Environment:
"""The underlying Jinja2 Environment for advanced use.
Use this to register custom filters, tests, or globals.
"""
return self._env
@@ -14,19 +14,10 @@
"""Web routes for the custom commands module.""" """Web routes for the custom commands module."""
from pathlib import Path
import jinja2
from aiohttp import web from aiohttp import web
from owlbot.api import RouteContext, on_route from owlbot.api import RouteContext, on_route
_template_dir = Path(__file__).resolve().parent / "templates"
_jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(_template_dir),
autoescape=True,
)
@on_route("/list", methods=["GET"]) @on_route("/list", methods=["GET"])
async def command_list_page(ctx: RouteContext) -> web.Response: async def command_list_page(ctx: RouteContext) -> web.Response:
@@ -63,7 +54,5 @@ async def command_list_page(ctx: RouteContext) -> web.Response:
for row in rows for row in rows
] ]
template = _jinja_env.get_template("list.html") page = ctx.templates.render("list.html", commands=commands, prefix=prefix)
page = template.render(commands=commands, prefix=ctx.commands.prefix)
return web.Response(text=page, content_type="text/html") return web.Response(text=page, content_type="text/html")
@@ -1,37 +1,26 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}Custom Commands{% endblock %}
<head> {% block content %}
<meta charset="utf-8"> <h1 class="mb-3">Custom Commands</h1>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Custom Commands</title>
<style>
body { font-family: sans-serif; margin: 2rem; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 0.5rem 0.75rem; text-align: left; }
th { background: #f5f5f5; }
td:nth-child(4), th:nth-child(4),
td:nth-child(5), th:nth-child(5) { text-align: center; }
</style>
</head>
<body>
<h1>Custom Commands</h1>
{% if commands %} {% if commands %}
<table> <table class="table table-bordered table-striped">
<thead><tr> <thead>
<tr>
<th>Command</th> <th>Command</th>
<th>Aliases</th> <th>Aliases</th>
<th>Response</th> <th>Response</th>
<th>Cooldown</th> <th class="text-center">Cooldown</th>
<th>Permissions</th> <th class="text-center">Permissions</th>
</tr></thead> </tr>
</thead>
<tbody> <tbody>
{% for cmd in commands %} {% for cmd in commands %}
<tr> <tr>
<td>{{ prefix }}{{ cmd.name }}</td> <td>{{ prefix }}{{ cmd.name }}</td>
<td>{{ cmd.aliases }}</td> <td>{{ cmd.aliases }}</td>
<td>{{ cmd.response }}</td> <td>{{ cmd.response }}</td>
<td>{{ cmd.cooldown }}</td> <td class="text-center">{{ cmd.cooldown }}</td>
<td>{{ cmd.permissions }}</td> <td class="text-center">{{ cmd.permissions }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -39,5 +28,4 @@ td:nth-child(5), th:nth-child(5) { text-align: center; }
{% else %} {% else %}
<p>No custom commands defined.</p> <p>No custom commands defined.</p>
{% endif %} {% endif %}
</body> {% endblock %}
</html>
+1 -11
View File
@@ -18,9 +18,7 @@ Allows moderators to store quotes and anyone to recall random quotes.
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
import jinja2
from aiohttp import web from aiohttp import web
from owlbot.api import ( from owlbot.api import (
@@ -32,12 +30,6 @@ from owlbot.api import (
on_setup, on_setup,
) )
_template_dir = Path(__file__).resolve().parent / "templates"
_jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(_template_dir),
autoescape=True,
)
@on_setup @on_setup
async def setup(ctx: ModuleContext) -> None: async def setup(ctx: ModuleContext) -> None:
@@ -84,9 +76,7 @@ async def quotes_list_page(ctx: RouteContext) -> web.Response:
for row in rows for row in rows
] ]
template = _jinja_env.get_template("list.html") page = ctx.templates.render("list.html", quotes=quotes)
page = template.render(quotes=quotes)
return web.Response(text=page, content_type="text/html") return web.Response(text=page, content_type="text/html")
@@ -1,31 +1,21 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}Quotes{% endblock %}
<head> {% block content %}
<meta charset="utf-8"> <h1 class="mb-3">Quotes</h1>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quotes</title>
<style>
body { font-family: sans-serif; margin: 2rem; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 0.5rem 0.75rem; text-align: left; }
th { background: #f5f5f5; }
td:first-child, th:first-child { text-align: center; }
</style>
</head>
<body>
<h1>Quotes</h1>
{% if quotes %} {% if quotes %}
<table> <table class="table table-bordered table-striped">
<thead><tr> <thead>
<th>#</th> <tr>
<th class="text-center">#</th>
<th>Quote</th> <th>Quote</th>
<th>Added By</th> <th>Added By</th>
<th>Date Added</th> <th>Date Added</th>
</tr></thead> </tr>
</thead>
<tbody> <tbody>
{% for quote in quotes %} {% for quote in quotes %}
<tr> <tr>
<td>{{ quote.id }}</td> <td class="text-center">{{ quote.id }}</td>
<td>{{ quote.text }}</td> <td>{{ quote.text }}</td>
<td>{{ quote.added_by }}</td> <td>{{ quote.added_by }}</td>
<td>{{ quote.date }}</td> <td>{{ quote.date }}</td>
@@ -36,5 +26,4 @@ td:first-child, th:first-child { text-align: center; }
{% else %} {% else %}
<p>No quotes have been added yet.</p> <p>No quotes have been added yet.</p>
{% endif %} {% endif %}
</body> {% endblock %}
</html>
+1 -11
View File
@@ -15,19 +15,11 @@
"""Web routes for the timers module.""" """Web routes for the timers module."""
from datetime import datetime from datetime import datetime
from pathlib import Path
import jinja2
from aiohttp import web from aiohttp import web
from owlbot.api import RouteContext, on_route from owlbot.api import RouteContext, on_route
_template_dir = Path(__file__).resolve().parent / "templates"
_jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(_template_dir),
autoescape=True,
)
@on_route("/list", methods=["GET"]) @on_route("/list", methods=["GET"])
async def timer_list_page(ctx: RouteContext) -> web.Response: async def timer_list_page(ctx: RouteContext) -> web.Response:
@@ -67,7 +59,5 @@ async def timer_list_page(ctx: RouteContext) -> web.Response:
} }
) )
template = _jinja_env.get_template("list.html") page = ctx.templates.render("list.html", timers=timers)
page = template.render(timers=timers)
return web.Response(text=page, content_type="text/html") return web.Response(text=page, content_type="text/html")
@@ -1,42 +1,29 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}Timers{% endblock %}
<head> {% block content %}
<meta charset="utf-8"> <h1 class="mb-3">Timers</h1>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Timers</title>
<style>
body { font-family: sans-serif; margin: 2rem; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 0.5rem 0.75rem; text-align: left; }
th { background: #f5f5f5; }
td:first-child, th:first-child,
td:nth-child(4), th:nth-child(4),
td:nth-child(5), th:nth-child(5),
td:nth-child(6), th:nth-child(6) { text-align: center; }
</style>
</head>
<body>
<h1>Timers</h1>
{% if timers %} {% if timers %}
<table> <table class="table table-bordered table-striped">
<thead><tr> <thead>
<th>#</th> <tr>
<th class="text-center">#</th>
<th>Name</th> <th>Name</th>
<th>Message</th> <th>Message</th>
<th>Interval</th> <th class="text-center">Interval</th>
<th>Min Lines</th> <th class="text-center">Min Lines</th>
<th>Status</th> <th class="text-center">Status</th>
<th>Last Fired</th> <th>Last Fired</th>
</tr></thead> </tr>
</thead>
<tbody> <tbody>
{% for timer in timers %} {% for timer in timers %}
<tr> <tr>
<td>{{ timer.id }}</td> <td class="text-center">{{ timer.id }}</td>
<td>{{ timer.name }}</td> <td>{{ timer.name }}</td>
<td>{{ timer.message }}</td> <td>{{ timer.message }}</td>
<td>{{ timer.interval }}</td> <td class="text-center">{{ timer.interval }}</td>
<td>{{ timer.min_lines }}</td> <td class="text-center">{{ timer.min_lines }}</td>
<td>{{ timer.status }}</td> <td class="text-center">{{ timer.status }}</td>
<td>{{ timer.last_fired }}</td> <td>{{ timer.last_fired }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -45,5 +32,4 @@ td:nth-child(6), th:nth-child(6) { text-align: center; }
{% else %} {% else %}
<p>No timers have been created yet.</p> <p>No timers have been created yet.</p>
{% endif %} {% endif %}
</body> {% endblock %}
</html>
+8
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections.abc import Awaitable, Callable, Coroutine from collections.abc import Awaitable, Callable, Coroutine
from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from aiohttp import web from aiohttp import web
@@ -40,6 +41,8 @@ async def _server_header_middleware(
) -> web.StreamResponse: ) -> web.StreamResponse:
response = await handler(request) response = await handler(request)
response.headers["Server"] = f"Owlbot/{__version__}" response.headers["Server"] = f"Owlbot/{__version__}"
if request.path.startswith("/owlbot/static/"):
response.headers["Cache-Control"] = "max-age=86400"
return response return response
@@ -79,6 +82,11 @@ class HttpServer:
self.app = web.Application(middlewares=[_server_header_middleware]) self.app = web.Application(middlewares=[_server_header_middleware])
self.app.router.add_post(self.config.webhook_path, self._handle_webhook) self.app.router.add_post(self.config.webhook_path, self._handle_webhook)
# Static assets served from the owlbot package.
static_dir = Path(__file__).resolve().parent / "static"
if static_dir.is_dir():
self.app.router.add_static("/owlbot/static", static_dir)
# Catch-all routes for dynamic module route dispatch. # Catch-all routes for dynamic module route dispatch.
# These single aiohttp routes handle all requests under /owlbot/ and # These single aiohttp routes handle all requests under /owlbot/ and
# dispatch them to the appropriate handler via RouteDispatcher lookup # dispatch them to the appropriate handler via RouteDispatcher lookup
+21
View File
@@ -27,6 +27,7 @@ from .api.context import ModuleContext
from .api.owncast_admin_client import OwncastAdminClient from .api.owncast_admin_client import OwncastAdminClient
from .api.owncast_client import OwncastClient from .api.owncast_client import OwncastClient
from .api.storage import ModuleStorage from .api.storage import ModuleStorage
from .api.templates import ModuleTemplates
from .builtin_modules import BUILTIN_MODULE_NAMES from .builtin_modules import BUILTIN_MODULE_NAMES
from .registries.commands import CommandDispatcher, ModuleCommands from .registries.commands import CommandDispatcher, ModuleCommands
from .registries.events import EventDispatcher, ModuleEvents from .registries.events import EventDispatcher, ModuleEvents
@@ -40,6 +41,8 @@ if TYPE_CHECKING:
logger = logging.getLogger("owlbot.modules") logger = logging.getLogger("owlbot.modules")
RESERVED_MODULE_NAMES: frozenset[str] = frozenset({"static"})
class ModuleLoadError(Exception): class ModuleLoadError(Exception):
"""Raised when a module fails to load.""" """Raised when a module fails to load."""
@@ -99,6 +102,8 @@ class ModuleLoader:
handler_timeout=config.handler_timeout, handler_timeout=config.handler_timeout,
) )
self._core_template_dir = Path(__file__).resolve().parent / "templates"
logger.debug( logger.debug(
f"ModuleLoader initialized (user modules directory: {self.modules_dir})" f"ModuleLoader initialized (user modules directory: {self.modules_dir})"
) )
@@ -138,6 +143,13 @@ class ModuleLoader:
logger.debug("Discovering modules...") logger.debug("Discovering modules...")
user_names = self._discover_user_module_names() user_names = self._discover_user_module_names()
reserved = user_names & RESERVED_MODULE_NAMES
for name in sorted(reserved):
logger.warning(
f"User module '{name}' uses a reserved name and will be skipped."
)
user_names -= reserved
overrides = user_names & BUILTIN_MODULE_NAMES overrides = user_names & BUILTIN_MODULE_NAMES
for name in sorted(overrides): for name in sorted(overrides):
logger.info( logger.info(
@@ -273,6 +285,11 @@ class ModuleLoader:
if module_name in self.loaded_modules: if module_name in self.loaded_modules:
raise ValueError(f"Module '{module_name}' is already loaded") raise ValueError(f"Module '{module_name}' is already loaded")
if module_name in RESERVED_MODULE_NAMES:
raise ModuleLoadError(
f"Module name '{module_name}' is reserved and cannot be used"
)
logger.debug(f"Attempting to load module: {module_name}") logger.debug(f"Attempting to load module: {module_name}")
if not self.config.is_module_enabled(module_name): if not self.config.is_module_enabled(module_name):
@@ -299,6 +316,9 @@ class ModuleLoader:
logger.debug(f"Executing module: {module_name}") logger.debug(f"Executing module: {module_name}")
spec.loader.exec_module(module) spec.loader.exec_module(module)
module_dir = module_path.parent
module_templates = ModuleTemplates(module_dir, self._core_template_dir)
scoped_config = ModuleConfig(self.config, module_name) scoped_config = ModuleConfig(self.config, module_name)
storage = ModuleStorage( storage = ModuleStorage(
self.config.storage_dir, module_name, self.config.pool_size self.config.storage_dir, module_name, self.config.pool_size
@@ -317,6 +337,7 @@ class ModuleLoader:
events=module_events, events=module_events,
routes=module_routes, routes=module_routes,
http=self.http_client, http=self.http_client,
templates=module_templates,
admin_client=self.admin_client, admin_client=self.admin_client,
) )
self._module_contexts[module_name] = module_ctx self._module_contexts[module_name] = module_ctx
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/owlbot/static/bootstrap.min.css">
<title>{% block title %}Owlbot{% endblock %}</title>
{% block head %}{% endblock %}
</head>
<body class="container d-flex flex-column gap-3 min-vh-100 pt-4">
<main class="flex-grow-1">
{% block content %}{% endblock %}
</main>
<footer class="py-3 border-top">
<small class="text-body-secondary">Powered by Owlbot v{{ owlbot_version }}</small>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>