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
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:
+1
-1
Submodule docs updated: 4c6c3f2658...b26ca8b75f
@@ -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",
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Vendored
+7
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
Reference in New Issue
Block a user