Initial commit.

This commit is contained in:
2026-02-14 15:20:52 -05:00
commit 067b7c5a0a
48 changed files with 12169 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Version file (auto-generated by hatch-vcs)
owlbot/_version.py
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
dist/
build/
*.egg-info/
*.egg
# Virtual environments
venv/
.venv/
env/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Runtime data
data/*.db
logs/
modules/
# Local configuration (keep config.yaml.example in repo)
config.yaml
+3
View File
@@ -0,0 +1,3 @@
[submodule "docs"]
path = docs
url = https://git.logal.dev/LogalDeveloper/Owlbot.wiki.git
+1
View File
@@ -0,0 +1 @@
3.14
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+81
View File
@@ -0,0 +1,81 @@
owlbot:
# Address and port to bind the web server.
# Default: 127.0.0.1:8081
host: "127.0.0.1"
port: 8081
# Secret token for the webhook URL path (/webhook/<secret>).
# A cryptographically secure secret is generated on first run and saved
# back to this file automatically. Leave this empty unless you have a
# specific reason to set it manually.
webhook_secret: ""
# Public base URL for Owlbot's web server. Used to construct URLs
# generated by modules (e.g. via ctx.routes.url_for()).
#
# Set this if Owlbot is hosted on a different domain than your Owncast
# instance. If not set, falls back to owncast.url.
#public_base_url: "https://owlbot.example.com"
# Prefix character for chat commands.
# Default: "!"
#command_prefix: "!"
# Maximum seconds to wait for an event or command handler to complete.
# If a handler exceeds this, it is cancelled and any storage changes
# are rolled back.
# Default: 30.0
#handler_timeout: 30.0
# Directory for per-module SQLite databases. Each module gets its own
# <module_name>.db file in this directory. Relative paths are resolved
# from the working directory.
# Default: "data"
#storage_dir: "data"
# Maximum number of SQLite connections per module in the connection pool.
# Connections are created lazily as needed, up to this limit.
# Default: 4
#pool_size: 4
# Directory containing user modules. Built-in modules are loaded from the
# package regardless of this setting.
# Default: "modules" (relative to the working directory)
#modules_dir: "modules"
# Directory for the owlbot.log file. When set, logs are written to
# owlbot.log in this directory in addition to stdout. When unset,
# logs go to stdout only.
#log_dir: "logs"
owncast:
# URL where viewers access your stream (e.g. "https://stream.logal.dev").
# Required.
url: ""
# API access token for the Owncast integrations API. Required for
# sending messages, hiding messages, listing connected clients, etc.
# Generate one in Owncast under Admin -> Integrations -> Access Tokens.
access_token: ""
# Optional admin API access for privileged operations like user
# management, server configuration, and moderation. If not enabled,
# ctx.admin_client will be None in handlers.
admin:
enabled: false
username: "admin"
password: "abc123"
# Per-module configuration. Each key is a module name (the filename without
# .py for single-file modules, or the directory name for package modules).
# Modules are enabled by default; set enabled: false to disable.
# Modules can define their own config keys via ctx.config.register_defaults()
# in their setup() function.
modules:
# custom_commands:
# # Default cooldown in seconds for newly created custom commands.
# # Default: 5
# default_cooldown: 5
# # Maximum placeholder nesting depth in custom command responses.
# # Default: 4
# max_nesting_depth: 4
Submodule
+1
Submodule docs added at 268e3c839a
+37
View File
@@ -0,0 +1,37 @@
# 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.
"""
Owlbot - A modular chat bot for Owncast.
The public API for building modules is in the `owlbot.api` subpackage:
from owlbot.api import on_event, on_command, EventType, EventContext
"""
try:
from ._version import __version__
except ImportError:
__version__ = "0.0.0+unknown"
OWNCAST_TARGET_VERSION = "0.2.4"
from .bot import Owlbot, StartupError
__all__ = [
"OWNCAST_TARGET_VERSION",
"Owlbot",
"StartupError",
"__version__",
]
+236
View File
@@ -0,0 +1,236 @@
# 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.
"""Entry point for running Owlbot as a module."""
from __future__ import annotations
import argparse
import asyncio
import importlib.resources
import logging
import shutil
import signal
import sys
from pathlib import Path
from typing import Any
from . import __version__
from .api.config import Config
from .bot import Owlbot, StartupError
def _init() -> None:
"""Scaffold default config into the current directory."""
config_dest = Path.cwd() / "config.yaml"
if config_dest.exists():
print(f"Skipped (already exists): {config_dest}")
return
# Try the installed package first, fall back to repo root for dev installs.
source = importlib.resources.files("owlbot._defaults") / "config.example.yaml"
if not source.is_file():
source = Path(__file__).resolve().parent.parent / "config.example.yaml"
if not source.is_file():
print(
"Error: Cannot locate default config. "
"Ensure the package is installed correctly.",
file=sys.stderr,
)
sys.exit(1)
if isinstance(source, Path):
shutil.copy2(source, config_dest)
else:
config_dest.write_bytes(source.read_bytes())
print(f"Created: {config_dest}")
def main() -> None:
"""Parse command-line arguments and start the bot."""
if len(sys.argv) > 1 and sys.argv[1] == "init":
_init()
return
parser = argparse.ArgumentParser(
description="Owlbot - Owncast Chat Bot",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Commands:
owlbot init # Scaffold default config
Examples:
python -m owlbot # Run with info logging (default)
python -m owlbot -v # Run with debug logging
python -m owlbot -c my_config.yaml # Use custom config file
""",
)
parser.add_argument(
"-c",
"--config",
default="config.yaml",
help="Path to config file (default: config.yaml)",
)
parser.add_argument(
"-m",
"--modules",
default=None,
help="Path to user modules directory (default: modules)",
)
parser.add_argument(
"--host",
default=None,
help="Address to bind the web server",
)
parser.add_argument(
"--port",
type=int,
default=None,
help="Port for the web server",
)
parser.add_argument(
"-s",
"--storage-dir",
default=None,
help="Directory for module database files",
)
parser.add_argument(
"-l",
"--log-dir",
default=None,
help="Directory for the owlbot.log file",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Enable verbose (DEBUG) logging",
)
parser.add_argument(
"--skip-api-check",
action="store_true",
help="Skip API accessibility checks during startup",
)
parser.add_argument(
"--webhook-path",
action="store_true",
help="Print the webhook path from config and exit",
)
parser.add_argument(
"--version",
action="version",
version=f"Owlbot {__version__}",
)
args = parser.parse_args()
overrides: dict[str, Any] = {}
if args.host is not None:
overrides["host"] = args.host
if args.port is not None:
overrides["port"] = args.port
if args.modules is not None:
overrides["modules_dir"] = args.modules
if args.storage_dir is not None:
overrides["storage_dir"] = args.storage_dir
if args.log_dir is not None:
overrides["log_dir"] = args.log_dir
if args.webhook_path:
try:
config = Config(args.config, overrides=overrides)
except Exception as e:
print(f"Error loading config: {e}", file=sys.stderr)
sys.exit(1)
if config._data.get("owlbot", {}).get("public_base_url"):
base = config.public_base_url
else:
base = f"http://{config.host}:{config.port}"
print(f"{base}{config.webhook_path}")
return
log_level = logging.DEBUG if args.verbose else logging.INFO
if args.verbose:
log_format = (
"%(asctime)s [%(levelname)s] %(name)s "
"(%(filename)s:%(lineno)d): %(message)s"
)
else:
log_format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
logging.basicConfig(
level=log_level,
format=log_format,
datefmt="%Y-%m-%d %H:%M:%S",
)
# Suppress aiohttp's access logs unless we're in verbose mode.
# These logs are very noisy and not useful for normal operation.
if not args.verbose:
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
logger = logging.getLogger("owlbot")
logger.info(f"Log level: {logging.getLevelName(log_level)}")
try:
bot = Owlbot(
config_path=args.config,
overrides=overrides,
skip_api_check=args.skip_api_check,
)
except StartupError as e:
logger.error(str(e))
sys.exit(1)
if bot.config.log_dir is not None:
try:
bot.config.log_dir.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(
bot.config.log_dir / "owlbot.log",
mode="a",
)
file_handler.setLevel(log_level)
file_handler.setFormatter(
logging.Formatter(log_format, datefmt="%Y-%m-%d %H:%M:%S"),
)
logging.getLogger().addHandler(file_handler)
logger.info(f"File logging enabled: {bot.config.log_dir / 'owlbot.log'}")
except OSError as e:
logger.warning(f"Could not set up file logging: {e}")
async def _run() -> None:
loop = asyncio.get_running_loop()
stop_event = asyncio.Event()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop_event.set)
async with bot:
await stop_event.wait()
logger.info("Received shutdown signal.")
try:
asyncio.run(_run())
except StartupError as e:
logger.error(str(e))
sys.exit(1)
except Exception:
logger.exception("Unexpected error during Owlbot execution.")
sys.exit(1)
if __name__ == "__main__":
main()
+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.
"""Package marker for bundled default files."""
+166
View File
@@ -0,0 +1,166 @@
# 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.
"""Public API for Owlbot modules.
This package provides all the APIs modules use to interact with Owlbot:
- **event_types**: Event types and dataclasses (EventType, ChatEvent, User, etc.)
- **context**: Handler contexts (ModuleContext, EventContext,
CommandContext, RouteContext)
- **events**: Event registration (@on_event, Priority)
- **commands**: Command registration (@on_command, CommandEvent)
- **routes**: HTTP route registration (@on_route, RouteInfo)
- **lifecycle**: Lifecycle hooks (@on_setup, @on_teardown)
- **storage**: SQLite storage (ModuleStorage)
- **config**: Configuration (ModuleConfig)
- **owncast_client**: Owncast client (OwncastClient)
- **owncast_admin_client**: Owncast admin client (OwncastAdminClient)
- **ModuleCommands, ModuleEvents, ModuleRoutes**: Module-scoped service wrappers
(re-exported from registries)
"""
# Event types and dataclasses (pure data).
# Module-scoped service wrappers (re-exported from registries).
from ..registries.commands import ModuleCommands
from ..registries.events import ModuleEvents
from ..registries.routes import ModuleRoutes
# Command system (module-facing).
from .commands import (
CommandEvent,
CommandHandler,
CommandInfo,
on_command,
)
# Configuration.
from .config import ModuleConfig
# Context objects (dependency injection).
from .context import (
CommandContext,
EventContext,
ModuleContext,
RouteContext,
)
from .event_types import (
ChatEvent,
Event,
EventType,
NameChangedEvent,
StreamStartedEvent,
StreamStatus,
StreamStoppedEvent,
StreamTitleUpdatedEvent,
User,
UserJoinedEvent,
UserPartedEvent,
VisibilityUpdateEvent,
)
# Event handler registration (module-facing).
from .events import (
EventHandler,
Priority,
on_event,
)
# HTTP client.
from .http_client import HttpClient
# Lifecycle hooks (module-facing).
from .lifecycle import (
LifecycleHandler,
on_setup,
on_teardown,
)
# Owncast admin client.
from .owncast_admin_client import (
ExternalAction,
OwncastAdminClient,
SocialHandle,
StreamKey,
VideoVariant,
)
# Owncast client.
from .owncast_client import OwncastClient, OwncastError
# HTTP routes (module-facing).
from .routes import (
RouteHandler,
RouteInfo,
on_route,
)
# Storage API.
from .storage import ModuleStorage, StorageError
__all__ = [
# Events.
"ChatEvent",
"Event",
"EventContext",
"EventHandler",
"EventType",
"NameChangedEvent",
"StreamStartedEvent",
"StreamStoppedEvent",
"StreamTitleUpdatedEvent",
"UserJoinedEvent",
"UserPartedEvent",
"VisibilityUpdateEvent",
# Commands.
"CommandContext",
"CommandEvent",
"CommandHandler",
"CommandInfo",
"on_command",
# Routes.
"RouteContext",
"RouteHandler",
"RouteInfo",
"on_route",
# Lifecycle.
"LifecycleHandler",
"on_event",
"on_setup",
"on_teardown",
# Context.
"ModuleContext",
# Module-scoped service wrappers.
"ModuleCommands",
"ModuleEvents",
"ModuleRoutes",
# Config.
"ModuleConfig",
# Storage.
"ModuleStorage",
"StorageError",
# HTTP.
"HttpClient",
# Owncast.
"ExternalAction",
"OwncastAdminClient",
"OwncastClient",
"OwncastError",
"Priority",
"SocialHandle",
"StreamKey",
"StreamStatus",
"User",
"VideoVariant",
]
+108
View File
@@ -0,0 +1,108 @@
# 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 system types and decorators for Owlbot.
This module provides the module-facing API for commands:
- @on_command decorator for registering command handlers
- CommandEvent dataclass for parsed command data
- CommandInfo dataclass for command metadata
- CommandHandler type alias
"""
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, TypedDict
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from .context import CommandContext
from .event_types import ChatEvent
class CommandMark(TypedDict):
"""Type for the command marker attribute set by @on_command."""
name: str
aliases: list[str] | tuple[str, ...] | None
requires_authenticated: bool
requires_moderator: bool
cooldown: int | float
@dataclass
class CommandEvent:
"""Parsed command information from a chat message."""
command: str
args: str
args_list: list[str]
prefix: str
chat_event: ChatEvent
type CommandHandler = "Callable[[CommandContext], Awaitable[None]]"
@dataclass
class CommandInfo:
"""Metadata about a registered command."""
name: str
handler: CommandHandler
module_name: str
aliases: frozenset[str] = field(default_factory=frozenset)
requires_authenticated: bool = False
requires_moderator: bool = False
cooldown: int | float = 0
@property
def all_triggers(self) -> frozenset[str]:
"""All names that trigger this command (name + aliases)."""
return frozenset({self.name}) | self.aliases
def on_command(
name: str,
*,
aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False,
requires_moderator: bool = False,
cooldown: int | float = 0,
) -> Callable[[CommandHandler], CommandHandler]:
"""
Decorator to register a command handler.
:param name: Primary command name (case-insensitive).
:param aliases: Optional list of alternative names.
:param requires_authenticated: If True, user must be logged in.
:param requires_moderator: If True, user must have moderator privileges.
:param cooldown: Minimum seconds between invocations, global across all
users (0 to disable).
:return: Decorator that marks the function for registration.
"""
def decorator(func: CommandHandler) -> CommandHandler:
# Mark the function with command info for deferred registration.
# The module loader will scan for this attribute and register commands.
func._owlbot_command = CommandMark( # type: ignore[attr-defined]
name=name,
aliases=aliases,
requires_authenticated=requires_authenticated,
requires_moderator=requires_moderator,
cooldown=cooldown,
)
return func
return decorator
+577
View File
@@ -0,0 +1,577 @@
# 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.
"""YAML-based configuration for Owlbot."""
import logging
import os
import secrets
from pathlib import Path
from typing import Any, TypeVar, overload
import yaml
logger = logging.getLogger("owlbot.config")
_UNSET = object()
_T = TypeVar("_T")
class Config:
"""Configuration manager for Owlbot."""
def __init__(
self,
config_path: str | Path = "config.yaml",
overrides: dict[str, Any] | None = None,
):
"""
Initialize the configuration manager.
:param config_path: Path to the YAML config file.
:param overrides: CLI overrides (keys match property names).
"""
self.config_path = Path(config_path)
self._overrides: dict[str, Any] = overrides or {}
self._data: dict[str, Any] = {}
self._module_defaults: dict[str, dict[str, Any]] = {}
self.load()
@overload
def _resolve(
self,
*,
section: str,
key: str,
default: str,
env: str | None = ...,
cli: Any = ...,
) -> str: ...
@overload
def _resolve(
self,
*,
section: str,
key: str,
default: _T,
type_fn: type[_T],
env: str | None = ...,
cli: Any = ...,
) -> _T: ...
@overload
def _resolve(
self,
*,
section: str,
key: str,
env: str | None = ...,
cli: Any = ...,
) -> str | None: ...
def _resolve(
self,
*,
env: str | None = None,
cli: Any = _UNSET,
section: str,
key: str,
default: Any = _UNSET,
type_fn: type = str,
) -> Any:
"""
Resolve a setting through the priority chain:
CLI arg > env var > config file > default.
When *default* is a ``str``, the resolved value is coerced to ``str``
(the implicit *type_fn*). For non-string types, pass both *default*
and a matching *type_fn* (e.g. ``default=8081, type_fn=int``). When
*default* is omitted the value may be ``None``.
:param env: Environment variable name to check.
:param cli: CLI override value (_UNSET or None means not provided).
:param section: Top-level config section (e.g. "owlbot").
:param key: Key within the section.
:param default: Default value if nothing else is set.
:param type_fn: Callable to coerce the resolved value.
:return: The resolved value.
"""
if cli is not _UNSET and cli is not None:
return type_fn(cli)
if env:
val = os.environ.get(env)
if val is not None:
return type_fn(val)
val = self._data.get(section, {}).get(key)
if val is not None:
return type_fn(val)
if default is not _UNSET:
return default
return None
@property
def webhook_secret(self) -> str:
"""
Secret string for the webhook URL path.
The webhook endpoint is always /webhook/<secret>. A cryptographically
secure value is generated on first run if not explicitly configured.
"""
return self._resolve(
cli=self._overrides.get("webhook_secret"),
env="OWLBOT_WEBHOOK_SECRET",
section="owlbot",
key="webhook_secret",
default="",
)
@property
def webhook_path(self) -> str:
"""
URL path where Owncast sends webhooks.
Always returns /webhook/<secret>. The secret is auto-generated
on first run if not configured.
"""
return f"/webhook/{self.webhook_secret}"
@property
def host(self) -> str:
"""Address to bind the webhook server."""
return self._resolve(
cli=self._overrides.get("host"),
env="OWLBOT_HOST",
section="owlbot",
key="host",
default="127.0.0.1",
)
@property
def port(self) -> int:
"""Port for the webhook server."""
return self._resolve(
cli=self._overrides.get("port"),
env="OWLBOT_PORT",
section="owlbot",
key="port",
default=8081,
type_fn=int,
)
@property
def handler_timeout(self) -> float:
"""Max seconds to wait for handlers to complete."""
return self._resolve(
env="OWLBOT_HANDLER_TIMEOUT",
section="owlbot",
key="handler_timeout",
default=30.0,
type_fn=float,
)
@property
def pool_size(self) -> int:
"""Maximum number of SQLite connections per module storage pool."""
return max(
1,
self._resolve(
env="OWLBOT_POOL_SIZE",
section="owlbot",
key="pool_size",
default=4,
type_fn=int,
),
)
@property
def command_prefix(self) -> str:
"""Prefix character for chat commands."""
return self._resolve(
env="OWLBOT_COMMAND_PREFIX",
section="owlbot",
key="command_prefix",
default="!",
)
@property
def public_base_url(self) -> str:
"""
Public base URL for Owlbot's web server.
Used to construct URLs for module routes and the webhook endpoint.
Falls back to ``owncast.url`` if not explicitly set.
"""
configured = self._resolve(
env="OWLBOT_PUBLIC_BASE_URL",
section="owlbot",
key="public_base_url",
)
if configured:
return configured.rstrip("/")
return self.owncast_url.rstrip("/")
@property
def storage_dir(self) -> Path:
"""
Directory for module database files.
Each module gets its own database file named '<module_name>.db'.
Defaults to 'data/' in the working directory.
"""
configured = self._resolve(
cli=self._overrides.get("storage_dir"),
env="OWLBOT_STORAGE_DIR",
section="owlbot",
key="storage_dir",
)
if configured:
return Path(configured)
return Path("data")
@property
def modules_dir(self) -> Path:
"""
Directory containing user modules.
Built-in modules are loaded from the package regardless of this setting.
Defaults to 'modules/' in the working directory.
"""
configured = self._resolve(
cli=self._overrides.get("modules_dir"),
env="OWLBOT_MODULES_DIR",
section="owlbot",
key="modules_dir",
)
if configured:
return Path(configured)
return Path("modules")
@property
def log_dir(self) -> Path | None:
"""
Directory for the log file.
When set, an ``owlbot.log`` file is written to this directory in
addition to stdout. Returns ``None`` when unset (stdout only).
"""
configured = self._resolve(
cli=self._overrides.get("log_dir"),
env="OWLBOT_LOG_DIR",
section="owlbot",
key="log_dir",
)
if configured:
return Path(configured)
return None
@property
def owncast_url(self) -> str:
"""Base URL of the Owncast server."""
return self._resolve(
env="OWLBOT_OWNCAST_URL",
section="owncast",
key="url",
default="",
)
@property
def owncast_access_token(self) -> str:
"""API access token for Owncast."""
return self._resolve(
env="OWLBOT_OWNCAST_ACCESS_TOKEN",
section="owncast",
key="access_token",
default="",
)
@property
def admin_enabled(self) -> bool:
"""Whether the Owncast admin client is enabled."""
env_val = os.environ.get("OWLBOT_OWNCAST_ADMIN_ENABLED")
if env_val is not None:
return env_val.lower() in ("1", "true", "yes")
return bool(
self._data.get("owncast", {}).get("admin", {}).get("enabled", False)
)
@property
def admin_username(self) -> str:
"""Username for the Owncast admin API."""
env_val = os.environ.get("OWLBOT_OWNCAST_ADMIN_USERNAME")
if env_val is not None:
return env_val
return str(
self._data.get("owncast", {}).get("admin", {}).get("username", "admin")
)
@property
def admin_password(self) -> str:
"""Password for the Owncast admin API."""
env_val = os.environ.get("OWLBOT_OWNCAST_ADMIN_PASSWORD")
if env_val is not None:
return env_val
return str(
self._data.get("owncast", {}).get("admin", {}).get("password", "abc123")
)
def load(self) -> None:
"""Load config from YAML, applying defaults for missing sections."""
logger.debug(f"Loading configuration from: {self.config_path.absolute()}")
if self.config_path.exists():
try:
with open(self.config_path) as f:
self._data = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
logger.error(f"Failed to parse config file: {e}")
raise
except OSError as e:
logger.error(f"Failed to read config file: {e}")
raise
logger.info(f"Configuration loaded from: {self.config_path.absolute()}")
else:
raise FileNotFoundError(f"Config file not found: {self.config_path}")
if not isinstance(self._data.get("owlbot"), dict):
self._data["owlbot"] = {}
if not isinstance(self._data.get("owncast"), dict):
self._data["owncast"] = {}
if not isinstance(self._data.get("modules"), dict):
self._data["modules"] = {}
if not self.owncast_url:
raise ValueError(
"owncast.url is required. "
"Set it in config.yaml, or via the OWLBOT_OWNCAST_URL "
"environment variable "
"(e.g. https://stream.logal.dev)."
)
# Generate a cryptographically secure secret if one isn't configured
# (and not provided via env var or CLI override).
if not self.webhook_secret:
generated = secrets.token_urlsafe(32)
self._data["owlbot"]["webhook_secret"] = generated
self.save()
logger.info("Generated webhook secret and saved to config file.")
logger.debug("Webhook Secret: [set]")
logger.debug(f"Server Bind Address: {self.host}:{self.port}")
logger.debug(f"Owncast Server URL: {self.owncast_url}")
logger.debug(
"Owncast Access Token: "
f"{'[set]' if self.owncast_access_token else '[unset]'}"
)
logger.debug(f"Command Prefix: {self.command_prefix!r}")
logger.debug(f"Handler Timeout: {self.handler_timeout}s")
logger.debug(f"Storage Directory: {self.storage_dir}")
logger.debug(f"Modules Directory: {self.modules_dir}")
logger.debug(f"Log Directory: {self.log_dir or '[unset]'}")
logger.debug(f"Public Base URL: {self.public_base_url}")
logger.debug(
f"Admin API Enabled: {'[set]' if self.admin_enabled else '[unset]'}"
)
if self.admin_enabled:
logger.debug(f"Admin API Username: {self.admin_username}")
logger.debug(
f"Admin API Password: {'[set]' if self.admin_password else '[unset]'}"
)
def save(self) -> None:
"""Write current configuration to the YAML file."""
logger.debug(f"Saving configuration to: {self.config_path.absolute()}")
try:
with open(self.config_path, "w") as f:
yaml.safe_dump(
self._ordered_data(), f, default_flow_style=False, sort_keys=False
)
except yaml.YAMLError as e:
logger.error(f"Failed to serialize config data: {e}")
raise
except OSError as e:
logger.error(f"Failed to write config file: {e}")
raise
logger.info(f"Configuration saved to: {self.config_path.absolute()}")
def is_module_enabled(self, module_name: str) -> bool:
"""
Check if a module is enabled.
Modules are enabled by default unless explicitly disabled with
``modules.<name>.enabled: false`` in the config file.
:param module_name: The module name (filename without .py extension).
:return: True if enabled, False if disabled.
"""
module_config = self._data.get("modules", {}).get(module_name, {})
if isinstance(module_config, dict):
return bool(module_config.get("enabled", True))
# Module might be configured as a scalar or missing entirely.
# Default to enabled when the value is not a mapping.
return True
def get_module_config(self, module_name: str) -> dict[str, Any]:
"""
Get the configuration dict for a module.
Returns merged defaults and config file values, with config file
values taking precedence.
:param module_name: The module name.
:return: Dict of configuration values for the module.
"""
defaults = self._module_defaults.get(module_name, {})
config = self._data.get("modules", {}).get(module_name, {})
if not isinstance(config, dict):
config = {}
# Config file values take precedence over module defaults.
return {**defaults, **config}
def set_module_config(self, module_name: str, config: dict[str, Any]) -> None:
"""
Update the configuration for a module at runtime and persist to disk.
:param module_name: The module name.
:param config: Dict of configuration values to set.
"""
if not isinstance(self._data.get("modules"), dict):
self._data["modules"] = {}
self._data["modules"][module_name] = config
self.save()
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.config")
module_logger.debug("Updated config.")
def register_module_defaults(
self, module_name: str, defaults: dict[str, Any]
) -> None:
"""
Register default configuration values for a module.
Called by modules during setup to declare their expected config keys
and default values. Missing keys are backfilled into the config file
so the YAML always reflects all available options.
:param module_name: The module name.
:param defaults: Dict of default configuration values.
"""
self._module_defaults[module_name] = defaults
if not isinstance(self._data.get("modules"), dict):
self._data["modules"] = {}
existing = self._data["modules"].get(module_name, {})
if not isinstance(existing, dict):
existing = {}
changed = False
merged = {**defaults, **existing}
if module_name not in self._data["modules"]:
# New module section. Include enabled: True alongside all defaults.
merged.setdefault("enabled", True)
changed = True
elif len(merged) > len(existing):
# Existing section, but some default keys were missing.
changed = True
self._data["modules"][module_name] = merged
if changed:
self.save()
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.config")
module_logger.debug("Registered defaults.")
def _ordered_data(self) -> dict[str, Any]:
"""Return config data with sections in a stable order."""
ordered: dict[str, Any] = {}
for key in ("owlbot", "owncast", "modules"):
if key in self._data:
value = self._data[key]
if key == "modules" and isinstance(value, dict):
value = dict(sorted(value.items()))
ordered[key] = value
for key in sorted(self._data.keys() - ordered.keys()):
ordered[key] = self._data[key]
return ordered
class ModuleConfig:
"""Pre-scoped configuration for a specific module."""
def __init__(self, config: Config, module_name: str):
"""
Initialize a module-scoped configuration.
:param config: The parent Config object.
:param module_name: The name of the module this config
is scoped to (internal use).
"""
self._config = config
self._module_name = module_name
@property
def enabled(self) -> bool:
"""Check if this module is enabled in config."""
return self._config.is_module_enabled(self._module_name)
@property
def public_base_url(self) -> str:
"""Public base URL for Owlbot's web server."""
return self._config.public_base_url
def get(self, key: str, default: Any = None) -> Any:
"""
Get a config value by key.
:param key: The configuration key.
:param default: Value to return if key is not found.
:return: The config value, or default if not found.
"""
return self.as_dict().get(key, default)
def as_dict(self) -> dict[str, Any]:
"""
Get the full config dict for this module.
:return: Dict of all configuration values.
"""
return self._config.get_module_config(self._module_name)
def set(self, key: str, value: Any) -> None:
"""
Set a config value at runtime and persist to disk.
:param key: The configuration key.
:param value: The value to set.
"""
current = self._config.get_module_config(self._module_name)
current[key] = value
self._config.set_module_config(self._module_name, current)
def register_defaults(self, defaults: dict[str, Any]) -> None:
"""
Register default values for this module's config.
Called during setup() to declare expected config keys and their
default values.
:param defaults: Dict of default configuration values.
"""
self._config.register_module_defaults(self._module_name, defaults)
+355
View File
@@ -0,0 +1,355 @@
# 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.
"""Context objects for Owlbot module handlers.
This module provides the context objects that handlers receive:
- ModuleContext: Shared services available to all handlers
- EventContext: For event handlers
- CommandContext: For command handlers
- RouteContext: For HTTP route handlers
"""
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from aiohttp import web
from ..registries.commands import ModuleCommands
from ..registries.events import ModuleEvents
from ..registries.routes import ModuleRoutes
from .commands import CommandEvent
from .config import ModuleConfig
from .event_types import ChatEvent, User
from .http_client import HttpClient
from .owncast_admin_client import OwncastAdminClient
from .owncast_client import OwncastClient
from .storage import ModuleStorage
@dataclass
class ModuleContext:
"""
Shared services available to all module handlers.
Created once per module during loading and reused for all handler
invocations. This bundles the common dependencies that every handler needs.
"""
# Module name (identity).
module_name: str
# Module-scoped configuration.
config: ModuleConfig
# Client for interacting with the Owncast server.
owncast_client: OwncastClient
# SQLite storage API for persisting data.
storage: ModuleStorage
# Module-scoped command API for dynamic command registration/lookup.
commands: ModuleCommands
# Module-scoped event handler API for dynamic handler registration/lookup.
events: ModuleEvents
# Module-scoped route API for URL building and route introspection.
routes: ModuleRoutes
# Shared HTTP client for making web requests.
http: HttpClient
# Optional admin client for the Owncast Admin API (None if admin is not enabled).
admin_client: OwncastAdminClient | None = None
# Module-scoped logger (named "owlbot.modules.<module_name>").
# Derived from module_name in __post_init__; not a constructor parameter.
logger: logging.Logger = field(init=False)
def __post_init__(self) -> None:
self.logger = logging.getLogger(f"owlbot.modules.{self.module_name}")
@dataclass
class PropagationState:
"""Mutable state for controlling event propagation across handlers."""
stopped: bool = False
reason: str = ""
@dataclass
class EventContext[E]:
"""
Context passed to event handlers.
Each handler invocation receives its own EventContext instance with the event
data and access to shared services via the module context.
"""
# The event that triggered this handler (ChatEvent, UserJoinedEvent, etc.).
event: E
# Shared services for this module.
module: ModuleContext
# Shared state for propagation control.
# All handlers for a single event dispatch share the same instance.
_propagation: PropagationState = field(default_factory=PropagationState, repr=False)
@property
def module_name(self) -> str:
"""Module name."""
return self.module.module_name
@property
def config(self) -> ModuleConfig:
"""Module-scoped configuration."""
return self.module.config
@property
def owncast_client(self) -> OwncastClient:
"""Client for interacting with the Owncast server."""
return self.module.owncast_client
@property
def storage(self) -> ModuleStorage:
"""SQLite storage API for persisting data."""
return self.module.storage
@property
def commands(self) -> ModuleCommands:
"""Command registry for dynamic command registration/lookup."""
return self.module.commands
@property
def events(self) -> ModuleEvents:
"""Module-scoped event handler API for dynamic handler registration/lookup."""
return self.module.events
@property
def routes(self) -> ModuleRoutes:
"""Module-scoped route API for URL building and route introspection."""
return self.module.routes
@property
def logger(self) -> logging.Logger:
"""Module-scoped logger."""
return self.module.logger
@property
def http(self) -> HttpClient:
"""Shared HTTP client for making web requests."""
return self.module.http
@property
def admin_client(self) -> OwncastAdminClient | None:
"""Optional client for the Owncast Admin API (None if admin is not enabled)."""
return self.module.admin_client
@property
def propagation_stopped(self) -> bool:
"""
Check if event propagation has been stopped by a handler.
:return: True if stop_propagation() was called by any handler.
"""
return self._propagation.stopped
def stop_propagation(self, reason: str | None = None) -> None:
"""
Stop event from being dispatched to remaining handlers and commands.
Once called, no further handlers will be invoked for this event, and
command dispatch (for CHAT events) will be skipped.
:param reason: Optional reason for stopping (logged for debugging).
"""
self._propagation.stopped = True
# Only store the first reason provided (subsequent calls don't override).
if reason and not self._propagation.reason:
self._propagation.reason = reason
@dataclass
class CommandContext:
"""
Context passed to command handlers.
Provides access to the parsed command data, original chat event context,
and shared services. Like EventContext and RouteContext, all ModuleContext
fields are proxied for convenience.
"""
# The parsed command information.
command_event: CommandEvent
# The event context for the original ChatEvent (provides propagation control).
event_context: EventContext[ChatEvent]
# Shared services for this module.
module: ModuleContext
@property
def command(self) -> str:
"""The command name that was invoked (canonical name, not alias)."""
return self.command_event.command
@property
def args(self) -> str:
"""The raw argument string after the command."""
return self.command_event.args
@property
def args_list(self) -> list[str]:
"""Arguments split into a list."""
return self.command_event.args_list
@property
def prefix(self) -> str:
"""The command prefix (e.g., '!')."""
return self.command_event.prefix
@property
def chat_event(self) -> ChatEvent:
"""The original chat event that triggered this command."""
return self.command_event.chat_event
@property
def user(self) -> User:
"""The user who invoked the command (shortcut to chat_event.user)."""
return self.command_event.chat_event.user
@property
def module_name(self) -> str:
"""Module name."""
return self.module.module_name
@property
def config(self) -> ModuleConfig:
"""Module-scoped configuration."""
return self.module.config
@property
def owncast_client(self) -> OwncastClient:
"""Client for interacting with the Owncast server."""
return self.module.owncast_client
@property
def storage(self) -> ModuleStorage:
"""SQLite storage API for persisting data."""
return self.module.storage
@property
def commands(self) -> ModuleCommands:
"""Command registry for dynamic command registration/lookup."""
return self.module.commands
@property
def events(self) -> ModuleEvents:
"""Module-scoped event handler API for dynamic handler registration/lookup."""
return self.module.events
@property
def routes(self) -> ModuleRoutes:
"""Module-scoped route API for URL building and route introspection."""
return self.module.routes
@property
def logger(self) -> logging.Logger:
"""Module-scoped logger."""
return self.module.logger
@property
def http(self) -> HttpClient:
"""Shared HTTP client for making web requests."""
return self.module.http
@property
def admin_client(self) -> OwncastAdminClient | None:
"""Optional client for the Owncast Admin API (None if admin is not enabled)."""
return self.module.admin_client
@dataclass
class RouteContext:
"""
Context passed to HTTP route handlers.
Similar to EventContext but includes the aiohttp request object
for accessing HTTP-specific data (body, headers, query params).
"""
# The aiohttp request object.
request: web.Request
# Shared services for this module.
module: ModuleContext
# Captured path parameters from pattern matching (e.g., {"id": "123"}).
# Empty dict for plain routes, populated for routes with {name} patterns.
match_info: dict[str, str] = field(default_factory=dict)
@property
def module_name(self) -> str:
"""Module name."""
return self.module.module_name
@property
def config(self) -> ModuleConfig:
"""Module-scoped configuration."""
return self.module.config
@property
def owncast_client(self) -> OwncastClient:
"""Client for interacting with the Owncast server."""
return self.module.owncast_client
@property
def storage(self) -> ModuleStorage:
"""SQLite storage API for persisting data."""
return self.module.storage
@property
def commands(self) -> ModuleCommands:
"""Command registry for dynamic command registration/lookup."""
return self.module.commands
@property
def events(self) -> ModuleEvents:
"""Module-scoped event handler API for dynamic handler registration/lookup."""
return self.module.events
@property
def routes(self) -> ModuleRoutes:
"""Module-scoped route API for URL building and route introspection."""
return self.module.routes
@property
def logger(self) -> logging.Logger:
"""Module-scoped logger."""
return self.module.logger
@property
def http(self) -> HttpClient:
"""Shared HTTP client for making web requests."""
return self.module.http
@property
def admin_client(self) -> OwncastAdminClient | None:
"""Optional client for the Owncast Admin API (None if admin is not enabled)."""
return self.module.admin_client
+463
View File
@@ -0,0 +1,463 @@
# 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.
"""Event dataclasses for Owncast webhook payloads."""
import logging
import re
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import Any
logger = logging.getLogger("owlbot.events")
class EventType(StrEnum):
"""Supported event types for Owncast webhooks."""
CHAT = "CHAT"
USER_JOINED = "USER_JOINED"
USER_PARTED = "USER_PARTED"
NAME_CHANGE = "NAME_CHANGE"
STREAM_STARTED = "STREAM_STARTED"
STREAM_STOPPED = "STREAM_STOPPED"
STREAM_TITLE_UPDATED = "STREAM_TITLE_UPDATED"
# Owncast sends this type with a dash rather than an underscore.
# The enum member uses an underscore so modules see a consistent naming
# style; the string value preserves the original for webhook matching.
VISIBILITY_UPDATE = "VISIBILITY-UPDATE"
def _parse_timestamp(ts: str | None) -> datetime | None:
"""
Parse an ISO 8601 timestamp string into a datetime.
Handles timezone suffixes and the Go zero-value timestamp that Owncast
sends for missing/unset timestamps.
:param ts: The timestamp string to parse.
:return: Parsed datetime, or None if the timestamp is empty or invalid.
"""
# Owncast (written in Go) sends "0001-01-01T00:00:00Z" for unset time.Time values.
if not ts or ts == "0001-01-01T00:00:00Z":
return None
# Replace "Z" (UTC indicator) with the equivalent offset that
# fromisoformat understands. Timezone offsets like
# "+05:30" are kept as-is so the returned datetime is always aware.
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
# Go's time.Time emits nanosecond-precision fractional seconds (9 digits),
# but Python's datetime only supports microseconds (6 digits). Truncate
# any excess fractional digits to avoid a ValueError from fromisoformat().
ts = re.sub(r"(\.\d{6})\d+", r"\1", ts)
try:
return datetime.fromisoformat(ts)
except ValueError:
# Malformed timestamp. Return None rather than crashing.
logger.warning(f"Owncast API returned timestamp in unexpected format: {ts!r}")
return None
@dataclass
class User:
"""Represents a chat user's identity and metadata."""
id: str
display_name: str
display_color: int
created_at: datetime | None
previous_names: list[str]
name_changed_at: datetime | None
is_bot: bool
is_authenticated: bool
scopes: list[str]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> User:
"""
Create a User from webhook JSON data.
:param data: The user data from the webhook payload.
:return: A populated User instance.
"""
return cls(
id=data.get("id", ""),
display_name=data.get("displayName", ""),
display_color=data.get("displayColor", 0),
created_at=_parse_timestamp(data.get("createdAt")),
previous_names=data.get("previousNames", []),
name_changed_at=_parse_timestamp(data.get("nameChangedAt")),
is_bot=data.get("isBot", False),
is_authenticated=data.get("authenticated", False),
scopes=data.get("scopes", []),
)
@property
def is_moderator(self) -> bool:
"""
Check if the user has moderator privileges.
:return: True if the user has the MODERATOR scope.
"""
return "MODERATOR" in self.scopes
@dataclass
class ChatEvent:
"""Represents a chat message sent by a user."""
user: User
client_id: int
body: str
raw_body: str
message_id: str
is_visible: bool
timestamp: datetime | None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ChatEvent:
"""
Create a ChatEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated ChatEvent instance.
"""
# Owncast wraps the message body in paragraph tags for HTML rendering.
# We strip these off so handlers get clean text without HTML cruft.
body = data.get("body", "").strip()
if body.startswith("<p>") and body.endswith("</p>"):
body = body[3:-4]
return cls(
user=User.from_dict(data.get("user", {})),
client_id=data.get("clientId", 0),
body=body,
raw_body=data.get("rawBody", data.get("body", "")),
message_id=data.get("id", ""),
is_visible=data.get("visible", True),
timestamp=_parse_timestamp(data.get("timestamp")),
)
@dataclass
class UserJoinedEvent:
"""Fired when a user joins the chat."""
user: User
client_id: int
event_id: str
timestamp: datetime | None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> UserJoinedEvent:
"""
Create a UserJoinedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated UserJoinedEvent instance.
"""
return cls(
user=User.from_dict(data.get("user", {})),
client_id=data.get("clientId", 0),
event_id=data.get("id", ""),
timestamp=_parse_timestamp(data.get("timestamp")),
)
@dataclass
class UserPartedEvent:
"""Fired when a user disconnects from chat."""
user: User
client_id: int
event_id: str
timestamp: datetime | None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> UserPartedEvent:
"""
Create a UserPartedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated UserPartedEvent instance.
"""
return cls(
user=User.from_dict(data.get("user", {})),
client_id=data.get("clientId", 0),
event_id=data.get("id", ""),
timestamp=_parse_timestamp(data.get("timestamp")),
)
@dataclass
class NameChangedEvent:
"""Fired when a user changes their display name."""
user: User
client_id: int
new_name: str
event_id: str
timestamp: datetime | None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> NameChangedEvent:
"""
Create a NameChangedEvent from webhook JSON data.
Note: The user object contains the OLD display name in user.display_name.
:param data: The event data from the webhook payload.
:return: A populated NameChangedEvent instance.
"""
return cls(
user=User.from_dict(data.get("user", {})),
client_id=data.get("clientId", 0),
new_name=data.get("newName", ""),
event_id=data.get("id", ""),
timestamp=_parse_timestamp(data.get("timestamp")),
)
@dataclass
class StreamStartedEvent:
"""Fired when the stream goes live."""
server_id: str
server_name: str
stream_title: str
summary: str
timestamp: datetime | None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStartedEvent:
"""
Create a StreamStartedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated StreamStartedEvent instance.
"""
return cls(
server_id=data.get("id", ""),
server_name=data.get("name", ""),
stream_title=data.get("streamTitle", ""),
summary=data.get("summary", ""),
timestamp=_parse_timestamp(data.get("timestamp")),
)
@dataclass
class StreamStoppedEvent:
"""Fired when the stream goes offline."""
server_id: str
server_name: str
stream_title: str
summary: str
timestamp: datetime | None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStoppedEvent:
"""
Create a StreamStoppedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated StreamStoppedEvent instance.
"""
return cls(
server_id=data.get("id", ""),
server_name=data.get("name", ""),
stream_title=data.get("streamTitle", ""),
summary=data.get("summary", ""),
timestamp=_parse_timestamp(data.get("timestamp")),
)
@dataclass
class StreamStatus:
"""Current stream status snapshot included in some events."""
last_connect_time: datetime | None
last_disconnect_time: datetime | None
version_number: str
stream_title: str
viewer_count: int
overall_max_viewer_count: int
session_max_viewer_count: int
is_online: bool
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStatus:
"""
Create a StreamStatus from webhook JSON data.
:param data: The status data from the webhook payload.
:return: A populated StreamStatus instance.
"""
return cls(
last_connect_time=_parse_timestamp(data.get("lastConnectTime")),
last_disconnect_time=_parse_timestamp(data.get("lastDisconnectTime")),
version_number=data.get("versionNumber", ""),
stream_title=data.get("streamTitle", ""),
viewer_count=data.get("viewerCount", 0),
overall_max_viewer_count=data.get("overallMaxViewerCount", 0),
session_max_viewer_count=data.get("sessionMaxViewerCount", 0),
is_online=data.get("online", False),
)
@dataclass
class StreamTitleUpdatedEvent:
"""Fired when the stream title is changed."""
server_id: str
server_name: str
stream_title: str
summary: str
status: StreamStatus | None
timestamp: datetime | None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamTitleUpdatedEvent:
"""
Create a StreamTitleUpdatedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated StreamTitleUpdatedEvent instance.
"""
status_data = data.get("status")
return cls(
server_id=data.get("id", ""),
server_name=data.get("name", ""),
stream_title=data.get("streamTitle", ""),
summary=data.get("summary", ""),
status=StreamStatus.from_dict(status_data) if status_data else None,
timestamp=_parse_timestamp(data.get("timestamp")),
)
@dataclass
class VisibilityUpdateEvent:
"""Fired when message visibility changes (moderation action)."""
event_id: str
message_ids: list[str]
is_visible: bool
timestamp: datetime | None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> VisibilityUpdateEvent:
"""
Create a VisibilityUpdateEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated VisibilityUpdateEvent instance.
"""
return cls(
event_id=data.get("id", ""),
message_ids=data.get("ids", []),
is_visible=data.get("visible", False),
timestamp=_parse_timestamp(data.get("timestamp")),
)
# Union type for type hints accepting any event.
type Event = (
ChatEvent
| UserJoinedEvent
| UserPartedEvent
| NameChangedEvent
| StreamStartedEvent
| StreamStoppedEvent
| StreamTitleUpdatedEvent
| VisibilityUpdateEvent
)
# Tuple of (event_type, event_instance) returned by parse_event().
type _ParsedEvent = tuple[EventType, Event]
# Maps EventType enum values to their corresponding event classes.
_EVENT_TYPE_MAP: dict[EventType, type[Event]] = {
EventType.CHAT: ChatEvent,
EventType.USER_JOINED: UserJoinedEvent,
EventType.USER_PARTED: UserPartedEvent,
EventType.NAME_CHANGE: NameChangedEvent,
EventType.STREAM_STARTED: StreamStartedEvent,
EventType.STREAM_STOPPED: StreamStoppedEvent,
EventType.STREAM_TITLE_UPDATED: StreamTitleUpdatedEvent,
EventType.VISIBILITY_UPDATE: VisibilityUpdateEvent,
}
def parse_event(data: dict[str, Any]) -> _ParsedEvent | None:
"""
Parse a raw webhook payload into a typed event.
:param data: The JSON payload from Owncast's webhook.
:return: Tuple of (event_type, event_instance), or None if the event type
is not recognized.
"""
event_type_str: str | None = data.get("type")
event_data = data.get("eventData", {})
if event_type_str is None:
return None
try:
event_type = EventType(event_type_str)
except ValueError:
# Unknown event type. This could be a new Owncast feature we don't support yet.
return None
event_class = _EVENT_TYPE_MAP[event_type]
return event_type, event_class.from_dict(event_data)
def log_event(event_type: EventType, event: Event) -> None:
"""
Log event details for debugging and monitoring.
:param event_type: The type of event being logged.
:param event: The parsed event instance.
"""
if isinstance(event, ChatEvent):
logger.info(
f"[{event_type}] {event.user.display_name} "
f"({event.message_id}): {event.body}"
)
elif isinstance(event, UserJoinedEvent):
logger.info(f"[{event_type}] {event.user.display_name} joined.")
elif isinstance(event, UserPartedEvent):
logger.info(f"[{event_type}] {event.user.display_name} parted.")
elif isinstance(event, NameChangedEvent):
logger.info(
f"[{event_type}] {event.user.display_name} "
f"changed name to {event.new_name}."
)
elif isinstance(event, StreamStartedEvent):
logger.info(f'[{event_type}] Stream started: "{event.stream_title}"')
elif isinstance(event, StreamStoppedEvent):
logger.info(f"[{event_type}] Stream ended.")
elif isinstance(event, StreamTitleUpdatedEvent):
logger.info(f'[{event_type}] Title changed to "{event.stream_title}"')
elif isinstance(event, VisibilityUpdateEvent):
action = "shown" if event.is_visible else "hidden"
ids = ", ".join(event.message_ids)
logger.info(
f"[{event_type}] {len(event.message_ids)} message(s) {action}: {ids}"
)
+83
View File
@@ -0,0 +1,83 @@
# 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.
"""Event handler registration decorators and types.
This module provides the module-facing API for event handlers:
- @on_event decorator for registering handlers
- Priority class for handler ordering
- EventHandler type alias
"""
from enum import IntEnum
from typing import TYPE_CHECKING, Any, TypedDict
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from .context import EventContext
from .event_types import EventType
class EventMark(TypedDict):
"""Type for the event marker attribute set by @on_event."""
event_types: tuple[EventType, ...]
priority: int
type EventHandler = "Callable[[EventContext[Any]], Awaitable[None]]"
class Priority(IntEnum):
"""
Standard priority levels for event handlers.
Higher values run first. Handlers at the same priority level run in
registration order. Custom numeric values can be used for fine-grained control.
"""
HIGHEST = 100 # Filters, rate limiting, authentication.
HIGH = 75 # Moderation, logging.
NORMAL = 50 # Default for most handlers.
LOW = 25 # Reactions, notifications.
LOWEST = 0 # Stats collection, cleanup.
def on_event(
*event_types: EventType,
priority: int = Priority.NORMAL,
) -> Callable[[EventHandler], EventHandler]:
"""
Decorator to register a function as a handler for one or more event types.
The decorated function will be called whenever an event of the specified
type(s) is received. Handlers are executed sequentially in priority order
(highest priority first). Handlers at the same priority run in registration order.
:param event_types: One or more EventType enum values.
:param priority: Handler priority (higher runs first).
Default: Priority.NORMAL (50).
:return: Decorator that marks the function for registration by the module loader.
"""
def decorator(func: EventHandler) -> EventHandler:
# Mark the function with event info for deferred registration.
# The module loader will scan for this attribute and register handlers.
func._owlbot_event = EventMark( # type: ignore[attr-defined]
event_types=event_types, priority=priority
)
return func
return decorator
+136
View File
@@ -0,0 +1,136 @@
# 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 HTTP client for Owlbot.
Provides a managed aiohttp ClientSession with convenience methods for
making HTTP requests. Used internally by OwncastClient and available
to modules via ``ctx.http``.
"""
import logging
from typing import Any
import aiohttp
logger = logging.getLogger("owlbot.http")
class HttpClient:
"""Shared HTTP client for making web requests.
Owns the underlying :class:`aiohttp.ClientSession` and exposes
convenience methods that return aiohttp context managers::
async with ctx.http.get("https://api.example.com/data") as resp:
data = await resp.json()
The lifecycle is managed by :class:`~owlbot.bot.Owlbot`:
:meth:`_start` creates the session and :meth:`_close` tears it down.
"""
def __init__(self) -> None:
"""Initialize the HTTP client (session is not created until :meth:`_start`)."""
self._session: aiohttp.ClientSession | None = None
@property
def session(self) -> aiohttp.ClientSession:
"""The underlying aiohttp ClientSession.
:raises RuntimeError: If the client has not been started yet.
"""
if self._session is None:
raise RuntimeError("HttpClient has not been started")
return self._session
async def _start(self) -> None:
"""Create the underlying aiohttp session.
The User-Agent header is derived from the package version at
call time so the import is deferred until the bot is actually
starting up.
"""
from .. import __version__
connector = aiohttp.TCPConnector(keepalive_timeout=120)
timeout = aiohttp.ClientTimeout(connect=10, sock_connect=10, sock_read=10)
self._session = aiohttp.ClientSession(
connector=connector,
timeout=timeout,
headers={
"User-Agent": (
f"Owlbot/{__version__} "
"(bot; +https://git.logal.dev/"
"LogalDeveloper/Owlbot)"
),
},
)
logger.debug("HTTP client started.")
async def _close(self) -> None:
"""Close the underlying session. Safe to call multiple times."""
if self._session and not self._session.closed:
await self._session.close()
logger.debug("HTTP client closed.")
def get(self, url: str, **kwargs: Any) -> aiohttp.client._RequestContextManager:
"""Send a GET request.
:param url: The URL to request.
:param kwargs: Additional arguments passed to :meth:`aiohttp.ClientSession.get`.
:return: An async context manager yielding a :class:`aiohttp.ClientResponse`.
"""
return self.session.get(url, **kwargs)
def post(self, url: str, **kwargs: Any) -> aiohttp.client._RequestContextManager:
"""Send a POST request.
:param url: The URL to request.
:param kwargs: Additional arguments passed to
:meth:`aiohttp.ClientSession.post`.
:return: An async context manager yielding a :class:`aiohttp.ClientResponse`.
"""
return self.session.post(url, **kwargs)
def put(self, url: str, **kwargs: Any) -> aiohttp.client._RequestContextManager:
"""Send a PUT request.
:param url: The URL to request.
:param kwargs: Additional arguments passed to :meth:`aiohttp.ClientSession.put`.
:return: An async context manager yielding a :class:`aiohttp.ClientResponse`.
"""
return self.session.put(url, **kwargs)
def delete(self, url: str, **kwargs: Any) -> aiohttp.client._RequestContextManager:
"""Send a DELETE request.
:param url: The URL to request.
:param kwargs: Additional arguments passed to
:meth:`aiohttp.ClientSession.delete`.
:return: An async context manager yielding a :class:`aiohttp.ClientResponse`.
"""
return self.session.delete(url, **kwargs)
def request(
self, method: str, url: str, **kwargs: Any
) -> aiohttp.client._RequestContextManager:
"""Send a request with an arbitrary HTTP method.
:param method: The HTTP method (e.g., ``"PATCH"``).
:param url: The URL to request.
:param kwargs: Additional arguments passed to
:meth:`aiohttp.ClientSession.request`.
:return: An async context manager yielding a :class:`aiohttp.ClientResponse`.
"""
return self.session.request(method, url, **kwargs)
+71
View File
@@ -0,0 +1,71 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# 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.
"""Lifecycle hook decorators for module setup and teardown.
This module provides the module-facing API for lifecycle hooks:
- @on_setup decorator for marking setup functions
- @on_teardown decorator for marking teardown functions
- LifecycleHandler type alias
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from .context import ModuleContext
type LifecycleHandler = "Callable[[ModuleContext], Awaitable[None]]"
def on_setup(func: LifecycleHandler) -> LifecycleHandler:
"""
Decorator to mark a function as a module setup hook.
The decorated function will be called during module loading with a
``ModuleContext``. Setup hooks run inside a storage transaction that
is committed on success and rolled back on failure.
Applied without parentheses::
@on_setup
async def setup(ctx: ModuleContext) -> None:
await ctx.storage.execute("CREATE TABLE IF NOT EXISTS ...")
:param func: The setup function to mark.
:return: The same function, with ``_owlbot_setup`` attribute set.
"""
func._owlbot_setup = True # type: ignore[attr-defined]
return func
def on_teardown(func: LifecycleHandler) -> LifecycleHandler:
"""
Decorator to mark a function as a module teardown hook.
The decorated function will be called during module unload or bot
shutdown with the same ``ModuleContext`` from setup.
Applied without parentheses::
@on_teardown
async def teardown(ctx: ModuleContext) -> None:
ctx.logger.info("Cleaning up...")
:param func: The teardown function to mark.
:return: The same function, with ``_owlbot_teardown`` attribute set.
"""
func._owlbot_teardown = True # type: ignore[attr-defined]
return func
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
# 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.
"""Async HTTP client for the Owncast Integration API."""
import json
import logging
from typing import TYPE_CHECKING, Any
import aiohttp
if TYPE_CHECKING:
from .http_client import HttpClient
def _extract_error(text: str) -> str:
"""Extract a human-readable error from a response body.
Owncast may return ``{"error": "..."}`` or ``{"success": false, "message": "..."}``,
or plain text. This helper unwraps the JSON envelope when present.
:param text: Raw response body.
:return: The extracted error string.
"""
try:
data = json.loads(text)
except ValueError, TypeError:
return text
if isinstance(data, dict):
if "error" in data:
return str(data["error"])
if "message" in data:
return str(data["message"])
return text
class OwncastError(Exception):
"""Raised when an Owncast API request fails."""
def __init__(self, status: int, message: str):
"""
Initialize the error.
:param status: HTTP status code from the failed request, or 0 if the
request failed due to a connection error before receiving a response.
:param message: Error message or response body from the server.
"""
self.status = status
self.message = message
super().__init__(f"Owncast error {status}: {message}")
class OwncastClient:
"""Async client for the Owncast Integration API.
Uses a shared :class:`~owlbot.api.http_client.HttpClient` for HTTP
transport, with per-request Bearer token authentication.
"""
def __init__(self, base_url: str, access_token: str, http_client: HttpClient):
"""
Initialize the Owncast client.
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
:param access_token: API access token from Owncast admin settings.
:param http_client: Shared HTTP client for making requests.
"""
self._base_url = base_url.rstrip("/")
self._http = http_client
self._logger = logging.getLogger("owlbot.owncast_client")
self._headers: dict[str, str] | None = {
"Authorization": f"Bearer {access_token}"
}
self._auth: aiohttp.BasicAuth | None = None
self._logger.debug(f"Owncast API client initialized for: {self._base_url}")
@property
def base_url(self) -> str:
"""The Owncast server base URL (e.g., "https://stream.logal.dev")."""
return self._base_url
async def get_status(self) -> dict[str, Any]:
"""
Get the public server status.
This is a public endpoint that does not require authentication.
Returns server info including version, online status, and viewer count.
:return: Status dict with ``versionNumber``, ``online``, ``viewerCount``, etc.
"""
self._logger.debug("Fetching server status.")
return dict(await self._get("/api/status"))
async def send_message(self, body: str) -> str:
"""
Send a chat message visible to all viewers.
:param body: The message text (supports markdown).
:return: Success message from the server.
"""
self._logger.info(f"Sending chat message: {body}")
return await self._post("/api/integrations/chat/send", {"body": body})
async def send_system_message(self, body: str) -> str:
"""
Send a system message visible to all viewers.
System messages are styled differently from regular chat (typically
italicized or dimmed) and are used for announcements or notifications.
:param body: The message text.
:return: Success message from the server.
"""
self._logger.info(f"Sending system message: {body}")
return await self._post("/api/integrations/chat/system", {"body": body})
async def send_action(self, body: str) -> str:
"""
Send an action message (like IRC /me).
Action messages display as "*BotName does something*" and are used
for describing actions rather than speech.
:param body: The action text (displayed after the bot name).
:return: Success message from the server.
"""
self._logger.info(f"Sending action: {body}")
return await self._post("/api/integrations/chat/action", {"body": body})
async def send_system_message_to_client(self, client_id: int, body: str) -> str:
"""
Send a private system message to a specific viewer.
The message is only visible to the targeted client, useful for
welcome messages or private notifications.
:param client_id: The numeric client ID (from event.client_id).
:param body: The message text.
:return: Success message from the server.
"""
self._logger.info(f"Sending system message to client {client_id}: {body}")
return await self._post(
f"/api/integrations/chat/system/client/{client_id}", {"body": body}
)
async def set_message_visibility(
self, message_ids: list[str], visible: bool
) -> str:
"""
Hide or show chat messages (moderation).
Hidden messages are removed from the chat display for all viewers.
This is typically used for moderation purposes.
:param message_ids: List of message IDs to modify.
:param visible: True to show messages, False to hide them.
:return: Success message from the server.
"""
action = "Showing" if visible else "Hiding"
ids = ", ".join(message_ids)
self._logger.info(f"{action} {len(message_ids)} message(s): {ids}")
return await self._post(
"/api/integrations/chat/messagevisibility",
{"idArray": message_ids, "visible": visible},
)
async def get_chat_history(self) -> list[dict[str, Any]]:
"""
Fetch recent chat messages.
:return: List of recent chat message objects with user info and content.
"""
self._logger.debug("Fetching chat history.")
return list(await self._get("/api/integrations/chat"))
async def get_connected_clients(self) -> list[dict[str, Any]]:
"""
Get list of currently connected viewers.
:return: List of connected client objects with user info and connection details.
"""
self._logger.debug("Fetching connected clients.")
return list(await self._get("/api/integrations/clients"))
async def set_stream_title(self, title: str) -> str:
"""
Update the stream title.
:param title: The new stream title.
:return: Success message from the server.
"""
self._logger.info(f"Setting stream title: {title}")
return await self._post("/api/integrations/streamtitle", {"value": title})
async def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> str:
"""
Send a POST request to the Owncast API.
Owncast POST endpoints return ``{"success": true, "message": "..."}``.
This method validates the response and returns just the message string.
:param endpoint: The API endpoint path.
:param data: Optional JSON body to send.
:return: The success message string from the response.
:raises OwncastError: If the request fails.
"""
url = f"{self._base_url}{endpoint}"
self._logger.debug(f"POST {endpoint}")
try:
async with self._http.session.post(
url,
json=data,
headers=self._headers,
auth=self._auth,
allow_redirects=False,
) as response:
if response.status >= 400:
text = await response.text()
message = _extract_error(text)
self._logger.error(
f"Error {response.status} on POST {endpoint}: {message}"
)
raise OwncastError(response.status, message)
self._logger.debug(f"POST {endpoint} -> {response.status}")
if response.content_type == "application/json":
try:
result = await response.json()
except ValueError, aiohttp.ContentTypeError:
text = await response.text()
self._logger.error(f"Invalid JSON on POST {endpoint}: {text}")
raise OwncastError(response.status, text) from None
if isinstance(result, dict):
# Is there an error field? Owncast returns
# {"error": "..."} for internal errors.
if "error" in result:
self._logger.error(
f"Error on POST {endpoint}: {result['error']}"
)
raise OwncastError(response.status, result["error"])
# Does the success flag indicate failure?
if "success" in result and not result["success"]:
message = result.get("message", "unknown error")
self._logger.error(f"Error on POST {endpoint}: {message}")
raise OwncastError(response.status, message)
# Is this a simple success response? Extract the message.
if "success" in result:
return str(result.get("message", ""))
# Unknown response shape from Owncast.
self._logger.warning(
f"Unknown response on POST {endpoint}: {result}"
)
return ""
return ""
except aiohttp.ClientError as e:
self._logger.error(f"Connection error on POST {endpoint}: {e}")
raise OwncastError(0, str(e)) from e
async def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
"""
Send a GET request to the Owncast API.
:param endpoint: The API endpoint path.
:param params: Optional query parameters.
:return: The JSON response.
:raises OwncastError: If the request fails.
"""
url = f"{self._base_url}{endpoint}"
self._logger.debug(f"GET {endpoint}")
try:
async with self._http.session.get(
url,
params=params,
headers=self._headers,
auth=self._auth,
allow_redirects=False,
) as response:
if response.status >= 400:
text = await response.text()
message = _extract_error(text)
self._logger.error(
f"Error {response.status} on GET {endpoint}: {message}"
)
raise OwncastError(response.status, message)
self._logger.debug(f"GET {endpoint} -> {response.status}")
try:
result = await response.json()
except ValueError, aiohttp.ContentTypeError:
text = await response.text()
self._logger.error(f"Invalid JSON on GET {endpoint}: {text}")
raise OwncastError(response.status, text) from None
# Is there an error field? Owncast returns
# {"error": "..."} for internal errors.
if isinstance(result, dict) and "error" in result:
self._logger.error(f"Error on GET {endpoint}: {result['error']}")
raise OwncastError(response.status, result["error"])
return result
except aiohttp.ClientError as e:
self._logger.error(f"Connection error on GET {endpoint}: {e}")
raise OwncastError(0, str(e)) from e
+81
View File
@@ -0,0 +1,81 @@
# 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 route registration decorators and types for Owlbot.
This module provides the module-facing API for HTTP routes:
- @on_route decorator for registering route handlers
- RouteInfo dataclass for route metadata
- RouteHandler type alias
"""
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, TypedDict
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from aiohttp import web
from .context import RouteContext
class RouteMark(TypedDict):
"""Type for the route marker attribute set by @on_route."""
path: str
methods: list[str] | None
# Handler type: receives RouteContext, returns web.Response or dict (auto-JSON).
type RouteHandler = (
"Callable[[RouteContext], Awaitable[web.Response | dict[str, Any] | None]]"
)
@dataclass
class RouteInfo:
"""Metadata about a registered route."""
path: str # Path relative to module namespace (e.g., "/stats").
full_path: str # Full path including namespace (e.g., "/owlbot/mymodule/stats").
methods: frozenset[str] # HTTP methods (GET, POST, etc.).
handler: RouteHandler
module_name: str
def on_route(
path: str,
*,
methods: list[str] | None = None,
) -> Callable[[RouteHandler], RouteHandler]:
"""
Decorator to register an HTTP route handler.
Routes are namespaced under /owlbot/<module_name>/<path>.
:param path: URL path (relative to module namespace, e.g., "/stats").
:param methods: List of HTTP methods to accept. Default: ["GET"].
:return: Decorator that marks the function for registration.
"""
def decorator(func: RouteHandler) -> RouteHandler:
# Mark the function with route info for deferred registration.
# methods=None is resolved to ["GET"] by RouteRegistry.register().
func._owlbot_route = RouteMark( # type: ignore[attr-defined]
path=path, methods=methods
)
return func
return decorator
+321
View File
@@ -0,0 +1,321 @@
# 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.
"""SQLite storage API for Owlbot modules."""
import asyncio
import contextvars
import logging
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
import aiosqlite
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
class StorageError(Exception):
"""Raised when a storage operation fails."""
class ModuleStorage:
"""
Module-scoped async SQLite storage backed by a lazy connection pool.
Each module gets its own isolated database file. Connections are created
lazily and pooled up to ``pool_size``. WAL mode is enabled so that
concurrent readers and a single writer can operate without "database is
locked" errors.
Transactions are managed at the handler level. The bot commits after each
handler succeeds, and rolls back if the handler throws an exception. Module
developers don't need to think about commits for normal usage.
For finer control within a handler, use the transaction() context manager
to group multiple operations that should succeed or fail together.
Concurrency: The _checkout() context manager acquires a dedicated
connection from the pool for the current handler invocation.
"""
def __init__(self, storage_dir: Path, module_name: str, pool_size: int = 4):
"""
Initialize the storage API.
:param storage_dir: Directory where module databases are stored.
:param module_name: Name of the module this storage belongs to.
:param pool_size: Maximum number of pooled connections.
"""
self._db_path = storage_dir / f"{module_name}.db"
self._module_name = module_name
self._pool: asyncio.Queue[aiosqlite.Connection] = asyncio.Queue()
self._pool_size = pool_size
self._all_connections: list[aiosqlite.Connection] = []
self._closed = False
self._txn_conn: contextvars.ContextVar[aiosqlite.Connection | None] = (
contextvars.ContextVar(f"_txn_conn_{module_name}", default=None)
)
self._logger = logging.getLogger(f"owlbot.modules.{module_name}.storage")
self._logger.info(f"Database initialized at: {self._db_path.absolute()}")
@asynccontextmanager
async def transaction(self) -> AsyncIterator[ModuleStorage]:
"""
Context manager for explicit transaction control within a handler.
Use this when you need multiple operations to succeed or fail together
within a single handler. Commits on success, rolls back on exception.
Example:
async with ctx.storage.transaction():
await ctx.storage.execute("UPDATE scores SET score = score - ?", (10,))
await ctx.storage.execute("UPDATE scores SET score = score + ?", (10,))
# Both committed together, or both rolled back on error
:return: This ModuleStorage instance.
"""
self._logger.debug("Explicit transaction started.")
try:
yield self
await self._commit()
except BaseException:
await self._rollback()
raise
async def execute(
self,
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> aiosqlite.Cursor:
"""
Execute a SQL statement.
:param sql: SQL statement (use ? or :name for parameters).
:param parameters: Query parameters (tuple for ?, dict for :name).
:return: Cursor with lastrowid and rowcount.
:raises StorageError: If execution fails.
"""
async with self._connection() as conn:
self._logger.debug(f"Execute: {sql[:80]}{'...' if len(sql) > 80 else ''}")
try:
return await conn.execute(sql, parameters)
except aiosqlite.Error as e:
self._logger.error(f"SQL error: {e}")
raise StorageError(f"SQL execution failed: {e}") from e
async def execute_many(
self,
sql: str,
parameters: list[tuple[Any, ...]] | list[dict[str, Any]],
) -> aiosqlite.Cursor:
"""
Execute a SQL statement with multiple parameter sets.
Useful for batch inserts/updates.
:param sql: SQL statement.
:param parameters: List of parameter tuples/dicts.
:return: Cursor with rowcount.
:raises StorageError: If execution fails.
"""
async with self._connection() as conn:
self._logger.debug(
f"Execute many ({len(parameters)} rows): "
f"{sql[:80]}{'...' if len(sql) > 80 else ''}"
)
try:
return await conn.executemany(sql, parameters)
except aiosqlite.Error as e:
self._logger.error(f"SQL error in executemany: {e}")
raise StorageError(f"SQL execution failed: {e}") from e
async def fetch_one(
self,
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> aiosqlite.Row | None:
"""
Execute a query and fetch one row.
:param sql: SELECT statement.
:param parameters: Query parameters.
:return: Row as a sqlite3.Row (supports both index and key access),
or None if no results.
:raises StorageError: If execution fails.
"""
async with self._connection() as conn:
self._logger.debug(f"Fetch one: {sql[:80]}{'...' if len(sql) > 80 else ''}")
try:
cursor = await conn.execute(sql, parameters)
return await cursor.fetchone()
except aiosqlite.Error as e:
self._logger.error(f"SQL error: {e}")
raise StorageError(f"SQL fetch failed: {e}") from e
async def fetch_all(
self,
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> list[aiosqlite.Row]:
"""
Execute a query and fetch all rows.
:param sql: SELECT statement.
:param parameters: Query parameters.
:return: List of rows as sqlite3.Row objects (support both index and key access).
:raises StorageError: If execution fails.
"""
async with self._connection() as conn:
self._logger.debug(f"Fetch all: {sql[:80]}{'...' if len(sql) > 80 else ''}")
try:
cursor = await conn.execute(sql, parameters)
return list(await cursor.fetchall())
except aiosqlite.Error as e:
self._logger.error(f"SQL error: {e}")
raise StorageError(f"SQL fetch failed: {e}") from e
async def fetch_value(
self,
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> Any | None:
"""
Execute a query and fetch a single value.
:param sql: SELECT statement returning one column.
:param parameters: Query parameters.
:return: The value, or None if no results.
:raises StorageError: If execution fails.
"""
row = await self.fetch_one(sql, parameters)
if row is None:
return None
return row[0]
async def _create_connection(self) -> aiosqlite.Connection:
"""Create a new database connection with WAL mode and foreign keys."""
self._db_path.parent.mkdir(parents=True, exist_ok=True)
conn = await aiosqlite.connect(self._db_path)
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA journal_mode=WAL")
await conn.execute("PRAGMA foreign_keys = ON")
self._all_connections.append(conn)
self._logger.debug(
f"Pool connection created ({len(self._all_connections)}/{self._pool_size})."
)
return conn
async def _acquire(self) -> aiosqlite.Connection:
"""Acquire a connection from the pool, creating one if needed."""
if self._closed:
raise StorageError("Storage is closed")
try:
return self._pool.get_nowait()
except asyncio.QueueEmpty:
pass
if len(self._all_connections) < self._pool_size:
return await self._create_connection()
# Pool exhausted, wait for one to be returned.
return await self._pool.get()
def _release(self, conn: aiosqlite.Connection) -> None:
"""Return a connection to the pool."""
self._pool.put_nowait(conn)
@asynccontextmanager
async def _connection(self) -> AsyncIterator[aiosqlite.Connection]:
"""
Async context manager that provides a connection.
If already inside a ``_checkout``, yields the checked-out connection
without releasing it. Otherwise acquires a standalone connection from
the pool that auto-commits on success and rolls back on failure
before being released.
"""
existing = self._txn_conn.get()
if existing is not None:
yield existing
return
conn = await self._acquire()
try:
yield conn
await conn.commit()
except BaseException:
await conn.rollback()
raise
finally:
self._release(conn)
@asynccontextmanager
async def _checkout(self) -> AsyncIterator[None]:
"""
Check out a connection from the pool for the duration of a handler.
Sets a ContextVar so that all storage operations within the handler
reuse the same connection.
"""
conn = await self._acquire()
token = self._txn_conn.set(conn)
try:
yield
finally:
self._txn_conn.reset(token)
self._release(conn)
async def _commit(self) -> None:
"""
Commit the current transaction (internal use by bot).
Called automatically after each handler completes successfully.
"""
conn = self._txn_conn.get()
if conn is not None:
await conn.commit()
self._logger.debug("Transaction committed.")
async def _rollback(self) -> None:
"""
Rollback the current transaction (internal use by bot).
Called automatically if a handler throws an exception.
"""
conn = self._txn_conn.get()
if conn is not None:
await conn.rollback()
self._logger.debug("Transaction rolled back.")
async def _close(self) -> None:
"""Close all pool connections (internal use by bot)."""
self._closed = True
for conn in self._all_connections:
try:
await conn.close()
except Exception as e:
self._logger.debug(f"Exception closing connection: {e}")
while not self._pool.empty():
try:
self._pool.get_nowait()
except asyncio.QueueEmpty:
break
self._all_connections.clear()
self._logger.info("All pool connections closed.")
+262
View File
@@ -0,0 +1,262 @@
# 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.
"""Main bot class that ties together the webhook server, modules, and Owncast API."""
import logging
from typing import TYPE_CHECKING, Any
import yaml
from . import OWNCAST_TARGET_VERSION, __version__
from .api.config import Config
from .api.http_client import HttpClient
from .api.owncast_client import OwncastError
from .http_server import HttpServer
from .module_loader import ModuleLoader
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger("owlbot")
class StartupError(Exception):
"""Raised when a fatal error occurs during bot startup."""
class Owlbot:
"""The main Owlbot application."""
def __init__(
self,
config_path: str | Path = "config.yaml",
overrides: dict[str, Any] | None = None,
skip_api_check: bool = False,
):
"""
Initialize Owlbot.
:param config_path: Path to the YAML config file.
:param overrides: CLI overrides passed to the config manager.
:param skip_api_check: Skip API accessibility checks during startup.
"""
logger.info(f"Owlbot v{__version__} - A logal.dev project")
self.skip_api_check = skip_api_check
# Load config. Raises StartupError on missing/invalid config.
try:
self.config = Config(config_path, overrides=overrides)
except (FileNotFoundError, ValueError, yaml.YAMLError, OSError) as e:
raise StartupError(str(e)) from e
self.modules_dir = self.config.modules_dir
if not self.modules_dir.exists():
logger.info(
f"User modules directory not found: {self.modules_dir} "
f"(only built-in modules will be loaded)"
)
# Shared HTTP client (owns the aiohttp session lifecycle).
self.http_client = HttpClient()
# Module loader (owns API clients, dispatchers, and module contexts).
self.module_loader = ModuleLoader(
self.modules_dir,
self.config,
self.http_client,
)
self.http_server = HttpServer(
config=self.config,
event_dispatch=self.module_loader.event_dispatcher.dispatch,
route_dispatcher=self.module_loader.route_dispatcher,
)
logger.debug("Owlbot initialization complete.")
async def __aenter__(self) -> Owlbot:
"""Start the bot as an async context manager."""
await self.start()
return self
async def __aexit__(self, *exc_info: Any) -> None:
"""Stop the bot when exiting the async context manager."""
await self.stop()
async def start(self) -> None:
"""Initialize the bot and start listening for webhooks.
Returns once the bot is ready. The caller is responsible for
keeping the event loop alive and calling :meth:`stop` when done.
Prefer using Owlbot as an async context manager instead of
calling ``start`` / ``stop`` manually.
On failure, cleans up any partially initialized state before
raising.
:raises StartupError: If the bot fails to start (e.g., API unreachable).
"""
logger.info("Starting Owlbot...")
try:
# Auth is handled per-request by each Owncast client.
await self.http_client._start()
if self.skip_api_check:
logger.info("Skipping startup connection tests.")
if self.module_loader.admin_client:
logger.info("Owncast admin API is enabled.")
else:
await self._check_api_accessibility()
await self.module_loader.load_all_modules()
await self.http_server.start(self.config.host, self.config.port)
logger.info("Owlbot start complete.")
except StartupError:
await self.stop()
raise
except OSError as e:
await self.stop()
raise StartupError(
f"Failed to start web server on "
f"{self.config.host}:{self.config.port}: {e}"
) from e
async def stop(self) -> None:
"""Gracefully shut down the bot, unloading all modules.
Safe to call multiple times or on partially initialized state.
"""
logger.info("Shutting down Owlbot...")
# Stop accepting new webhooks.
await self.http_server.stop()
# Wait for in-flight event handlers to finish.
await self.http_server.drain()
# Unload modules now that all handlers have completed.
await self.module_loader.unload_all_modules()
# Close the shared HTTP client (safe to call if startup failed early).
await self.http_client._close()
logger.info("Owlbot shutdown complete.")
async def _check_api_accessibility(self) -> None:
"""
Verify that the Owncast APIs are reachable before proceeding with startup.
Calls a non-transformative endpoint on each configured client to confirm
the server is accessible and credentials are valid. Raises
:class:`StartupError` if any check fails.
"""
owncast_client = self.module_loader.owncast_client
admin_client = self.module_loader.admin_client
base_url = owncast_client.base_url
logger.debug("Checking Owncast server version...")
try:
status = await owncast_client.get_status()
except OwncastError as e:
if e.status == 0:
logger.error(
"Startup connection test failed: "
f"Could not connect to {base_url}: {e.message}"
)
raise StartupError(
f"Could not connect to {base_url}: {e.message}"
) from e
logger.error(
"Startup connection test failed: "
f"{base_url} returned HTTP {e.status}: {e.message}"
)
raise StartupError(
"Owncast version check failed: "
f"{base_url} returned HTTP {e.status}: {e.message}"
) from e
version = status.get("versionNumber") if isinstance(status, dict) else None
if not version:
logger.error(
f"Startup connection test failed: {base_url} "
"does not appear to be an Owncast instance "
"(no versionNumber in response)"
)
raise StartupError(
f"{base_url} does not appear to be an Owncast instance "
"(no versionNumber in response)"
)
# Warn on version mismatch but continue startup.
if version != OWNCAST_TARGET_VERSION:
logger.warning(
f"Owncast version {version} detected at {base_url}, "
f"but this build of Owlbot is designed for {OWNCAST_TARGET_VERSION}. "
"Startup will continue, but some things may not behave as expected."
)
else:
logger.info(f"Owncast version {version} detected at {base_url}.")
logger.debug("Checking Owncast integrations API accessibility...")
try:
await owncast_client.get_connected_clients()
logger.debug("Owncast integrations API is accessible.")
except OwncastError as e:
if e.status == 0:
logger.error(
"Startup connection test failed: Could not "
f"connect to integrations API: {e.message}"
)
raise StartupError(
f"Could not connect to integrations API: {e.message}"
) from e
logger.error(
"Startup connection test failed: "
"Integrations API returned "
f"HTTP {e.status}: {e.message}"
)
raise StartupError(
"Owncast integrations API check failed: "
f"returned HTTP {e.status}: {e.message}"
) from e
if admin_client:
logger.debug("Checking Owncast admin API accessibility...")
try:
await admin_client.get_status()
logger.info("Owncast admin API is enabled.")
except OwncastError as e:
if e.status == 0:
logger.error(
"Startup connection test failed: Could "
f"not connect to admin API: {e.message}"
)
raise StartupError(
f"Could not connect to admin API: {e.message}"
) from e
logger.error(
"Startup connection test failed: "
"Admin API returned "
f"HTTP {e.status}: {e.message}"
)
raise StartupError(
"Owncast admin API check failed: "
f"returned HTTP {e.status}: {e.message}"
) from e
+23
View File
@@ -0,0 +1,23 @@
# 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.
"""Built-in modules that ship with the Owlbot package."""
BUILTIN_MODULE_NAMES: frozenset[str] = frozenset(
{
"custom_commands",
"quotes",
"timers",
}
)
@@ -0,0 +1,120 @@
# 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.
"""Custom commands module for Owlbot.
Allows moderators to create, edit, delete, and list custom chat commands at runtime.
Custom commands are stored in SQLite and dynamically registered
with the CommandRegistry.
"""
from owlbot.api import ModuleContext, on_setup
from .handler import custom_command_handler
# Re-export decorated handlers so the module loader discovers them.
from .management_commands import (
addalias,
addcommand,
commandcooldown,
commandmodonly,
deletecommand,
editcommand,
listcommands,
removealias,
resetcommand,
)
from .routes import command_list_page
__all__ = [
"addalias",
"addcommand",
"command_list_page",
"commandcooldown",
"commandmodonly",
"deletecommand",
"editcommand",
"listcommands",
"removealias",
"resetcommand",
"setup",
]
@on_setup
async def setup(ctx: ModuleContext) -> None:
"""
Initialize the custom_commands module.
Creates the database schema and loads existing commands from the database.
:param ctx: Module context with config, storage, and other services.
"""
ctx.config.register_defaults({"max_nesting_depth": 4, "default_cooldown": 5})
await ctx.storage.execute("""
CREATE TABLE IF NOT EXISTS commands (
name TEXT PRIMARY KEY NOT NULL,
response TEXT NOT NULL,
use_count INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
requires_moderator INTEGER DEFAULT 0,
cooldown INTEGER DEFAULT 0
)
""")
await ctx.storage.execute("""
CREATE TABLE IF NOT EXISTS command_aliases (
alias TEXT PRIMARY KEY NOT NULL,
command_name TEXT NOT NULL,
FOREIGN KEY (command_name) REFERENCES commands(name) ON DELETE CASCADE
)
""")
await ctx.storage.execute("""
CREATE TABLE IF NOT EXISTS counters (
name TEXT PRIMARY KEY NOT NULL,
value INTEGER DEFAULT 0
)
""")
rows = await ctx.storage.fetch_all(
"SELECT c.name, c.requires_moderator, c.cooldown, "
"GROUP_CONCAT(ca.alias) AS aliases "
"FROM commands c "
"LEFT JOIN command_aliases ca ON c.name = ca.command_name "
"GROUP BY c.name"
)
skipped = 0
for row in rows:
aliases = row["aliases"].split(",") if row["aliases"] else []
try:
ctx.commands.register(
name=row["name"],
handler=custom_command_handler,
aliases=aliases,
requires_moderator=bool(row["requires_moderator"]),
cooldown=row["cooldown"],
)
except ValueError:
ctx.logger.warning(
f"Skipping custom command '{row['name']}': "
"conflicts with an existing command."
)
skipped += 1
loaded_count = len(rows) - skipped
ctx.logger.info(f"Loaded {loaded_count} custom command(s) from database.")
if skipped:
ctx.logger.info(f"Skipped {skipped} conflicting custom command(s).")
@@ -0,0 +1,175 @@
# 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 custom command handler and database/registry helpers."""
from datetime import UTC, datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import aiosqlite
from owlbot.api import CommandContext, ModuleCommands, ModuleStorage
from .placeholders import DEFAULT_MAX_DEPTH, process_placeholders
async def get_aliases_for_command(
storage: ModuleStorage, command_name: str
) -> list[str]:
"""
Fetch all aliases for a command from the database.
:param storage: The module storage instance.
:param command_name: The canonical command name.
:return: List of alias names (may be empty).
"""
rows = await storage.fetch_all(
"SELECT alias FROM command_aliases WHERE command_name = ? ORDER BY alias",
(command_name,),
)
return [row["alias"] for row in rows]
async def resolve_command_name(
storage: ModuleStorage, name: str
) -> aiosqlite.Row | None:
"""
Resolve a command name or alias to the full command row.
Checks the commands table first, then falls back to the aliases table.
Returns the command's name, requires_moderator, and cooldown columns.
:param storage: The module storage instance.
:param name: A command name or alias (already lowercased).
:return: The command row, or None if not found.
"""
return await storage.fetch_one(
"SELECT c.name, c.requires_moderator, c.cooldown FROM commands c "
"WHERE c.name = ? "
"UNION ALL "
"SELECT c.name, c.requires_moderator, c.cooldown "
"FROM command_aliases a JOIN commands c ON c.name = a.command_name "
"WHERE a.alias = ? "
"LIMIT 1",
(name, name),
)
async def custom_command_handler(ctx: CommandContext) -> None:
"""
Shared handler for all custom commands.
Looks up the command in the database, increments use count,
processes placeholders, and sends the response.
:param ctx: The command context.
"""
cmd_name = ctx.command
row = await ctx.storage.fetch_one(
"UPDATE commands SET use_count = use_count + 1 WHERE name = ? "
"RETURNING response, use_count",
(cmd_name,),
)
# Shouldn't happen unless the command was deleted but not unregistered.
if not row:
ctx.logger.warning(f"Custom command '{cmd_name}' not found in database.")
return
max_depth = ctx.config.get("max_nesting_depth", DEFAULT_MAX_DEPTH)
response = await process_placeholders(
row["response"],
ctx.args_list,
ctx.user.display_name,
row["use_count"],
ctx.storage,
max_depth=max_depth,
)
await ctx.owncast_client.send_message(response)
def reregister_command(
commands: ModuleCommands,
name: str,
*,
aliases: list[str],
requires_moderator: bool,
cooldown: int,
) -> None:
"""
Unregister and re-register a custom command with updated settings.
Both registry operations are synchronous, so no other coroutine can observe
the intermediate unregistered state.
:param commands: The module-scoped command API (ModuleCommands).
:param name: The canonical command name.
:param aliases: List of alias names.
:param requires_moderator: Whether the command requires moderator.
:param cooldown: Cooldown in seconds.
"""
commands.unregister(name)
commands.register(
name=name,
handler=custom_command_handler,
aliases=aliases,
requires_moderator=requires_moderator,
cooldown=cooldown,
)
async def update_and_reregister(
ctx: CommandContext,
command_row: aiosqlite.Row,
*,
requires_moderator: int | None = None,
cooldown: int | None = None,
) -> None:
"""Update a command's settings in the database and re-register it.
Uses the provided *command_row* for current settings, applies any
overrides, writes them back, and re-registers the command so the
in-memory registry matches the database.
:param ctx: The command context.
:param command_row: The command row (from ``resolve_command_name``).
:param requires_moderator: New value, or None to keep the current one.
:param cooldown: New value, or None to keep the current one.
"""
name: str = command_row["name"]
final_mod = (
requires_moderator
if requires_moderator is not None
else command_row["requires_moderator"]
)
final_cooldown = cooldown if cooldown is not None else command_row["cooldown"]
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"UPDATE commands SET requires_moderator = ?, cooldown = ?, updated_at = ? "
"WHERE name = ?",
(final_mod, final_cooldown, now, name),
)
aliases = await get_aliases_for_command(ctx.storage, name)
reregister_command(
ctx.commands,
name,
aliases=aliases,
requires_moderator=bool(final_mod),
cooldown=final_cooldown,
)
@@ -0,0 +1,484 @@
# 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.
"""Management commands for custom commands (add, edit, delete, etc.)."""
import re
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from owlbot.api import CommandContext, on_command
from .handler import (
custom_command_handler,
get_aliases_for_command,
reregister_command,
resolve_command_name,
update_and_reregister,
)
if TYPE_CHECKING:
import aiosqlite
def _clean_raw_name(raw_name: str, prefix: str) -> str:
"""Strip the command prefix (if present) and lowercase a raw name."""
return raw_name.removeprefix(prefix).lower()
async def _resolve_or_error(ctx: CommandContext, raw_name: str) -> aiosqlite.Row | None:
"""Resolve a raw argument to a custom command row, or send an error message.
Cleans the raw name, resolves it via the database, and -- if not found --
sends the appropriate error to chat (distinguishing "not a custom command"
from "does not exist").
:param ctx: The command context.
:param raw_name: The raw argument from the user (may include prefix).
:return: The command row (name, requires_moderator, cooldown), or None
if resolution failed.
"""
prefix = ctx.commands.prefix
input_name = _clean_raw_name(raw_name, prefix)
row = await resolve_command_name(ctx.storage, input_name)
if row is not None:
return row
if ctx.commands.exists(input_name):
await ctx.owncast_client.send_message(
"That command is not a custom command and cannot be modified."
)
else:
await ctx.owncast_client.send_message("That command does not exist.")
return None
@on_command("addcommand", aliases=["addcmd"], requires_moderator=True)
async def addcommand(ctx: CommandContext) -> None:
"""
Create a new custom command.
Usage: !addcommand !name response text
:param ctx: The command context.
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) < 2:
await ctx.owncast_client.send_message(
f"Usage: {prefix}addcommand {prefix}name response text"
)
return
name = _clean_raw_name(args[0], prefix)
if not re.match(r"^[a-z0-9_]+$", name):
await ctx.owncast_client.send_message(
"Invalid command name. Only letters, numbers, and underscores are allowed."
)
return
parts = ctx.args.split(maxsplit=1)
if len(parts) < 2:
await ctx.owncast_client.send_message(
f"Usage: {prefix}addcommand {prefix}name response text"
)
return
response = parts[1]
if ctx.commands.exists(name):
await ctx.owncast_client.send_message(
f"Command {prefix}{name} already exists. "
f"Use '{prefix}editcommand' to modify it."
)
return
# Register immediately after the exists() check -- both are synchronous,
# so no other coroutine can interleave.
default_cooldown = ctx.config.get("default_cooldown", 5)
ctx.commands.register(
name=name,
handler=custom_command_handler,
cooldown=default_cooldown,
)
# Persist to database. If this fails, roll back the in-memory registration.
now = datetime.now(UTC).isoformat()
try:
await ctx.storage.execute(
"INSERT INTO commands (name, response, use_count,"
" created_at, updated_at, requires_moderator,"
" cooldown) VALUES (?, ?, 0, ?, ?, 0, ?)",
(name, response, now, now, default_cooldown),
)
except Exception:
ctx.commands.unregister(name)
raise
ctx.logger.info(f"Custom command '{name}' created.")
await ctx.owncast_client.send_message(f"Command {prefix}{name} created.")
@on_command("editcommand", aliases=["editcmd"], requires_moderator=True)
async def editcommand(ctx: CommandContext) -> None:
"""
Edit an existing custom command's response.
Usage: !editcommand !name new response
:param ctx: The command context.
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) < 2:
await ctx.owncast_client.send_message(
f"Usage: {prefix}editcommand {prefix}name new response"
)
return
parts = ctx.args.split(maxsplit=1)
if len(parts) < 2:
await ctx.owncast_client.send_message(
f"Usage: {prefix}editcommand {prefix}name new response"
)
return
response = parts[1]
row = await _resolve_or_error(ctx, args[0])
if row is None:
return
name: str = row["name"]
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"UPDATE commands SET response = ?, updated_at = ? WHERE name = ?",
(response, now, name),
)
ctx.logger.info(f"Custom command '{name}' updated.")
await ctx.owncast_client.send_message(f"Command {prefix}{name} updated.")
@on_command("deletecommand", aliases=["delcmd"], requires_moderator=True)
async def deletecommand(ctx: CommandContext) -> None:
"""
Delete a custom command.
Usage: !deletecommand !name
:param ctx: The command context.
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message(
f"Usage: {prefix}deletecommand {prefix}name"
)
return
input_name = _clean_raw_name(args[0], prefix)
row = await _resolve_or_error(ctx, args[0])
if row is None:
return
name: str = row["name"]
if name != input_name:
await ctx.owncast_client.send_message(
f"{prefix}{input_name} is an alias of {prefix}{name}. "
f"Use {prefix}removealias {prefix}{input_name} to remove the alias, "
f"or {prefix}deletecommand {prefix}{name} to delete the command."
)
return
await ctx.storage.execute("DELETE FROM commands WHERE name = ?", (name,))
ctx.commands.unregister(name)
ctx.logger.info(f"Custom command '{name}' deleted.")
await ctx.owncast_client.send_message(f"Command {prefix}{name} deleted.")
@on_command("commandmodonly", aliases=["cmdmodonly"], requires_moderator=True)
async def commandmodonly(ctx: CommandContext) -> None:
"""
Toggle moderator-only access for a custom command.
Usage: !commandmodonly !name <on|off>
:param ctx: The command context.
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) < 2:
await ctx.owncast_client.send_message(
f"Usage: {prefix}commandmodonly {prefix}name <on|off>"
)
return
setting = args[1].lower()
if setting not in ("on", "off"):
await ctx.owncast_client.send_message(
"Invalid setting. Expected 'on' or 'off'."
)
return
requires_moderator = 1 if setting == "on" else 0
row = await _resolve_or_error(ctx, args[0])
if row is None:
return
name: str = row["name"]
await update_and_reregister(ctx, row, requires_moderator=requires_moderator)
status = "moderator-only" if requires_moderator else "public"
ctx.logger.info(f"Custom command '{name}' set to {status}.")
await ctx.owncast_client.send_message(f"Command {prefix}{name} is now {status}.")
@on_command("resetcommand", aliases=["resetcmd"], requires_moderator=True)
async def resetcommand(ctx: CommandContext) -> None:
"""
Reset a custom command's use counter to 0.
Usage: !resetcommand !name
:param ctx: The command context.
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message(
f"Usage: {prefix}resetcommand {prefix}name"
)
return
row = await _resolve_or_error(ctx, args[0])
if row is None:
return
name: str = row["name"]
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"UPDATE commands SET use_count = 0, updated_at = ? WHERE name = ?",
(now, name),
)
ctx.logger.info(f"Custom command '{name}' counter reset to 0.")
await ctx.owncast_client.send_message(f"Command {prefix}{name} counter reset.")
@on_command("commandcooldown", aliases=["cmdcooldown"], requires_moderator=True)
async def commandcooldown(ctx: CommandContext) -> None:
"""
Set or disable a custom command's cooldown.
Usage: !commandcooldown !name <seconds>
:param ctx: The command context.
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) < 2:
await ctx.owncast_client.send_message(
f"Usage: {prefix}commandcooldown {prefix}name <seconds>"
)
return
raw_seconds = args[1]
try:
seconds = int(raw_seconds)
except ValueError:
await ctx.owncast_client.send_message(
"Invalid cooldown value. Must be a non-negative integer."
)
return
if seconds < 0:
await ctx.owncast_client.send_message(
"Cooldown must be a non-negative integer (0 to disable)."
)
return
row = await _resolve_or_error(ctx, args[0])
if row is None:
return
name: str = row["name"]
await update_and_reregister(ctx, row, cooldown=seconds)
if seconds == 0:
ctx.logger.info(f"Custom command '{name}' cooldown disabled.")
await ctx.owncast_client.send_message(
f"Command {prefix}{name} cooldown disabled."
)
else:
ctx.logger.info(f"Custom command '{name}' cooldown set to {seconds}s.")
await ctx.owncast_client.send_message(
f"Command {prefix}{name} cooldown set to {seconds}s."
)
@on_command("addalias", requires_moderator=True)
async def addalias(ctx: CommandContext) -> None:
"""
Add an alias to an existing custom command.
Usage: !addalias !command !alias
:param ctx: The command context.
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) < 2:
await ctx.owncast_client.send_message(
f"Usage: {prefix}addalias {prefix}command {prefix}alias"
)
return
alias = _clean_raw_name(args[1], prefix)
if not re.match(r"^[a-z0-9_]+$", alias):
await ctx.owncast_client.send_message(
"Invalid alias name. Only letters, numbers, and underscores are allowed."
)
return
command_row = await _resolve_or_error(ctx, args[0])
if command_row is None:
return
name: str = command_row["name"]
if alias == name:
await ctx.owncast_client.send_message(
"An alias cannot be the same as the command name."
)
return
existing_alias = await ctx.storage.fetch_one(
"SELECT alias, command_name FROM command_aliases WHERE alias = ?",
(alias,),
)
if existing_alias:
if existing_alias["command_name"] == name:
await ctx.owncast_client.send_message(
f"Alias {prefix}{alias} is already assigned to {prefix}{name}."
)
else:
await ctx.owncast_client.send_message(
f"Alias {prefix}{alias} is already assigned to "
f"{prefix}{existing_alias['command_name']}."
)
return
if ctx.commands.exists(alias):
await ctx.owncast_client.send_message(
f"Alias {prefix}{alias} conflicts with an existing command."
)
return
current_aliases = await get_aliases_for_command(ctx.storage, name)
await ctx.storage.execute(
"INSERT INTO command_aliases (alias, command_name) VALUES (?, ?)",
(alias, name),
)
updated_aliases = [*current_aliases, alias]
reregister_command(
ctx.commands,
name,
aliases=updated_aliases,
requires_moderator=bool(command_row["requires_moderator"]),
cooldown=command_row["cooldown"],
)
ctx.logger.info(f"Alias '{alias}' added to custom command '{name}'.")
await ctx.owncast_client.send_message(
f"Alias {prefix}{alias} added to {prefix}{name}."
)
@on_command("removealias", requires_moderator=True)
async def removealias(ctx: CommandContext) -> None:
"""
Remove an alias from a custom command.
Usage: !removealias !alias
:param ctx: The command context.
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message(
f"Usage: {prefix}removealias {prefix}alias"
)
return
alias = _clean_raw_name(args[0], prefix)
row = await ctx.storage.fetch_one(
"SELECT a.command_name, c.requires_moderator, c.cooldown "
"FROM command_aliases a "
"JOIN commands c ON c.name = a.command_name "
"WHERE a.alias = ?",
(alias,),
)
if not row:
await ctx.owncast_client.send_message(f"Alias {prefix}{alias} does not exist.")
return
command_name: str = row["command_name"]
await ctx.storage.execute("DELETE FROM command_aliases WHERE alias = ?", (alias,))
remaining_aliases = await get_aliases_for_command(ctx.storage, command_name)
reregister_command(
ctx.commands,
command_name,
aliases=remaining_aliases,
requires_moderator=bool(row["requires_moderator"]),
cooldown=row["cooldown"],
)
ctx.logger.info(f"Alias '{alias}' removed from custom command '{command_name}'.")
await ctx.owncast_client.send_message(
f"Alias {prefix}{alias} removed from {prefix}{command_name}."
)
@on_command("listcommands", aliases=["listcmds"], cooldown=15)
async def listcommands(ctx: CommandContext) -> None:
"""
List all custom commands.
Sends a URL to the command list web page.
Has a 15-second cooldown to prevent spam.
Usage: !listcommands
:param ctx: The command context.
"""
url = ctx.routes.url_for("/list")
await ctx.owncast_client.send_message(f"Custom commands: {url}")
@@ -0,0 +1,477 @@
# 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.
"""Handler functions for placeholder resolution.
Each handler is a plain async function that receives the placeholder name,
pre-resolved argument list, and a :class:`PlaceholderContext`, and returns the
replacement string. Handlers raise :exc:`PlaceholderError` to report bad
arguments; the engine immediately stops processing and returns ``str(e)`` as
the entire response, discarding any other template content.
"""
import random
import re
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .placeholders import PlaceholderContext
class PlaceholderError(Exception):
"""Raised by a placeholder handler to report a resolution error."""
# Sourced from the Wikipedia "List of time zone abbreviations" article, which
# compiles data from the IANA Time Zone Database and other references.
# https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations
#
# Keys are uppercase because the lookup normalises user input with .upper().
# Ambiguous abbreviations use the most common interpretation:
# ACT = Acre Time (-5), not ASEAN Common Time (+8)
# AMT = Amazon Time (-4), not Armenia Time (+4)
# AST = Atlantic Standard Time (-4), not Arabia Standard Time (+3)
# BST = British Summer Time (+1), not Bangladesh/Bougainville
# CDT = Central Daylight Time (-5), not Cuba Daylight Time (-4)
# CST = Central Standard Time (-6), not China (+8) or Cuba (-5)
# ECT = Ecuador Time (-5), not Eastern Caribbean Time (-4)
# GST = Gulf Standard Time (+4), not South Georgia Time (-2)
# IST = India Standard Time (+5:30), not Irish (+1) or Israel (+2)
# LHST = Lord Howe Standard Time (+10:30), not summer (+11)
# MST = Mountain Standard Time (-7), not Malaysia Standard Time (+8)
# PST = Pacific Standard Time (-8), not Philippine Standard Time (+8)
TIMEZONE_OFFSETS: dict[str, float] = {
"ACDT": 10.5,
"ACST": 9.5,
"ACT": -5,
"ACWST": 8.75,
"ADT": -3,
"AEDT": 11,
"AEST": 10,
"AFT": 4.5,
"AKDT": -8,
"AKST": -9,
"ALMT": 6,
"AMST": -3,
"AMT": -4,
"ANAT": 12,
"AQTT": 5,
"ART": -3,
"AST": -4,
"AWST": 8,
"AZOST": 0,
"AZOT": -1,
"AZT": 4,
"BIT": -12,
"BIOT": 6,
"BNT": 8,
"BOT": -4,
"BRST": -2,
"BRT": -3,
"BST": 1,
"BTT": 6,
"CAT": 2,
"CCT": 6.5,
"CDT": -5,
"CEST": 2,
"CET": 1,
"CHADT": 13.75,
"CHAST": 12.75,
"CHOT": 8,
"CHOST": 9,
"CHST": 10,
"CHUT": 10,
"CIST": -8,
"CKT": -10,
"CLST": -3,
"CLT": -4,
"COST": -4,
"COT": -5,
"CST": -6,
"CVT": -1,
"CWST": 8.75,
"CXT": 7,
"DAVT": 7,
"DDUT": 10,
"DFT": 1,
"EASST": -5,
"EAST": -6,
"EAT": 3,
"ECT": -5,
"EDT": -4,
"EEST": 3,
"EET": 2,
"EGST": 0,
"EGT": -1,
"EST": -5,
"FET": 3,
"FJT": 12,
"FKST": -3,
"FKT": -4,
"FNT": -2,
"GALT": -6,
"GAMT": -9,
"GET": 4,
"GFT": -3,
"GILT": 12,
"GIT": -9,
"GMT": 0,
"GST": 4,
"GYT": -4,
"HAEC": 2,
"HDT": -9,
"HKT": 8,
"HMT": 5,
"HOVST": 8,
"HOVT": 7,
"HST": -10,
"ICT": 7,
"IDLW": -12,
"IDT": 3,
"IOT": 6,
"IRDT": 4.5,
"IRKT": 8,
"IRST": 3.5,
"IST": 5.5,
"JST": 9,
"KALT": 2,
"KGT": 6,
"KOST": 11,
"KRAT": 7,
"KST": 9,
"LHST": 10.5,
"LINT": 14,
"MAGT": 12,
"MART": -9.5,
"MAWT": 5,
"MDT": -6,
"MEST": 2,
"MET": 1,
"MHT": 12,
"MIST": 11,
"MIT": -9.5,
"MMT": 6.5,
"MSK": 3,
"MST": -7,
"MUT": 4,
"MVT": 5,
"MYT": 8,
"NCT": 11,
"NDT": -2.5,
"NFT": 11,
"NOVT": 7,
"NPT": 5.75,
"NST": -3.5,
"NT": -3.5,
"NUT": -11,
"NZDT": 13,
"NZDST": 13,
"NZST": 12,
"OMST": 6,
"ORAT": 5,
"PDT": -7,
"PET": -5,
"PETT": 12,
"PGT": 10,
"PHOT": 13,
"PHST": 8,
"PHT": 8,
"PKT": 5,
"PMDT": -2,
"PMST": -3,
"PONT": 11,
"PST": -8,
"PWT": 9,
"PYST": -3,
"PYT": -4,
"RET": 4,
"ROTT": -3,
"SAKT": 11,
"SAMT": 4,
"SAST": 2,
"SBT": 11,
"SCT": 4,
"SDT": -10,
"SGT": 8,
"SLST": 5.5,
"SRET": 11,
"SRT": -3,
"SST": -11,
"SYOT": 3,
"TAHT": -10,
"TFT": 5,
"THA": 7,
"TJT": 5,
"TKT": 13,
"TLT": 9,
"TMT": 5,
"TOT": 13,
"TRT": 3,
"TST": 8,
"TVT": 12,
"ULAST": 9,
"ULAT": 8,
"UTC": 0,
"UYST": -2,
"UYT": -3,
"UZT": 5,
"VET": -4,
"VLAT": 10,
"VOLT": 3,
"VOST": 6,
"VUT": 11,
"WAKT": 12,
"WAST": 2,
"WAT": 1,
"WEST": 1,
"WET": 0,
"WGST": -2,
"WGT": -3,
"WIB": 7,
"WIT": 9,
"WITA": 8,
"WST": 8,
"YAKT": 9,
"YEKT": 5,
}
# Pattern for validating counter names.
_COUNTER_NAME_RE = re.compile(r"^[a-z0-9_]+$")
def _parse_placeholder_date(date_str: str) -> datetime | None:
"""Parse a placeholder date string.
Format: ``"Dec 25 2025 12:00:00 AM EST"``
:param date_str: Date string to parse.
:return: datetime in UTC, or ``None`` if parsing fails.
"""
# Split off the timezone abbreviation (last token).
parts = date_str.rsplit(maxsplit=1)
if len(parts) != 2:
return None
date_part, tz_abbrev = parts
offset = TIMEZONE_OFFSETS.get(tz_abbrev.upper())
if offset is None:
return None
try:
dt = datetime.strptime(date_part, "%b %d %Y %I:%M:%S %p")
except ValueError:
return None
# Convert to UTC by subtracting the offset.
return (dt - timedelta(hours=offset)).replace(tzinfo=UTC)
def _format_duration(seconds: int) -> str:
"""Format seconds as human-readable duration.
Example: ``"1 day 3 hours 20 minutes 30 seconds"``
:param seconds: Total seconds (positive).
:return: Human-readable duration string.
"""
days, remainder = divmod(seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, secs = divmod(remainder, 60)
parts = []
if days:
parts.append(f"{days} day{'s' if days != 1 else ''}")
if hours:
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
if minutes:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
if secs or not parts:
parts.append(f"{secs} second{'s' if secs != 1 else ''}")
return " ".join(parts)
async def _evaluate_arg(
name: str,
args: list[str],
ctx: PlaceholderContext,
) -> str:
"""``$(1)`` through ``$(9)`` -- return the positional argument or ``""``."""
if args:
raise PlaceholderError(f"Invalid $({name}): does not accept arguments")
index = int(name) - 1
if 0 <= index < len(ctx.args_list):
return ctx.args_list[index]
return ""
async def _evaluate_user(
name: str,
args: list[str],
ctx: PlaceholderContext,
) -> str:
"""``$(user)`` -- return the invoking user's display name."""
if args:
raise PlaceholderError("Invalid $(user): does not accept arguments")
return ctx.user_display_name
async def _evaluate_count(
name: str,
args: list[str],
ctx: PlaceholderContext,
) -> str:
"""``$(count)`` / ``$(count name [mod])`` -- use count or named counter."""
if not args:
# No arguments: return the command's use_count (backward compatible).
return str(ctx.use_count)
# Named counter.
counter_name = args[0].lower()
if not _COUNTER_NAME_RE.match(counter_name):
raise PlaceholderError(
"Invalid $(count): counter name may only contain "
"letters, numbers, and underscores"
)
modifier_str = args[1] if len(args) > 1 else "+1"
if len(args) > 2:
raise PlaceholderError(
"Invalid $(count): too many arguments, expected $(count name [modifier])"
)
if modifier_str[0] in ("+", "-"):
try:
delta = int(modifier_str)
except ValueError as e:
raise PlaceholderError(
"Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
) from e
row = await ctx.storage.fetch_one(
"INSERT INTO counters (name, value) VALUES (?, ?) "
"ON CONFLICT(name) DO UPDATE SET value = value + ? "
"RETURNING value",
(counter_name, delta, delta),
)
if row is None:
raise PlaceholderError("Internal error: counter update failed")
return str(row["value"])
else:
try:
value = int(modifier_str)
except ValueError as e:
raise PlaceholderError(
"Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
) from e
row = await ctx.storage.fetch_one(
"INSERT OR REPLACE INTO counters (name, value) VALUES (?, ?) "
"RETURNING value",
(counter_name, value),
)
if row is None:
raise PlaceholderError("Internal error: counter update failed")
return str(row["value"])
async def _evaluate_getcount(
name: str,
args: list[str],
ctx: PlaceholderContext,
) -> str:
"""``$(getcount name)`` -- read a named counter's value."""
if not args:
raise PlaceholderError("Invalid $(getcount): a counter name is required")
if len(args) > 1:
raise PlaceholderError(
"Invalid $(getcount): too many arguments, expected $(getcount name)"
)
counter_name = args[0].lower()
if not _COUNTER_NAME_RE.match(counter_name):
raise PlaceholderError(
"Invalid $(getcount): counter name may only contain "
"letters, numbers, and underscores"
)
row = await ctx.storage.fetch_one(
"SELECT value FROM counters WHERE name = ?", (counter_name,)
)
return str(row["value"]) if row else "0"
async def _evaluate_rand(
name: str,
args: list[str],
ctx: PlaceholderContext,
) -> str:
"""``$(rand start stop)`` -- random integer in range."""
if len(args) < 2:
raise PlaceholderError(
"Invalid $(rand): too few arguments, expected $(rand start stop)"
)
if len(args) > 2:
raise PlaceholderError(
"Invalid $(rand): too many arguments, expected $(rand start stop)"
)
try:
start = int(args[0])
stop = int(args[1])
except ValueError as e:
raise PlaceholderError(
"Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)"
) from e
return str(random.randint(min(start, stop), max(start, stop)))
async def _evaluate_countdown(
name: str,
args: list[str],
ctx: PlaceholderContext,
) -> str:
"""``$(countdown date)`` / ``$(countup date)`` -- time delta."""
date_str = " ".join(args)
if not date_str.strip():
raise PlaceholderError(
f"Invalid $({name}): missing date, expected "
f"$({name} Dec 25 2025 12:00:00 AM EST)"
)
target = _parse_placeholder_date(date_str)
if target is None:
raise PlaceholderError(
f"Invalid $({name}): unrecognized date format, "
f"expected $({name} Dec 25 2025 12:00:00 AM EST)"
)
now = datetime.now(UTC)
delta = (target - now) if name == "countdown" else (now - target)
seconds = int(delta.total_seconds())
if seconds > 0:
return _format_duration(seconds)
else:
return "0 seconds"
type PlaceholderHandler = Callable[
[str, list[str], "PlaceholderContext"], Awaitable[str]
]
HANDLERS: dict[str, PlaceholderHandler] = {}
for _i in range(1, 10):
HANDLERS[str(_i)] = _evaluate_arg
HANDLERS["user"] = _evaluate_user
HANDLERS["count"] = _evaluate_count
HANDLERS["getcount"] = _evaluate_getcount
HANDLERS["rand"] = _evaluate_rand
HANDLERS["countdown"] = _evaluate_countdown
HANDLERS["countup"] = _evaluate_countdown
@@ -0,0 +1,394 @@
# 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.
"""Recursive descent placeholder processing for custom commands.
This module implements an AST-based pipeline for parsing and evaluating
placeholders in custom command response templates. The pipeline has two
stages:
1. **Parser**: A recursive descent parser (``_parse``) converts a template
string into a list of ``Node`` objects (``TextNode`` for literal text,
``PlaceholderNode`` for ``$(...)`` expressions). Nesting is supported up to
a configurable maximum depth.
2. **Evaluator**: An async tree-walker (``_evaluate``) resolves the AST
inside-out: children of each ``PlaceholderNode`` are evaluated first, then
the resulting flat content string is dispatched to the matching handler in
``placeholder_handlers`` for final resolution.
Supported placeholders: ``$(1)``-``$(9)``, ``$(count)``, ``$(count name [mod])``,
``$(getcount name)``, ``$(user)``, ``$(rand start stop)``, ``$(countdown date)``,
``$(countup date)``.
Example AST::
Template: "$(rand $(1) $(2))"
Parsed: [PlaceholderNode("rand", [PlaceholderNode("1", []),
TextNode(" "),
PlaceholderNode("2", [])])]
"""
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from owlbot.api.storage import ModuleStorage
from .placeholder_handlers import HANDLERS, PlaceholderError
DEFAULT_MAX_DEPTH: int = 4
@dataclass
class TextNode:
"""A span of literal text that needs no further processing.
:param text: The literal text content.
"""
text: str
@dataclass
class PlaceholderNode:
"""A ``$(name ...)`` placeholder expression.
:param name: The placeholder name, extracted as literal text from the
template (e.g. ``"rand"``, ``"1"``, ``"user"``). Never contains
nested placeholders.
:param children: The parsed body content after the name. May contain
nested ``PlaceholderNode`` instances (for dynamic arguments) or be
empty for no-argument placeholders like ``$(user)``.
"""
name: str
children: list[Node]
type Node = TextNode | PlaceholderNode
@dataclass
class PlaceholderContext:
"""Bundles the runtime state needed to resolve placeholders.
:param args_list: Command arguments (``$(1)``-``$(9)`` values).
:param user_display_name: Display name of the invoking user.
:param use_count: The command's current use count.
:param storage: Module storage for database access.
"""
args_list: list[str]
user_display_name: str
use_count: int
storage: ModuleStorage
# The parser uses recursive descent to convert a template string into a list
# of Node objects. It handles escaping (``\$(...)``), nesting (``$(rand
# $(1) $(2))``), unclosed placeholders (degraded to literal text), and a
# configurable maximum nesting depth.
def _find_matching_close(template: str, pos: int) -> int:
"""Find the position of the ``)`` that closes an escaped ``\\$(...)`` group.
Tracks nested ``$(`` / ``)`` pairs so that escaped groups containing inner
placeholders (e.g. ``\\$(rand $(1) $(2))``) are consumed in their entirety.
:param template: The full template string.
:param pos: The position immediately after the opening ``$(`` of the
escaped group (i.e. the first character of the content).
:return: The index of the matching ``)``, or ``-1`` if not found.
"""
depth = 1
i = pos
length = len(template)
while i < length:
if template[i : i + 2] == "$(":
depth += 1
i += 2
elif template[i] == ")":
depth -= 1
if depth == 0:
return i
i += 1
else:
i += 1
return -1
def _parse_placeholder(
template: str,
pos: int,
depth: int,
max_depth: int,
) -> tuple[PlaceholderNode, int] | None:
"""Parse a single placeholder after the opening ``$(`` has been consumed.
Reads the placeholder name (word characters up to a space, ``)``, ``$``,
or end-of-string), then parses children if the name is followed by a space.
:param template: The full template string.
:param pos: Position immediately after ``$(`` (start of the name).
:param depth: Current nesting depth.
:param max_depth: Maximum allowed nesting depth.
:return: A ``(PlaceholderNode, new_pos)`` tuple, or ``None`` if the
placeholder is invalid (e.g. empty name).
"""
length = len(template)
# Read the placeholder name: word characters (\w) up to a delimiter.
name_start = pos
while pos < length and template[pos] not in (" ", ")", "$"):
if not (template[pos].isalnum() or template[pos] == "_"):
break
pos += 1
name = template[name_start:pos]
# Empty name (e.g. $() or $($(...))). Degrade to literal.
if not name:
return None
# No arguments: immediate close or end of string.
if pos >= length:
# Unclosed placeholder at end of string. Return None so the
# caller degrades "$(" to literal text; the name characters will
# be re-scanned as literals since the caller's pos only advances
# past "$(".
return None
if template[pos] == ")":
# $(name) -- no children.
return PlaceholderNode(name, []), pos + 1
if template[pos] == " ":
# $(name ... ) -- parse children after the space delimiter.
child_nodes, new_pos, found_close = _parse_nodes(
template,
pos + 1,
depth + 1,
inside_placeholder=True,
max_depth=max_depth,
)
if found_close:
return PlaceholderNode(name, child_nodes), new_pos
else:
# Unclosed placeholder, degrade to literal. Return None so
# the caller emits "$(" as literal and re-scans the rest.
return None
# The character after the name is something unexpected (e.g. another $).
# Treat as unclosed/invalid -- degrade.
if template[pos : pos + 2] == "$(":
# Something like $(name$(...)) with no space. Degrade.
return None
return None
def _parse_nodes(
template: str,
pos: int,
depth: int,
inside_placeholder: bool,
max_depth: int,
) -> tuple[list[Node], int, bool]:
"""Core recursive parser loop.
Scans *template* starting at *pos*, accumulating literal characters and
recognising ``$(...)`` placeholder openings.
:param template: The full template string.
:param pos: Current scan position.
:param depth: Current nesting depth (0 = top level).
:param inside_placeholder: ``True`` when parsing the children of a
``PlaceholderNode`` -- a bare ``)`` closes the current group.
:param max_depth: Maximum allowed nesting depth.
:return: A 3-tuple ``(nodes, new_pos, found_close)`` where *found_close*
is ``True`` if scanning stopped because a matching ``)`` was found.
"""
nodes: list[Node] = []
buf: list[str] = []
length = len(template)
def flush_buffer() -> None:
"""Flush accumulated literal characters as a TextNode."""
if buf:
nodes.append(TextNode("".join(buf)))
buf.clear()
while pos < length:
# Escaped placeholder: \$(...) becomes literal text.
if template[pos] == "\\" and template[pos + 1 : pos + 3] == "$(":
# Find the matching close paren, accounting for inner $( pairs.
close = _find_matching_close(template, pos + 3)
if close == -1:
# No matching close, treat everything from here as literal.
buf.append(template[pos:])
pos = length
else:
# Emit the content (without the leading backslash) as literal.
buf.append(template[pos + 1 : close + 1])
pos = close + 1
continue
# Placeholder opening: $(
if template[pos : pos + 2] == "$(":
# If we've hit the nesting limit, treat $( as literal text.
if depth >= max_depth:
buf.append("$(")
pos += 2
continue
flush_buffer()
# Delegate to _parse_placeholder for name extraction and children.
result = _parse_placeholder(template, pos + 2, depth, max_depth)
if result is None:
# Failed to parse a valid placeholder (empty name, etc.).
# Degrade the $( to literal text and continue scanning.
buf.append("$(")
pos += 2
else:
node, pos = result
nodes.append(node)
continue
# Closing paren while inside a placeholder's children.
if template[pos] == ")" and inside_placeholder:
flush_buffer()
return nodes, pos + 1, True
# Ordinary character: accumulate into the literal buffer.
buf.append(template[pos])
pos += 1
flush_buffer()
return nodes, pos, False
def _parse(template: str, max_depth: int = DEFAULT_MAX_DEPTH) -> list[Node]:
"""Parse a template string into an AST of ``Node`` objects.
This is the entry point for the parser stage.
:param template: The response template with placeholders.
:param max_depth: Maximum nesting depth for placeholders. ``$(`` tokens
encountered at or beyond this depth are treated as literal text.
:return: List of top-level nodes.
Example::
>>> _parse("Hello $(user)!")
[TextNode("Hello "), PlaceholderNode("user", []), TextNode("!")]
>>> _parse("$(rand $(1) $(2))")
[PlaceholderNode("rand", [PlaceholderNode("1", []),
TextNode(" "),
PlaceholderNode("2", [])])]
"""
nodes, _, _ = _parse_nodes(
template, 0, 0, inside_placeholder=False, max_depth=max_depth
)
return nodes
# Evaluation proceeds inside-out: for each PlaceholderNode the evaluator first
# recursively evaluates all children to produce a flat args string, then
# dispatches to the matching handler from HANDLERS.
async def _evaluate_placeholder(
node: PlaceholderNode,
ctx: PlaceholderContext,
) -> str:
"""Evaluate a single ``PlaceholderNode``.
Children are evaluated first (inside-out) and the resulting string is
split on whitespace to form the argument list. The handler for the
placeholder name is then looked up and called with those arguments.
:param node: The placeholder node to evaluate.
:param ctx: Runtime context for placeholder resolution.
:return: The resolved replacement string.
"""
name = node.name.lower()
if node.children:
args_str = await _evaluate(node.children, ctx)
args = args_str.split()
else:
args = []
handler = HANDLERS.get(name)
if handler is None:
content = name + (" " + " ".join(args) if args else "")
return f"$({content})"
return await handler(name, args, ctx)
async def _evaluate(nodes: list[Node], ctx: PlaceholderContext) -> str:
"""Walk the AST and produce the final output string.
:param nodes: List of parsed nodes from ``_parse``.
:param ctx: Runtime context for placeholder resolution.
:return: The fully resolved string.
"""
parts: list[str] = []
for node in nodes:
if isinstance(node, TextNode):
parts.append(node.text)
else:
parts.append(await _evaluate_placeholder(node, ctx))
return "".join(parts)
async def process_placeholders(
template: str,
args_list: list[str],
user_display_name: str,
use_count: int,
storage: ModuleStorage,
max_depth: int = DEFAULT_MAX_DEPTH,
) -> str:
"""Replace placeholders in a response template.
Parses the template into an AST, then evaluates it to produce the final
output string with all placeholders resolved.
If any placeholder raises :exc:`PlaceholderError`, evaluation stops
immediately and the error message is returned as the entire response.
:param template: The response template with placeholders.
:param args_list: List of arguments passed to the command.
:param user_display_name: The executing user's display name.
:param use_count: The command's current use count.
:param storage: Module storage for database access.
:param max_depth: Maximum nesting depth for placeholders.
:return: The processed response string, or the error message on failure.
"""
nodes = _parse(template, max_depth=max_depth)
ctx = PlaceholderContext(
args_list=args_list,
user_display_name=user_display_name,
use_count=use_count,
storage=storage,
)
try:
return await _evaluate(nodes, ctx)
except PlaceholderError as e:
return str(e)
@@ -0,0 +1,70 @@
# 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.
"""Web routes for the custom commands module."""
from pathlib import Path
import jinja2
from aiohttp import web
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"])
async def command_list_page(ctx: RouteContext) -> web.Response:
"""
Serve an HTML page listing all custom commands in a table.
Columns: Command, Aliases, Response, Cooldown, Permissions.
Accessible at /owlbot/custom_commands/list.
:param ctx: The route context.
:return: HTML response with the command list table.
"""
rows = await ctx.storage.fetch_all(
"SELECT c.name, c.response, c.requires_moderator, c.cooldown, "
"GROUP_CONCAT(ca.alias, ', ') AS aliases "
"FROM commands c "
"LEFT JOIN command_aliases ca ON c.name = ca.command_name "
"GROUP BY c.name "
"ORDER BY c.name"
)
prefix = ctx.commands.prefix
commands = [
{
"name": row["name"],
"aliases": (
", ".join(f"{prefix}{a}" for a in row["aliases"].split(", "))
if row["aliases"]
else "None"
),
"response": row["response"],
"permissions": "Moderator" if row["requires_moderator"] else "Everyone",
"cooldown": "None" if row["cooldown"] == 0 else f"{row['cooldown']}s",
}
for row in rows
]
template = _jinja_env.get_template("list.html")
page = template.render(commands=commands, prefix=ctx.commands.prefix)
return web.Response(text=page, content_type="text/html")
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<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 %}
<table>
<thead><tr>
<th>Command</th>
<th>Aliases</th>
<th>Response</th>
<th>Cooldown</th>
<th>Permissions</th>
</tr></thead>
<tbody>
{% for cmd in commands %}
<tr>
<td>{{ prefix }}{{ cmd.name }}</td>
<td>{{ cmd.aliases }}</td>
<td>{{ cmd.response }}</td>
<td>{{ cmd.cooldown }}</td>
<td>{{ cmd.permissions }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No custom commands defined.</p>
{% endif %}
</body>
</html>
+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.
"""Quotes module for Owlbot.
Allows moderators to store quotes and anyone to recall random quotes.
"""
from datetime import UTC, datetime
from pathlib import Path
import jinja2
from aiohttp import web
from owlbot.api import (
CommandContext,
ModuleContext,
RouteContext,
on_command,
on_route,
on_setup,
)
_template_dir = Path(__file__).resolve().parent / "templates"
_jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(_template_dir),
autoescape=True,
)
@on_setup
async def setup(ctx: ModuleContext) -> None:
"""
Initialize the quotes module.
Creates the database schema.
:param ctx: Module context with config, storage, and other services.
"""
await ctx.storage.execute("""
CREATE TABLE IF NOT EXISTS quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
added_by TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
count = await ctx.storage.fetch_value("SELECT COUNT(*) FROM quotes")
ctx.logger.info(f"Loaded {count} quote(s) from database.")
@on_route("/list", methods=["GET"])
async def quotes_list_page(ctx: RouteContext) -> web.Response:
"""
Serve an HTML page listing all quotes in a table.
Columns: #, Quote, Added By, Date Added.
Accessible at /owlbot/quotes/list.
:param ctx: The route context.
:return: HTML response with the quotes list table.
"""
rows = await ctx.storage.fetch_all(
"SELECT id, text, added_by, created_at FROM quotes ORDER BY id"
)
quotes = [
{
"id": row["id"],
"text": row["text"],
"added_by": row["added_by"],
"date": datetime.fromisoformat(row["created_at"]).strftime("%Y-%m-%d"),
}
for row in rows
]
template = _jinja_env.get_template("list.html")
page = template.render(quotes=quotes)
return web.Response(text=page, content_type="text/html")
@on_command("quote", aliases=["q"])
async def quote_command(ctx: CommandContext) -> None:
"""
Display a quote. Random if no argument, specific if an ID is given.
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
row = await ctx.storage.fetch_one(
"SELECT id, text FROM quotes ORDER BY RANDOM() LIMIT 1"
)
if not row:
await ctx.owncast_client.send_message("No quotes have been added yet.")
return
await ctx.owncast_client.send_message(f'"{row["text"]}" (#{row["id"]})')
return
try:
quote_id = int(args[0])
except ValueError:
await ctx.owncast_client.send_message("Usage: !quote [id]")
return
row = await ctx.storage.fetch_one(
"SELECT id, text, added_by, created_at FROM quotes WHERE id = ?", (quote_id,)
)
if not row:
await ctx.owncast_client.send_message(f"Quote #{quote_id} not found.")
return
created_at = datetime.fromisoformat(row["created_at"])
date_str = created_at.strftime("%Y-%m-%d")
await ctx.owncast_client.send_message(
f'Quote #{row["id"]}: "{row["text"]}" '
f"- Added by {row['added_by']} on {date_str}"
)
@on_command("addquote", requires_moderator=True)
async def addquote_command(ctx: CommandContext) -> None:
"""
Add a new quote to the database. Moderator only.
:param ctx: The command context.
"""
quote_text = ctx.args.strip()
if not quote_text:
await ctx.owncast_client.send_message("Usage: !addquote <quote text>")
return
now = datetime.now(UTC).isoformat()
cursor = await ctx.storage.execute(
"INSERT INTO quotes (text, added_by, created_at) VALUES (?, ?, ?)",
(quote_text, ctx.user.display_name, now),
)
ctx.logger.info(f"Quote #{cursor.lastrowid} added by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Quote #{cursor.lastrowid} added.")
@on_command("deletequote", aliases=["delquote"], requires_moderator=True)
async def deletequote_command(ctx: CommandContext) -> None:
"""
Delete a quote by ID. Moderator only.
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message("Usage: !deletequote <id>")
return
try:
quote_id = int(args[0])
except ValueError:
await ctx.owncast_client.send_message("Usage: !deletequote <id>")
return
existing = await ctx.storage.fetch_one(
"SELECT id FROM quotes WHERE id = ?", (quote_id,)
)
if not existing:
await ctx.owncast_client.send_message(f"Quote #{quote_id} not found.")
return
await ctx.storage.execute("DELETE FROM quotes WHERE id = ?", (quote_id,))
ctx.logger.info(f"Quote #{quote_id} deleted by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Quote #{quote_id} deleted.")
@on_command("listquotes", cooldown=15)
async def listquotes_command(ctx: CommandContext) -> None:
"""
Send the URL to the quotes list web page.
:param ctx: The command context.
"""
url = ctx.routes.url_for("/list")
await ctx.owncast_client.send_message(f"Quotes: {url}")
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<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 %}
<table>
<thead><tr>
<th>#</th>
<th>Quote</th>
<th>Added By</th>
<th>Date Added</th>
</tr></thead>
<tbody>
{% for quote in quotes %}
<tr>
<td>{{ quote.id }}</td>
<td>{{ quote.text }}</td>
<td>{{ quote.added_by }}</td>
<td>{{ quote.date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No quotes have been added yet.</p>
{% endif %}
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
# 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.
"""Timers module for Owlbot.
Allows moderators to create recurring chat messages that fire on a configurable
interval with an optional minimum chat line threshold. Supports both simple
duration intervals and cron expressions.
"""
from owlbot.api import ModuleContext, on_setup, on_teardown
from .chat_counter import count_chat_message, handle_visibility_update
# Re-export decorated handlers so the module loader discovers them.
from .commands import (
addtimer,
deletetimer,
disabletimer,
enabletimer,
listtimers,
settimerinterval,
settimerlines,
settimermessage,
)
from .routes import timer_list_page
from .scheduler import TimerScheduler, clear_scheduler, get_scheduler, set_scheduler
__all__ = [
"addtimer",
"count_chat_message",
"deletetimer",
"disabletimer",
"enabletimer",
"handle_visibility_update",
"listtimers",
"settimerinterval",
"settimerlines",
"settimermessage",
"setup",
"teardown",
"timer_list_page",
]
@on_setup
async def setup(ctx: ModuleContext) -> None:
"""
Initialize the timers module.
Creates the database schema, initializes chat counters for enabled timers,
and starts the background scheduler.
:param ctx: Module context with config, storage, and other services.
"""
await ctx.storage.execute("""
CREATE TABLE IF NOT EXISTS timers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
message TEXT,
interval_value TEXT NOT NULL DEFAULT '15m',
interval_type TEXT NOT NULL DEFAULT 'simple',
min_chat_lines INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_fired_at TEXT
)
""")
rows = await ctx.storage.fetch_all(
"SELECT id FROM timers WHERE enabled = 1 AND message IS NOT NULL"
)
timer_ids = [row["id"] for row in rows]
sched = TimerScheduler()
sched.init_counted_ids(timer_ids)
ctx.logger.debug("Initialized chat counters for timer IDs: %s", timer_ids)
count = await ctx.storage.fetch_value("SELECT COUNT(*) FROM timers")
ctx.logger.info(f"Loaded {count} timer(s) from database.")
sched.start(ctx)
set_scheduler(sched)
@on_teardown
async def teardown(ctx: ModuleContext) -> None:
"""
Clean up the timers module.
Stops the background scheduler task.
:param ctx: Module context.
"""
await get_scheduler().stop(ctx)
clear_scheduler()
@@ -0,0 +1,85 @@
# 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.
"""Chat message counting for the timers module.
Tracks chat messages per timer to support the minimum chat lines threshold.
Bot messages and hidden messages are excluded from the count.
"""
from owlbot.api import (
ChatEvent,
EventContext,
EventType,
Priority,
VisibilityUpdateEvent,
on_event,
)
from .scheduler import get_scheduler
@on_event(EventType.CHAT, priority=Priority.LOWEST)
async def count_chat_message(ctx: EventContext[ChatEvent]) -> None:
"""
Count a chat message for all tracked timers.
Runs at lowest priority so all other CHAT handlers (moderation, etc.)
execute first. Skips bot messages and hidden messages.
:param ctx: The event context with the chat event.
"""
event = ctx.event
if event.user.is_bot or not event.is_visible:
reason = "bot message" if event.user.is_bot else "hidden message"
ctx.logger.debug("Skipping chat count for %s: %s.", event.message_id, reason)
return
counted_ids = get_scheduler().counted_ids
ctx.logger.debug(
"Counting message %s from %s for %d timer(s).",
event.message_id,
event.user.display_name,
len(counted_ids),
)
for timer_set in counted_ids.values():
timer_set.add(event.message_id)
@on_event(EventType.VISIBILITY_UPDATE, priority=Priority.LOWEST)
async def handle_visibility_update(ctx: EventContext[VisibilityUpdateEvent]) -> None:
"""
Remove hidden messages from chat counts.
Only handles the hide case. Un-hiding does not re-add messages because
we cannot distinguish previously counted user messages from bot messages
that were never counted.
:param ctx: The event context with the visibility update event.
"""
event = ctx.event
if event.is_visible:
return
affected = set(event.message_ids)
counted_ids = get_scheduler().counted_ids
ctx.logger.debug(
"Removing %d hidden message(s) from chat counts.",
len(affected),
)
for timer_set in counted_ids.values():
timer_set -= affected
+391
View File
@@ -0,0 +1,391 @@
# 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.
"""Moderator commands for managing timers."""
import re
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from owlbot.api import CommandContext, on_command
if TYPE_CHECKING:
import aiosqlite
from .scheduler import get_scheduler, parse_interval
# Timer names: must start with a letter or underscore to avoid ambiguity with
# numeric timer IDs. Letters, numbers, underscores, max 32 chars.
_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,31}$")
async def _resolve_timer(ctx: CommandContext, identifier: str) -> aiosqlite.Row | None:
"""
Resolve a timer by numeric ID or name.
Tries parsing as an integer first, then falls back to a name lookup.
:param ctx: The command context.
:param identifier: A timer ID or name string.
:return: The timer row, or None if not found.
"""
try:
timer_id = int(identifier)
ctx.logger.debug("Resolving timer by ID: %d", timer_id)
row = await ctx.storage.fetch_one(
"SELECT * FROM timers WHERE id = ?", (timer_id,)
)
if row:
ctx.logger.debug("Found timer by ID: %s", _timer_display(row))
return row
except ValueError:
pass
ctx.logger.debug("Resolving timer by name: %s", identifier)
row = await ctx.storage.fetch_one(
"SELECT * FROM timers WHERE name = ?", (identifier.lower(),)
)
if row:
ctx.logger.debug("Found timer by name: %s", _timer_display(row))
else:
ctx.logger.debug("Timer '%s' not found.", identifier)
return row
def _timer_display(row: aiosqlite.Row) -> str:
"""
Format a timer's display identifier.
:param row: Timer database row.
:return: Display string like "timer_name (#3)" or "timer #3".
"""
if row["name"]:
return f"{row['name']} (#{row['id']})"
return f"timer #{row['id']}"
@on_command("addtimer", requires_moderator=True)
async def addtimer(ctx: CommandContext) -> None:
"""
Create a new empty timer with an optional name.
Usage: !addtimer [name]
:param ctx: The command context.
"""
args = ctx.args_list
name = None
if args:
name = args[0].lower()
if not _NAME_PATTERN.match(name):
await ctx.owncast_client.send_message(
"Invalid timer name. Must start with a letter or underscore, "
"followed by letters, numbers, or underscores (max 32 characters)."
)
return
existing = await ctx.storage.fetch_one(
"SELECT id FROM timers WHERE name = ?", (name,)
)
if existing:
await ctx.owncast_client.send_message(
f"A timer named '{name}' already exists (#{existing['id']})."
)
return
now = datetime.now(UTC).isoformat()
cursor = await ctx.storage.execute(
"INSERT INTO timers (name, message, interval_value, interval_type, "
"min_chat_lines, enabled, created_at, updated_at) "
"VALUES (?, NULL, '15m', 'simple', 0, 0, ?, ?)",
(name, now, now),
)
timer_id = cursor.lastrowid
display = f"{name} (#{timer_id})" if name else f"#{timer_id}"
ctx.logger.info(f"Timer {display} created by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Timer {display} created.")
@on_command("settimermessage", requires_moderator=True)
async def settimermessage(ctx: CommandContext) -> None:
"""
Set the message text for a timer.
Usage: !settimermessage <id|name> <message>
:param ctx: The command context.
"""
parts = ctx.args.split(maxsplit=1)
if len(parts) < 2:
await ctx.owncast_client.send_message(
"Usage: !settimermessage <id|name> <message>"
)
return
identifier, message = parts
if not message.strip():
await ctx.owncast_client.send_message(
"Usage: !settimermessage <id|name> <message>"
)
return
row = await _resolve_timer(ctx, identifier)
if not row:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
return
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"UPDATE timers SET message = ?, updated_at = ? WHERE id = ?",
(message, now, row["id"]),
)
display = _timer_display(row)
ctx.logger.info(f"Timer {display} message updated by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Message set for {display}.")
if row["enabled"]:
get_scheduler().reschedule()
@on_command("settimerinterval", requires_moderator=True)
async def settimerinterval(ctx: CommandContext) -> None:
"""
Set the interval for a timer.
Accepts simple durations (15m, 1h30m) or cron expressions (*/15 * * * *).
Usage: !settimerinterval <id|name> <interval>
:param ctx: The command context.
"""
parts = ctx.args.split(maxsplit=1)
if len(parts) < 2:
await ctx.owncast_client.send_message(
"Usage: !settimerinterval <id|name> <interval>"
)
return
identifier, interval_str = parts
row = await _resolve_timer(ctx, identifier)
if not row:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
return
try:
interval_type, normalized = parse_interval(interval_str)
except ValueError as e:
await ctx.owncast_client.send_message(str(e))
return
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"UPDATE timers SET interval_type = ?, interval_value = ?, updated_at = ? "
"WHERE id = ?",
(interval_type, normalized, now, row["id"]),
)
display = _timer_display(row)
ctx.logger.info(
f"Timer {display} interval set to {normalized} by {ctx.user.display_name}."
)
await ctx.owncast_client.send_message(
f"Interval for {display} set to {normalized} ({interval_type})."
)
if row["enabled"]:
get_scheduler().reschedule()
@on_command("settimerlines", requires_moderator=True)
async def settimerlines(ctx: CommandContext) -> None:
"""
Set the minimum chat lines between timer firings.
Usage: !settimerlines <id|name> <count>
:param ctx: The command context.
"""
args = ctx.args_list
if len(args) < 2:
await ctx.owncast_client.send_message("Usage: !settimerlines <id|name> <count>")
return
identifier = args[0]
row = await _resolve_timer(ctx, identifier)
if not row:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
return
try:
count = int(args[1])
except ValueError:
await ctx.owncast_client.send_message("Line count must be a number.")
return
if count < 0:
await ctx.owncast_client.send_message("Line count cannot be negative.")
return
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"UPDATE timers SET min_chat_lines = ?, updated_at = ? WHERE id = ?",
(count, now, row["id"]),
)
display = _timer_display(row)
label = f"{count} line(s)" if count > 0 else "disabled"
ctx.logger.info(
f"Timer {display} min chat lines set to {count} by {ctx.user.display_name}."
)
await ctx.owncast_client.send_message(
f"Minimum chat lines for {display} set to {label}."
)
if row["enabled"]:
get_scheduler().reschedule()
@on_command("enabletimer", requires_moderator=True)
async def enabletimer(ctx: CommandContext) -> None:
"""
Enable a timer.
Won't enable a timer that has no message set.
Usage: !enabletimer <id|name>
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message("Usage: !enabletimer <id|name>")
return
identifier = args[0]
row = await _resolve_timer(ctx, identifier)
if not row:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
return
display = _timer_display(row)
if row["enabled"]:
await ctx.owncast_client.send_message(f"Timer {display} is already enabled.")
return
if not row["message"]:
await ctx.owncast_client.send_message(
f"Cannot enable {display}: no message set. "
f"Use !settimermessage to set one first."
)
return
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"UPDATE timers SET enabled = 1, updated_at = ? WHERE id = ?",
(now, row["id"]),
)
# Start tracking chat lines for this timer.
get_scheduler().counted_ids[row["id"]] = set()
ctx.logger.debug("Started chat line tracking for timer %s.", display)
ctx.logger.info(f"Timer {display} enabled by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Timer {display} enabled.")
get_scheduler().reschedule()
@on_command("disabletimer", requires_moderator=True)
async def disabletimer(ctx: CommandContext) -> None:
"""
Disable a timer.
Usage: !disabletimer <id|name>
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message("Usage: !disabletimer <id|name>")
return
identifier = args[0]
row = await _resolve_timer(ctx, identifier)
if not row:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
return
display = _timer_display(row)
if not row["enabled"]:
await ctx.owncast_client.send_message(f"Timer {display} is already disabled.")
return
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"UPDATE timers SET enabled = 0, updated_at = ? WHERE id = ?",
(now, row["id"]),
)
# Stop tracking chat lines for this timer.
get_scheduler().counted_ids.pop(row["id"], None)
ctx.logger.debug("Stopped chat line tracking for timer %s.", display)
ctx.logger.info(f"Timer {display} disabled by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Timer {display} disabled.")
get_scheduler().reschedule()
@on_command("deletetimer", requires_moderator=True)
async def deletetimer(ctx: CommandContext) -> None:
"""
Permanently delete a timer.
Usage: !deletetimer <id|name>
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message("Usage: !deletetimer <id|name>")
return
identifier = args[0]
row = await _resolve_timer(ctx, identifier)
if not row:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
return
display = _timer_display(row)
await ctx.storage.execute("DELETE FROM timers WHERE id = ?", (row["id"],))
# Stop tracking chat lines.
get_scheduler().counted_ids.pop(row["id"], None)
ctx.logger.info(f"Timer {display} deleted by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Timer {display} deleted.")
get_scheduler().reschedule()
@on_command("listtimers", requires_moderator=True, cooldown=15)
async def listtimers(ctx: CommandContext) -> None:
"""
Send the URL to the timer list web page.
Usage: !listtimers
:param ctx: The command context.
"""
url = ctx.routes.url_for("/list")
await ctx.owncast_client.send_message(f"Timers: {url}")
+74
View File
@@ -0,0 +1,74 @@
# 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.
"""Web routes for the timers module."""
from datetime import datetime
from pathlib import Path
import jinja2
from aiohttp import web
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"])
async def timer_list_page(ctx: RouteContext) -> web.Response:
"""
Serve an HTML page listing all timers in a table.
Columns: #, Name, Message, Interval, Min Lines, Status, Last Fired.
Accessible at /owlbot/timers/list.
:param ctx: The route context.
:return: HTML response with the timer list table.
"""
rows = await ctx.storage.fetch_all(
"SELECT id, name, message, interval_type, interval_value, "
"min_chat_lines, enabled, last_fired_at "
"FROM timers ORDER BY id"
)
timers = []
for row in rows:
last_fired = row["last_fired_at"]
if last_fired:
last_fired = datetime.fromisoformat(last_fired).strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
else:
last_fired = "Never"
timers.append(
{
"id": row["id"],
"name": row["name"] or "",
"message": row["message"] or "(not set)",
"interval": f"{row['interval_value']} ({row['interval_type']})",
"min_lines": row["min_chat_lines"],
"status": "Enabled" if row["enabled"] else "Disabled",
"last_fired": last_fired,
}
)
template = _jinja_env.get_template("list.html")
page = template.render(timers=timers)
return web.Response(text=page, content_type="text/html")
+419
View File
@@ -0,0 +1,419 @@
# 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.
"""Background scheduler and interval parsing for the timers module.
Manages the scheduler task that sleeps until the next timer is due and fires
messages at the right moment. Also provides interval parsing utilities and
shared state for chat line counting.
"""
import asyncio
import contextlib
import re
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from typing import TYPE_CHECKING
from cronsim import CronSim, CronSimError
if TYPE_CHECKING:
import aiosqlite
from owlbot.api import ModuleContext
class IntervalType(StrEnum):
"""Supported interval types for timer scheduling."""
SIMPLE = "simple"
CRON = "cron"
# Regex for simple duration strings like "30s", "5m", "1h", "2h30m".
_DURATION_PATTERN = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$", re.IGNORECASE)
_MIN_SIMPLE_SECONDS = 30
_MIN_CRON_MINUTES = 1
def parse_interval(value: str) -> tuple[IntervalType, str]:
"""
Parse and validate an interval string.
Simple durations (no spaces) are parsed with a regex. Cron expressions
(contain spaces) are validated with cronsim. Returns the detected type
and the normalized value.
:param value: Raw interval string from the user.
:return: Tuple of (interval_type, normalized_value).
:raises ValueError: If the value is not a valid interval.
"""
value = value.strip()
if not value:
raise ValueError("Interval cannot be empty.")
if " " in value:
return _parse_cron(value)
return _parse_simple(value)
def _parse_simple(value: str) -> tuple[IntervalType, str]:
"""
Parse and validate a simple duration string.
:param value: Duration string like "30s", "5m", "1h30m".
:return: Tuple of (IntervalType.SIMPLE, value).
:raises ValueError: If the format is invalid or below minimum.
"""
match = _DURATION_PATTERN.match(value)
if not match or not any(match.groups()):
raise ValueError(
f"Invalid duration: {value}. Use a format like 30s, 5m, 1h, or 2h30m."
)
hours = int(match.group(1) or 0)
minutes = int(match.group(2) or 0)
seconds = int(match.group(3) or 0)
total = hours * 3600 + minutes * 60 + seconds
if total < _MIN_SIMPLE_SECONDS:
raise ValueError(
f"Minimum interval is {_MIN_SIMPLE_SECONDS} seconds. Got {total}s."
)
return (IntervalType.SIMPLE, value.lower())
def _parse_cron(value: str) -> tuple[IntervalType, str]:
"""
Validate a cron expression.
:param value: Cron expression string (5 fields).
:return: Tuple of (IntervalType.CRON, value).
:raises ValueError: If the expression is invalid or fires too frequently.
"""
try:
now = datetime.now(UTC)
it = CronSim(value, now)
first = next(it)
second = next(it)
except CronSimError as e:
raise ValueError(f"Invalid cron expression: {value}") from e
gap = (second - first).total_seconds()
if gap < _MIN_CRON_MINUTES * 60:
raise ValueError(
f"Cron interval too frequent. Minimum gap is {_MIN_CRON_MINUTES} minute(s)."
)
return (IntervalType.CRON, value)
def _duration_to_seconds(value: str) -> int:
"""
Convert a validated simple duration string to total seconds.
:param value: A previously validated duration string.
:return: Total seconds.
"""
match = _DURATION_PATTERN.match(value)
if not match:
return 0
hours = int(match.group(1) or 0)
minutes = int(match.group(2) or 0)
seconds = int(match.group(3) or 0)
return hours * 3600 + minutes * 60 + seconds
def _next_fire_time(row: aiosqlite.Row, now: datetime) -> datetime | None:
"""
Compute when a timer will next be time-due.
:param row: Database row with interval_type, interval_value, last_fired_at.
:param now: Current UTC time.
:return: The datetime when this timer is next due, or None if it can't fire.
"""
last_fired = row["last_fired_at"]
last_fired_dt = datetime.fromisoformat(last_fired) if last_fired else None
if row["interval_type"] == IntervalType.SIMPLE:
interval_secs = _duration_to_seconds(row["interval_value"])
if interval_secs <= 0:
return None
anchor = last_fired_dt if last_fired_dt is not None else now
return anchor + timedelta(seconds=interval_secs)
# Cron timer: wait for the next scheduled tick.
try:
anchor = last_fired_dt if last_fired_dt is not None else now
return next(CronSim(row["interval_value"], anchor))
except ValueError, KeyError, CronSimError:
return None
def _is_timer_due(row: aiosqlite.Row, now: datetime) -> bool:
"""
Check whether a timer should fire based on its interval and last fire time.
:param row: Database row with interval_type, interval_value, last_fired_at.
:param now: Current UTC time.
:return: True if the timer is due to fire.
"""
fire_time = _next_fire_time(row, now)
if fire_time is None:
return False
return fire_time <= now
class TimerScheduler:
"""Encapsulates the mutable runtime state for the timer scheduler.
Holds the background task, wake event, and per-timer chat line counters.
"""
def __init__(self) -> None:
self._task: asyncio.Task[None] | None = None
self._counted_ids: dict[int, set[str]] = {}
self._wake_event: asyncio.Event | None = None
@property
def counted_ids(self) -> dict[int, set[str]]:
"""Per-timer sets of counted message IDs."""
return self._counted_ids
def init_counted_ids(self, timer_ids: list[int]) -> None:
"""
Initialize empty counter sets for the given timer IDs.
Called during module setup to prepare tracking for enabled timers.
:param timer_ids: List of enabled timer IDs to track.
"""
self._counted_ids = {tid: set() for tid in timer_ids}
def start(self, ctx: ModuleContext) -> None:
"""
Start the background scheduler task.
:param ctx: The module context.
"""
if self._task is not None:
return
self._wake_event = asyncio.Event()
self._task = asyncio.create_task(self._scheduler_loop(ctx, self._wake_event))
ctx.logger.debug("Timer scheduler started.")
async def stop(self, ctx: ModuleContext) -> None:
"""
Cancel the background scheduler task and wait for it to exit.
:param ctx: The module context.
"""
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
self._wake_event = None
ctx.logger.info("Timer scheduler stopped.")
def reschedule(self) -> None:
"""
Wake the scheduler so it recalculates the next fire time.
Called by timer management commands when timer state changes.
"""
if self._wake_event is not None:
self._wake_event.set()
async def _scheduler_loop(self, ctx: ModuleContext, wake: asyncio.Event) -> None:
"""
Background loop that sleeps until the next timer is due and fires it.
Uses an asyncio.Event to allow early wake-ups when timer state changes.
:param ctx: The module context.
:param wake: Event used to signal early wake-ups.
"""
ctx.logger.debug("Scheduler loop running with precise sleep.")
while True:
delay, display = await _compute_next_delay(ctx)
wake.clear()
try:
if delay is not None:
ctx.logger.debug(
"Next timer due in %.1fs (timer %s). Sleeping until then.",
delay,
display,
)
await asyncio.wait_for(wake.wait(), timeout=delay)
ctx.logger.debug(
"Woken early by reschedule event. "
"Recalculating next fire time.",
)
else:
ctx.logger.debug(
"No timers scheduled. Sleeping until woken by a timer change.",
)
await wake.wait()
ctx.logger.debug(
"Woken early by reschedule event. "
"Recalculating next fire time.",
)
except TimeoutError:
ctx.logger.debug("Sleep finished. Checking for due timers.")
try:
await self._tick(ctx)
except Exception:
ctx.logger.exception("Scheduler tick failed.")
async def _tick(self, ctx: ModuleContext) -> None:
"""
Single scheduler tick: query enabled timers and fire any that are due.
:param ctx: The module context.
"""
rows = await ctx.storage.fetch_all(
"SELECT id, name, message, interval_type, interval_value, "
"min_chat_lines, last_fired_at "
"FROM timers WHERE enabled = 1 AND message IS NOT NULL"
)
now = datetime.now(UTC)
ctx.logger.debug("Tick: %d enabled timer(s) to check.", len(rows))
for row in rows:
timer_id = row["id"]
display = row["name"] or f"#{timer_id}"
if not _is_timer_due(row, now):
ctx.logger.debug("Timer %s is not due yet.", display)
continue
min_lines = row["min_chat_lines"]
if min_lines > 0:
counted = len(self._counted_ids.get(timer_id, set()))
if counted < min_lines:
now_iso = now.isoformat()
await ctx.storage.execute(
"UPDATE timers SET last_fired_at = ? WHERE id = ?",
(now_iso, timer_id),
)
ctx.logger.info(
"Timer %s skipped: chat line threshold not met (%d/%d). "
"Schedule advanced.",
display,
counted,
min_lines,
)
continue
# Fire the timer. Wrapped in try/except so a single failing timer
# (e.g. network error) does not prevent other timers from firing.
try:
ctx.logger.debug("Firing timer %s.", display)
await ctx.owncast_client.send_message(row["message"])
now_iso = now.isoformat()
await ctx.storage.execute(
"UPDATE timers SET last_fired_at = ? WHERE id = ?",
(now_iso, timer_id),
)
self._counted_ids[timer_id] = set()
ctx.logger.info(f"Timer {display} fired.")
except Exception:
ctx.logger.exception("Failed to fire timer %s.", display)
async def _compute_next_delay(ctx: ModuleContext) -> tuple[float | None, str | None]:
"""
Query enabled timers and return seconds until the soonest one is time-due.
:param ctx: The module context.
:return: Tuple of (seconds until next timer, display name of that timer),
or (None, None) if no timers are scheduled.
"""
rows = await ctx.storage.fetch_all(
"SELECT id, name, interval_type, interval_value, last_fired_at "
"FROM timers WHERE enabled = 1 AND message IS NOT NULL"
)
now = datetime.now(UTC)
soonest_delay: float | None = None
soonest_display: str | None = None
for row in rows:
timer_id = row["id"]
display = row["name"] or f"#{timer_id}"
fire_time = _next_fire_time(row, now)
if fire_time is None:
continue
delay = (fire_time - now).total_seconds()
ctx.logger.debug(
"Timer %s next due at %s (in %.1fs).",
display,
fire_time,
delay,
)
if soonest_delay is None or delay < soonest_delay:
soonest_delay = delay
soonest_display = display
if soonest_delay is not None:
soonest_delay = max(0.0, soonest_delay)
ctx.logger.debug(
"Soonest timer is %s in %.1fs.",
soonest_display,
soonest_delay,
)
return soonest_delay, soonest_display
# Module-level singleton for the scheduler instance.
_scheduler: TimerScheduler | None = None
def get_scheduler() -> TimerScheduler:
"""
Return the active scheduler instance.
:return: The active TimerScheduler.
:raises RuntimeError: If the scheduler has not been initialized.
"""
if _scheduler is None:
raise RuntimeError("TimerScheduler is not initialized.")
return _scheduler
def set_scheduler(scheduler: TimerScheduler) -> None:
"""
Set the active scheduler instance.
:param scheduler: The TimerScheduler to install.
"""
global _scheduler
_scheduler = scheduler
def clear_scheduler() -> None:
"""Clear the active scheduler instance."""
global _scheduler
_scheduler = None
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<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 %}
<table>
<thead><tr>
<th>#</th>
<th>Name</th>
<th>Message</th>
<th>Interval</th>
<th>Min Lines</th>
<th>Status</th>
<th>Last Fired</th>
</tr></thead>
<tbody>
{% for timer in timers %}
<tr>
<td>{{ timer.id }}</td>
<td>{{ timer.name }}</td>
<td>{{ timer.message }}</td>
<td>{{ timer.interval }}</td>
<td>{{ timer.min_lines }}</td>
<td>{{ timer.status }}</td>
<td>{{ timer.last_fired }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No timers have been created yet.</p>
{% endif %}
</body>
</html>
+167
View File
@@ -0,0 +1,167 @@
# 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 server for Owlbot."""
import asyncio
import logging
from collections.abc import Awaitable, Callable, Coroutine
from typing import TYPE_CHECKING, Any
from aiohttp import web
from . import __version__
from .api.event_types import Event, EventType, parse_event
if TYPE_CHECKING:
from .api.config import Config
from .registries.routes import RouteDispatcher
logger = logging.getLogger("owlbot.web")
@web.middleware
async def _server_header_middleware(
request: web.Request,
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
) -> web.StreamResponse:
response = await handler(request)
response.headers["Server"] = f"Owlbot/{__version__}"
return response
# Callback type for webhook dispatch (injected from Owlbot).
type WebhookCallback = Callable[[EventType, Event], Coroutine[Any, Any, None]]
class HttpServer:
"""
HTTP server for Owlbot.
Manages the aiohttp Application, built-in routes (webhook),
and module-registered routes via the RouteDispatcher.
"""
def __init__(
self,
config: Config,
event_dispatch: WebhookCallback,
route_dispatcher: RouteDispatcher,
):
"""
Initialize the web server.
:param config: Bot configuration.
:param event_dispatch: Async callback to dispatch webhook events.
:param route_dispatcher: Dispatcher for module HTTP routes.
"""
self.config = config
self._event_dispatch = event_dispatch
self._route_dispatcher = route_dispatcher
# Pending webhook dispatch tasks (tracked so drain() can await them).
self._pending_tasks: set[asyncio.Task[None]] = set()
# Runner is created by start() and cleaned up by stop().
self._runner: web.AppRunner | None = None
self.app = web.Application(middlewares=[_server_header_middleware])
self.app.router.add_post(self.config.webhook_path, self._handle_webhook)
# Catch-all routes for dynamic module route dispatch.
# These single aiohttp routes handle all requests under /owlbot/ and
# dispatch them to the appropriate handler via RouteDispatcher lookup
# at request time, enabling modules to register and unregister routes
# dynamically without restarting the server.
self.app.router.add_route(
"*", "/owlbot/{module_name}/{path:.*}", self._handle_catch_all
)
self.app.router.add_route("*", "/owlbot/{module_name}", self._handle_catch_all)
logger.debug("HttpServer initialized.")
async def start(self, host: str, port: int) -> None:
"""
Start the HTTP server.
:param host: Address to bind to.
:param port: Port to bind to.
"""
self._runner = web.AppRunner(self.app)
await self._runner.setup()
site = web.TCPSite(self._runner, host, port)
logger.debug(f"Binding web server to {host}:{port}...")
try:
await site.start()
except OSError as e:
logger.error(f"Failed to bind web server to {host}:{port}: {e}")
raise
logger.info(f"Web server started at: http://{host}:{port}")
async def stop(self) -> None:
"""Stop the HTTP server."""
if self._runner:
await self._runner.cleanup()
self._runner = None
logger.info("Web server stopped.")
async def drain(self) -> None:
"""Wait for all pending webhook dispatch tasks to complete."""
if self._pending_tasks:
logger.info(
f"Waiting for {len(self._pending_tasks)} "
"pending event(s) to complete..."
)
await asyncio.gather(*self._pending_tasks)
logger.debug("All pending events drained.")
async def _handle_webhook(self, request: web.Request) -> web.Response:
"""Handle incoming webhook requests from Owncast."""
try:
data = await request.json()
except ValueError as e:
# Invalid JSON received. This shouldn't happen
# with legitimate Owncast webhooks.
logger.warning(f"Failed to parse webhook JSON: {e}")
return web.Response(status=400)
event_type = data.get("type", "unknown")
logger.debug(f"Received webhook: {event_type}")
result = parse_event(data)
if result is None:
# Owncast may have added a new webhook we don't handle yet.
logger.warning(
f"Owncast sent unrecognized event type: {event_type!r}. Payload: {data}"
)
return web.Response(status=400)
parsed_type, event = result
# Dispatch the event to handlers in a background task so
# we can respond immediately. This avoids the possibility
# of slow handlers blocking Owncast's webhook delivery.
task: asyncio.Task[None] = asyncio.create_task(
self._event_dispatch(parsed_type, event)
)
self._pending_tasks.add(task)
task.add_done_callback(self._pending_tasks.discard)
return web.Response(status=202)
async def _handle_catch_all(self, request: web.Request) -> web.Response:
"""Delegate module route requests to the RouteDispatcher."""
return await self._route_dispatcher.dispatch(request)
+548
View File
@@ -0,0 +1,548 @@
# 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.
"""Dynamic module loading for Owlbot."""
import importlib.util
import logging
import sys
from pathlib import Path
from typing import TYPE_CHECKING, cast
from .api.config import Config, ModuleConfig
from .api.context import ModuleContext
from .api.owncast_admin_client import OwncastAdminClient
from .api.owncast_client import OwncastClient
from .api.storage import ModuleStorage
from .builtin_modules import BUILTIN_MODULE_NAMES
from .registries.commands import CommandDispatcher, ModuleCommands
from .registries.events import EventDispatcher, ModuleEvents
from .registries.routes import ModuleRoutes, RouteDispatcher
if TYPE_CHECKING:
from types import ModuleType
from .api.http_client import HttpClient
from .api.lifecycle import LifecycleHandler
logger = logging.getLogger("owlbot.modules")
class ModuleLoadError(Exception):
"""Raised when a module fails to load."""
class ModuleLoader:
"""Discovers and loads Owlbot modules from built-in and user directories."""
def __init__(
self,
modules_dir: str | Path,
config: Config,
http_client: HttpClient,
):
"""
Initialize the module loader.
:param modules_dir: Path to the user modules directory.
:param config: Configuration object for checking module enable/disable state.
:param http_client: Shared HTTP client for API clients and modules.
"""
self.modules_dir = Path(modules_dir).resolve()
self.config = config
self.http_client = http_client
self._module_contexts: dict[str, ModuleContext] = {}
self.loaded_modules: set[str] = set()
self.owncast_client = OwncastClient(
config.owncast_url,
config.owncast_access_token,
http_client,
)
# Optional admin client (only created when admin.enabled is true).
self.admin_client: OwncastAdminClient | None = None
if config.admin_enabled:
self.admin_client = OwncastAdminClient(
config.owncast_url,
config.admin_username,
config.admin_password,
http_client,
)
self.command_dispatcher = CommandDispatcher(
get_module_context=self._require_module_context,
owncast_client=self.owncast_client,
handler_timeout=config.handler_timeout,
loaded_modules=self.loaded_modules,
command_prefix=config.command_prefix,
)
self.event_dispatcher = EventDispatcher(
command_dispatch=self.command_dispatcher.dispatch,
get_module_context=self._require_module_context,
handler_timeout=config.handler_timeout,
)
self.route_dispatcher = RouteDispatcher(
get_module_context=self._require_module_context,
handler_timeout=config.handler_timeout,
)
logger.debug(
f"ModuleLoader initialized (user modules directory: {self.modules_dir})"
)
def get_module_context(self, module_name: str) -> ModuleContext | None:
"""
Look up a module's context by name.
:param module_name: The module name.
:return: The ModuleContext if the module is loaded, None otherwise.
"""
return self._module_contexts.get(module_name)
def _require_module_context(self, module_name: str) -> ModuleContext:
"""Look up a module's context, raising if the module is not loaded.
Used by dispatchers where the module is guaranteed to be loaded.
:param module_name: The module name.
:return: The ModuleContext.
:raises RuntimeError: If the module is not loaded.
"""
ctx = self._module_contexts.get(module_name)
if ctx is None:
raise RuntimeError(f"Module '{module_name}' is not loaded")
return ctx
def discover_module_names(self) -> list[str]:
"""
Discover all loadable modules from both built-in and user directories.
Built-in modules ship with the package. User modules are discovered
from the configured modules directory. If a user module has the same
name as a built-in, the user module takes precedence and a warning is
logged.
:return: Sorted list of module names.
"""
logger.debug("Discovering modules...")
user_names = self._discover_user_module_names()
overrides = user_names & BUILTIN_MODULE_NAMES
for name in sorted(overrides):
logger.info(
f"User module '{name}' found; built-in module of "
f"the same name will be skipped."
)
merged = BUILTIN_MODULE_NAMES | user_names
modules = sorted(merged)
builtin_count = len(BUILTIN_MODULE_NAMES - user_names)
user_count = len(user_names)
logger.info(
f"Discovered {len(modules)} module(s) "
f"({builtin_count} built-in, {user_count} user): "
f"{', '.join(modules) if modules else 'none'}"
)
return modules
def _discover_user_module_names(self) -> set[str]:
"""
Scan the user modules directory for loadable modules.
Supports both single-file modules (``name.py``) and package modules
(``name/__init__.py``). Files and directories starting with underscore
are ignored. If both forms exist for the same name, the module is
listed once.
:return: Set of user module names.
"""
logger.debug(f"Scanning user modules directory: {self.modules_dir}")
if not self.modules_dir.exists():
logger.debug(f"User modules directory does not exist: {self.modules_dir}")
return set()
found: set[str] = set()
# Single-file modules (*.py).
for path in self.modules_dir.glob("*.py"):
if path.name.startswith("_"):
continue
found.add(path.stem)
# Package modules (directory with __init__.py).
for path in self.modules_dir.iterdir():
if not path.is_dir() or path.name.startswith("_"):
continue
if (path / "__init__.py").exists():
found.add(path.name)
if found:
logger.debug(
f"Found {len(found)} user module(s): {', '.join(sorted(found))}"
)
return found
async def load_all_modules(self) -> list[str]:
"""
Discover and load all enabled modules using two-phase loading.
**Phase 1:** Import every module, create contexts, and register all
decorated handlers (``@on_event``, ``@on_command``, ``@on_route``).
**Phase 2:** Run ``@on_setup`` hooks for each successfully imported module.
This ordering guarantees that all decorated (static) commands are
registered before any module's ``@on_setup`` hooks attempt dynamic
registration, preventing conflicts when an ``@on_setup`` hook tries to
register a name already claimed by a decorated command.
:return: Names of successfully loaded modules.
"""
discovered = self.discover_module_names()
logger.info(f"Loading {len(discovered)} module(s)...")
imported: list[str] = []
loaded: list[str] = []
disabled_count = 0
failed_count = 0
# Phase 1: Import all modules and register decorated handlers.
for module_name in discovered:
try:
await self.load_module(module_name, _run_setup=False)
imported.append(module_name)
except (FileNotFoundError, ModuleLoadError) as e:
if "disabled in config" in str(e):
disabled_count += 1
else:
logger.error(str(e))
failed_count += 1
# Phase 2: Run @on_setup hooks for each imported module.
logger.debug(
f"Import phase complete ({len(imported)} imported). "
f"Running setup handlers..."
)
for module_name in imported:
try:
await self._run_module_setup(module_name)
loaded.append(module_name)
logger.info(f"Loaded module '{module_name}'.")
except ModuleLoadError as e:
logger.error(str(e))
failed_count += 1
parts = [f"{len(loaded)} loaded"]
if disabled_count:
parts.append(f"{disabled_count} disabled")
if failed_count:
parts.append(f"{failed_count} failed")
logger.info(f"Module loading complete: {', '.join(parts)}")
return loaded
async def load_module(self, module_name: str, *, _run_setup: bool = True) -> None:
"""
Load a single module by name.
If both a package and single-file form exist for the same name,
the package form is used. Checks if the module is enabled in
config before loading. After the module is executed, decorated
handlers (@on_event, @on_command) are scanned and registered.
If the module defines any ``@on_setup`` hooks, they are called
as the final step of loading (unless ``_run_setup`` is False,
in which case setup is deferred).
:param module_name: Name of the module to load (without .py extension).
:param _run_setup: Whether to run the module's @on_setup hooks.
When False, import and handler registration happen but setup
is deferred. Used internally by ``load_all_modules()`` to
implement two-phase loading.
:raises ValueError: If the module is already loaded.
:raises FileNotFoundError: If the module file cannot be found.
:raises ModuleLoadError: If the module is disabled in config,
or if the module fails to load or setup fails.
"""
if module_name in self.loaded_modules:
raise ValueError(f"Module '{module_name}' is already loaded")
logger.debug(f"Attempting to load module: {module_name}")
if not self.config.is_module_enabled(module_name):
raise ModuleLoadError(f"Module '{module_name}' is disabled in config")
module_path = self._resolve_module_path(module_name)
try:
# Load the module into an isolated namespace to prevent conflicts.
# Using "owlbot_modules." prefix keeps these separate
# from normal Python packages.
spec = importlib.util.spec_from_file_location(
f"owlbot_modules.{module_name}", module_path
)
if spec is None or spec.loader is None:
raise ModuleLoadError(
f"Failed to create module spec for '{module_name}'"
)
module = importlib.util.module_from_spec(spec)
sys.modules[f"owlbot_modules.{module_name}"] = module
logger.debug(f"Executing module: {module_name}")
spec.loader.exec_module(module)
scoped_config = ModuleConfig(self.config, module_name)
storage = ModuleStorage(
self.config.storage_dir, module_name, self.config.pool_size
)
module_commands = ModuleCommands(self.command_dispatcher, module_name)
module_events = ModuleEvents(self.event_dispatcher, module_name)
module_routes = ModuleRoutes(
self.route_dispatcher, module_name, self.config.public_base_url
)
module_ctx = ModuleContext(
module_name=module_name,
config=scoped_config,
owncast_client=self.owncast_client,
storage=storage,
commands=module_commands,
events=module_events,
routes=module_routes,
http=self.http_client,
admin_client=self.admin_client,
)
self._module_contexts[module_name] = module_ctx
self._register_module_handlers(module, module_name)
self.loaded_modules.add(module_name)
if _run_setup:
await self._run_module_setup(module_name)
logger.info(f"Loaded module '{module_name}'.")
else:
logger.debug(f"Imported module '{module_name}' (setup deferred).")
except ModuleLoadError:
raise
except Exception as e:
# Close storage if it was created before the failure.
cleanup_ctx = self._module_contexts.get(module_name)
if cleanup_ctx:
try:
await cleanup_ctx.storage._close()
except Exception as close_err:
logger.error(
f"Failed to close storage for module '{module_name}' "
f"during load error cleanup: {close_err}"
)
self._cleanup_module(module_name)
raise ModuleLoadError(f"Failed to load module '{module_name}': {e}") from e
async def unload_module(self, module_name: str) -> bool:
"""
Unload a module, calling its teardown and cleaning up all state.
:param module_name: The module to unload.
:return: True if module was unloaded, False if not found.
"""
logger.debug(f"Unloading module '{module_name}'")
if module_name not in self.loaded_modules:
logger.warning(f"Cannot unload '{module_name}': not loaded")
return False
module_ctx = self._module_contexts.get(module_name)
module = sys.modules.get(f"owlbot_modules.{module_name}")
teardown_funcs = (
self._collect_lifecycle_handlers(module, "_owlbot_teardown")
if module
else []
)
if teardown_funcs and module_ctx:
for teardown_func in teardown_funcs:
try:
logger.debug(f"Running @on_teardown for module: {module_name}")
await teardown_func(module_ctx)
logger.debug(f"Teardown completed for module: {module_name}")
except Exception as e:
logger.exception(f"Teardown failed for module '{module_name}': {e}")
else:
logger.debug(f"Module '{module_name}' has no @on_teardown handlers.")
if module_ctx:
try:
await module_ctx.storage._close()
except Exception as e:
logger.exception(
f"Failed to close storage for module '{module_name}': {e}"
)
self._cleanup_module(module_name)
logger.info(f"Unloaded module '{module_name}'.")
return True
async def unload_all_modules(self) -> None:
"""
Unload all modules, calling teardown and cleaning up all state.
Called during bot shutdown to allow modules to clean up resources.
"""
if not self.loaded_modules:
return
logger.info(f"Unloading {len(self.loaded_modules)} module(s)...")
for module_name in list(self.loaded_modules):
await self.unload_module(module_name)
logger.info("All module unload complete.")
async def _run_module_setup(self, module_name: str) -> None:
"""
Run a module's ``@on_setup`` hooks if any are defined.
All setup handlers run inside a single storage transaction that is
committed on success. If any handler fails, the transaction is
rolled back, storage is closed, and the module is fully cleaned up.
:param module_name: Name of the module whose setup to run.
:raises ModuleLoadError: If any @on_setup handler raises an exception.
"""
module = sys.modules.get(f"owlbot_modules.{module_name}")
setup_funcs = (
self._collect_lifecycle_handlers(module, "_owlbot_setup") if module else []
)
if not setup_funcs:
logger.debug(f"Module '{module_name}' has no @on_setup handlers.")
return
module_ctx = self._module_contexts[module_name]
try:
async with module_ctx.storage._checkout():
try:
for setup_func in setup_funcs:
logger.debug(f"Running @on_setup for module: {module_name}")
await setup_func(module_ctx)
await module_ctx.storage._commit()
logger.debug(f"Setup completed for module: {module_name}")
except Exception:
await module_ctx.storage._rollback()
raise
except Exception as e:
await module_ctx.storage._close()
self._cleanup_module(module_name)
raise ModuleLoadError(
f"Setup failed for module '{module_name}': {e}"
) from e
def _resolve_module_path(self, module_name: str) -> Path:
"""
Resolve the filesystem path for a module by name.
User modules are checked first (package form, then single-file).
If no user module is found and the name is a built-in, the built-in
path is returned.
:param module_name: The module name to resolve.
:return: The resolved path to the module file.
:raises FileNotFoundError: If neither form exists.
"""
# Check user modules directory first (allows overriding built-ins).
if self.modules_dir.exists():
package_path = self.modules_dir / module_name / "__init__.py"
if package_path.exists():
return package_path
single_path = self.modules_dir / f"{module_name}.py"
if single_path.exists():
return single_path
# Fall back to built-in modules.
if module_name in BUILTIN_MODULE_NAMES:
return self._resolve_builtin_module_path(module_name)
raise FileNotFoundError(f"Module not found: '{module_name}'")
@staticmethod
def _resolve_builtin_module_path(module_name: str) -> Path:
"""
Locate a built-in module's ``__init__.py`` inside the package.
:param module_name: Name of the built-in module.
:return: Path to the module's ``__init__.py``.
"""
from . import builtin_modules
return (
Path(builtin_modules.__file__).resolve().parent
/ module_name
/ "__init__.py"
)
def _register_module_handlers(self, module: ModuleType, module_name: str) -> None:
"""
Scan a module for decorated handlers and register them.
Delegates to each registry's ``register_from_module()`` method,
which knows how to find its own decorator markers.
:param module: The loaded Python module to scan.
:param module_name: Name of the module (for ownership tracking).
"""
self.event_dispatcher.register_from_module(module, module_name)
self.command_dispatcher.register_from_module(module, module_name)
self.route_dispatcher.register_from_module(module, module_name)
@staticmethod
def _collect_lifecycle_handlers(
module: ModuleType, marker: str
) -> list[LifecycleHandler]:
"""
Collect callables from a module that have a given marker attribute.
Scans ``vars(module)`` for callable objects where ``getattr(obj, marker)``
is truthy. Used to find ``@on_setup`` (marker ``"_owlbot_setup"``) and
``@on_teardown`` (marker ``"_owlbot_teardown"``) handlers.
:param module: The loaded Python module to scan.
:param marker: The attribute name to look for (e.g. ``"_owlbot_setup"``).
:return: List of matching callables.
"""
return [
cast("LifecycleHandler", obj)
for obj in vars(module).values()
if callable(obj) and getattr(obj, marker, False)
]
def _cleanup_module(self, module_name: str) -> None:
"""
Remove all state associated with a module.
Removes the top-level module entry and any submodule entries
(for package-style modules) from ``sys.modules``.
:param module_name: The module to clean up.
"""
prefix = f"owlbot_modules.{module_name}"
# Remove submodules first (e.g. owlbot_modules.custom_commands.handler),
# then the top-level entry.
for key in [k for k in sys.modules if k.startswith(f"{prefix}.")]:
sys.modules.pop(key, None)
sys.modules.pop(prefix, None)
self._module_contexts.pop(module_name, None)
self.loaded_modules.discard(module_name)
self.event_dispatcher.unregister_by_module(module_name)
self.command_dispatcher.unregister_by_module(module_name)
self.route_dispatcher.unregister_by_module(module_name)
logger.debug(f"Cleaned up module '{module_name}'.")
View File
+34
View File
@@ -0,0 +1,34 @@
# 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.
"""Internal infrastructure: registries, module-scoped wrappers, and dispatchers."""
from .commands import CommandDispatcher, CommandRegistry, ModuleCommands
from .events import EventDispatcher, EventRegistry, ModuleEvents
from .routes import ModuleRoutes, RouteDispatcher, RouteRegistry
__all__ = [
# Commands.
"CommandDispatcher",
"CommandRegistry",
"ModuleCommands",
# Events.
"EventDispatcher",
"EventRegistry",
"ModuleEvents",
# Routes.
"ModuleRoutes",
"RouteDispatcher",
"RouteRegistry",
]
+701
View File
@@ -0,0 +1,701 @@
# 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 registry, module-scoped wrapper, and dispatcher.
Internal infrastructure for managing command registration and dispatch.
"""
import asyncio
import logging
import math
import time
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, cast
from ..api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
from ..api.event_types import ChatEvent
if TYPE_CHECKING:
from types import ModuleType
from ..api.context import EventContext, ModuleContext
from ..api.owncast_client import OwncastClient
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
logger = logging.getLogger("owlbot.commands")
class CommandRegistry:
"""
Holds all registered commands for a bot instance.
Instance-scoped to enable proper dependency injection and allow multiple
bot instances to coexist without sharing state.
"""
def __init__(self, prefix: str = "!") -> None:
"""
Initialize the command registry.
:param prefix: Command prefix character (e.g., "!" for "!ping").
"""
# Maps primary command names to their CommandInfo objects.
self._commands: dict[str, CommandInfo] = {}
# Maps all triggers (primary names and aliases) to their primary command name.
# This allows O(1) lookup for any trigger without scanning all commands.
self._aliases: dict[str, str] = {}
self.prefix = prefix
logger.debug(f"CommandRegistry initialized with prefix '{prefix}'.")
def register(
self,
name: str,
handler: CommandHandler,
*,
aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False,
requires_moderator: bool = False,
cooldown: int | float = 0,
module_name: str,
) -> None:
"""
Register a command handler.
:param name: Primary command name (case-insensitive).
:param handler: Async function to handle the command.
:param aliases: Optional list of alternative names.
:param requires_authenticated: If True, user must be logged in.
:param requires_moderator: If True, user must have moderator privileges.
:param cooldown: Minimum seconds between invocations (0 to disable).
:param module_name: Name of the module registering this command.
:raises ValueError: If command name or alias conflicts with existing command.
"""
name_lower = name.lower()
alias_set = frozenset(a.lower() for a in (aliases or []))
all_triggers = {name_lower} | alias_set
for trigger in all_triggers:
if trigger in self._aliases:
existing = self._aliases[trigger]
raise ValueError(
f"Command trigger '{trigger}' conflicts with existing "
f"command '{existing}'"
)
info = CommandInfo(
name=name_lower,
handler=handler,
module_name=module_name,
aliases=alias_set,
requires_authenticated=requires_authenticated,
requires_moderator=requires_moderator,
cooldown=cooldown,
)
self._commands[name_lower] = info
for trigger in all_triggers:
self._aliases[trigger] = name_lower
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.commands")
module_logger.debug(
f"Registered command '{name_lower}' with aliases {sorted(alias_set)}, "
f"authenticated={requires_authenticated}, moderator={requires_moderator}, "
f"cooldown={cooldown}."
)
def unregister(self, name: str) -> bool:
"""
Unregister a command and all its aliases.
:param name: The primary command name or any alias.
:return: True if command was found and removed, False otherwise.
"""
name_lower = name.lower()
primary = self._aliases.get(name_lower)
if primary is None:
return False
info = self._commands.get(primary)
if info is None:
return False
for trigger in info.all_triggers:
self._aliases.pop(trigger, None)
del self._commands[primary]
module_logger = logging.getLogger(f"owlbot.modules.{info.module_name}.commands")
module_logger.debug(f"Unregistered command '{primary}'.")
return True
def get(self, trigger: str) -> CommandInfo | None:
"""
Look up a command by name or alias.
:param trigger: Command name or alias (case-insensitive).
:return: CommandInfo if found, None otherwise.
"""
trigger_lower = trigger.lower()
primary = self._aliases.get(trigger_lower)
if primary is None:
return None
return self._commands.get(primary)
def exists(self, trigger: str) -> bool:
"""
Check if a command is registered.
:param trigger: Command name or alias (case-insensitive).
:return: True if the command exists, False otherwise.
"""
return trigger.lower() in self._aliases
def get_all(self) -> dict[str, CommandInfo]:
"""
Get all registered commands.
:return: Dict mapping primary command names to CommandInfo.
"""
return self._commands.copy()
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all commands registered by a specific module.
:param module_name: The module whose commands should be removed.
:return: Number of commands removed.
"""
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.commands")
to_remove = [
name
for name, info in self._commands.items()
if info.module_name == module_name
]
module_logger.debug(f"Unregistering all commands ({len(to_remove)} total).")
for name in to_remove:
self.unregister(name)
return len(to_remove)
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for @on_command-decorated functions and register them.
Looks for functions with the ``_owlbot_command`` attribute set by
the ``@on_command`` decorator and registers each one.
:param module: The loaded Python module to scan.
:param module_name: Name of the module (for ownership tracking).
"""
for obj in vars(module).values():
if callable(obj):
cmd_info = getattr(obj, "_owlbot_command", None)
if cmd_info is not None:
mark = cast("CommandMark", cmd_info)
self.register(
name=mark["name"],
handler=obj,
aliases=mark["aliases"],
requires_authenticated=mark["requires_authenticated"],
requires_moderator=mark["requires_moderator"],
cooldown=mark["cooldown"],
module_name=module_name,
)
def parse(self, message: str) -> tuple[str, str] | None:
"""
Parse a message to extract command and arguments.
:param message: The chat message body.
:return: Tuple of (command_name, args_string), or None if not a command.
"""
if not message.startswith(self.prefix):
return None
content = message[len(self.prefix) :].strip()
if not content:
return None
parts = content.split(maxsplit=1)
command = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
logger.debug(f"Parsed command: {command!r} with args: {args!r}")
return command, args
class CommandDispatcher:
"""
Dispatches chat events to registered command handlers.
Parses messages, checks authentication/moderator requirements,
and calls the appropriate command handler.
"""
def __init__(
self,
get_module_context: Callable[[str], ModuleContext],
owncast_client: OwncastClient,
handler_timeout: float,
loaded_modules: set[str],
command_prefix: str = "!",
) -> None:
"""
Initialize the command dispatcher.
Creates and owns a :class:`CommandRegistry` internally.
:param get_module_context: Callable that looks up a
ModuleContext by module name.
:param owncast_client: Owncast API client for sending error messages.
:param handler_timeout: Timeout for command handlers in seconds.
:param loaded_modules: Reference to the set of currently loaded module names.
:param command_prefix: Prefix character for commands (e.g., "!").
"""
self._command_registry = CommandRegistry(command_prefix)
self._get_module_context = get_module_context
self._owncast_client = owncast_client
self._handler_timeout = handler_timeout
self._loaded_modules = loaded_modules
# Maps canonical command name -> monotonic timestamp of last invocation.
self._cooldown_tracker: dict[str, float] = {}
# Maps canonical command name -> built-in handler callable.
self._builtin_handlers: dict[str, BuiltinCommandHandler] = {}
self._register_builtin_commands()
@property
def prefix(self) -> str:
"""The command prefix character (e.g., '!')."""
return self._command_registry.prefix
def register_builtin(
self,
name: str,
handler: BuiltinCommandHandler,
*,
aliases: list[str] | tuple[str, ...] | None = None,
) -> None:
"""
Register a built-in command handler.
Built-in commands use a simpler handler signature (event, owncast_client)
and don't require module infrastructure.
:param name: Primary command name (case-insensitive).
:param handler: Async function with signature (ChatEvent, OwncastClient).
:param aliases: Optional list of alternative names.
"""
name_lower = name.lower()
self._builtin_handlers[name_lower] = handler
self._command_registry.register(
name=name,
handler=handler, # type: ignore[arg-type]
aliases=aliases,
module_name="__builtin__",
cooldown=60,
)
logger.debug(f"Registered built-in command '{name_lower}'.")
def register(
self,
name: str,
handler: CommandHandler,
*,
aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False,
requires_moderator: bool = False,
cooldown: int | float = 0,
module_name: str,
) -> None:
"""
Register a command handler.
Delegates to the internal CommandRegistry.
:param name: Primary command name (case-insensitive).
:param handler: Async function to handle the command.
:param aliases: Optional list of alternative names.
:param requires_authenticated: If True, user must be logged in.
:param requires_moderator: If True, user must have moderator privileges.
:param cooldown: Minimum seconds between invocations (0 to disable).
:param module_name: Name of the module registering this command.
:raises ValueError: If command name or alias conflicts with existing command.
"""
self._command_registry.register(
name=name,
handler=handler,
aliases=aliases,
requires_authenticated=requires_authenticated,
requires_moderator=requires_moderator,
cooldown=cooldown,
module_name=module_name,
)
# Clear any stale cooldown tracker entry on re-registration.
self._cooldown_tracker.pop(name.lower(), None)
def unregister(self, name: str) -> bool:
"""
Unregister a command and all its aliases.
Delegates to the internal CommandRegistry.
:param name: The primary command name or any alias.
:return: True if command was found and removed, False otherwise.
"""
# Resolve canonical name before unregistering so we can clean up the tracker.
info = self._command_registry.get(name)
result = self._command_registry.unregister(name)
if result and info is not None:
self._cooldown_tracker.pop(info.name, None)
return result
def get(self, trigger: str) -> CommandInfo | None:
"""
Look up a command by name or alias.
Delegates to the internal CommandRegistry.
:param trigger: Command name or alias (case-insensitive).
:return: CommandInfo if found, None otherwise.
"""
return self._command_registry.get(trigger)
def exists(self, trigger: str) -> bool:
"""
Check if a command is registered.
Delegates to the internal CommandRegistry.
:param trigger: Command name or alias (case-insensitive).
:return: True if the command exists, False otherwise.
"""
return self._command_registry.exists(trigger)
def get_by_module(self, module_name: str) -> dict[str, CommandInfo]:
"""
Get all commands registered by a specific module.
:param module_name: The module whose commands to return.
:return: Dict mapping primary command names to CommandInfo for that module.
"""
return {
name: info
for name, info in self._command_registry.get_all().items()
if info.module_name == module_name
}
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for @on_command-decorated functions and register them.
Delegates to the internal CommandRegistry.
:param module: The loaded Python module to scan.
:param module_name: Name of the module (for ownership tracking).
"""
self._command_registry.register_from_module(module, module_name)
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all commands registered by a specific module.
Delegates to the internal CommandRegistry.
:param module_name: The module whose commands should be removed.
:return: Number of commands removed.
"""
# Collect command names before unregistering so we can clean up the tracker.
module_commands = self.get_by_module(module_name)
for cmd_name in module_commands:
self._cooldown_tracker.pop(cmd_name, None)
return self._command_registry.unregister_by_module(module_name)
async def dispatch(self, event: ChatEvent) -> None:
"""
Dispatch a chat event to the appropriate command handler if it's a command.
:param event: The chat event to check for commands.
"""
from ..api.context import CommandContext, EventContext
parsed = self._command_registry.parse(event.body)
if parsed is None:
return
command_name, args = parsed
command_info = self._command_registry.get(command_name)
if command_info is None:
# Log for debugging but don't spam the chat with "unknown command" errors.
logger.debug(f"Unknown command: {command_name}")
return
user = event.user
logger.info(
f"Command '{command_info.name}' invoked by {user.display_name}"
f" with args: {args!r}"
)
if command_info.requires_authenticated and not user.is_authenticated:
logger.info(
f"Command '{command_name}' denied for {user.display_name}: "
"authentication required"
)
await self._owncast_client.send_system_message_to_client(
event.client_id,
f"You must be authenticated to use !{command_info.name}.",
)
return
if command_info.requires_moderator and not user.is_moderator:
logger.info(
f"Command '{command_name}' denied for {user.display_name}: "
"moderator required"
)
await self._owncast_client.send_system_message_to_client(
event.client_id,
f"Only moderators can use !{command_info.name}.",
)
return
if command_info.cooldown > 0:
now = time.monotonic()
last = self._cooldown_tracker.get(command_info.name)
if last is not None and now - last < command_info.cooldown:
remaining = math.ceil(command_info.cooldown - (now - last))
logger.info(
f"Command '{command_name}' denied for {user.display_name}: "
f"on cooldown ({remaining}s remaining)"
)
await self._owncast_client.send_system_message_to_client(
event.client_id,
f"!{command_info.name} can be used every "
f"{command_info.cooldown:.0f}s. Try again in {remaining}s.",
)
return
# Record immediately to prevent concurrent tasks from bypassing the
# cooldown between this check and handler completion.
self._cooldown_tracker[command_info.name] = now
# Built-in commands use a simpler dispatch path
# without ModuleContext or storage.
builtin_handler = self._builtin_handlers.get(command_info.name)
if builtin_handler is not None:
try:
start = time.perf_counter()
await asyncio.wait_for(
builtin_handler(event, self._owncast_client),
timeout=self._handler_timeout,
)
elapsed = (time.perf_counter() - start) * 1000
logger.debug(
f"Built-in command '{command_info.name}' "
f"completed in {elapsed:.1f}ms."
)
except TimeoutError:
logger.warning(
f"Built-in command '{command_info.name}' "
f"cancelled after {self._handler_timeout}s timeout."
)
except Exception as e:
logger.exception(
f"Built-in command '{command_info.name}' raised exception: {e}"
)
return
cmd_event = CommandEvent(
# Use the canonical name, not the alias the user typed.
command=command_info.name,
args=args,
args_list=args.split() if args else [],
prefix=self._command_registry.prefix,
chat_event=event,
)
module_ctx = self._get_module_context(command_info.module_name)
event_ctx: EventContext[ChatEvent] = EventContext(
event=event,
module=module_ctx,
)
cmd_ctx = CommandContext(
command_event=cmd_event,
event_context=event_ctx,
module=module_ctx,
)
# The checkout acquires a pooled connection for the
# duration of this command invocation.
async with module_ctx.storage._checkout():
try:
start = time.perf_counter()
await asyncio.wait_for(
command_info.handler(cmd_ctx), timeout=self._handler_timeout
)
elapsed = (time.perf_counter() - start) * 1000
# Command succeeded, commit any database changes.
await module_ctx.storage._commit()
logger.debug(
f"Command '{command_info.name}' completed in {elapsed:.1f}ms."
)
except TimeoutError:
# Command timed out. Rollback any partial changes.
await module_ctx.storage._rollback()
logger.warning(
f"Command handler '{command_info.name}' "
f"from module '{command_info.module_name}' "
f"cancelled after "
f"{self._handler_timeout}s timeout."
)
except Exception as e:
# Command raised an exception. Rollback any partial changes.
await module_ctx.storage._rollback()
logger.exception(
f"Command handler '{command_info.name}' "
f"from module '{command_info.module_name}' "
f"raised exception: {e}"
)
def _register_builtin_commands(self) -> None:
"""Register all built-in commands."""
from .._version import __version__
loaded_modules = self._loaded_modules
async def about_with_modules(
event: ChatEvent, owncast_client: OwncastClient
) -> None:
module_count = len(loaded_modules)
module_list = (
", ".join(sorted(loaded_modules)) if loaded_modules else "none"
)
await owncast_client.send_message(
f"Owlbot v{__version__} - A logal.dev project | "
f"Modules ({module_count}): {module_list}"
)
self.register_builtin("about", about_with_modules)
class ModuleCommands:
"""
Module-scoped wrapper around CommandDispatcher.
This wrapper auto-supplies the module name for registration operations,
so modules don't need to pass their own name back into the API.
Follows the same pattern as ModuleEvents and ModuleRoutes.
"""
def __init__(self, dispatcher: CommandDispatcher, module_name: str) -> None:
"""
Initialize the module-scoped command wrapper.
:param dispatcher: The CommandDispatcher that owns the command registry.
:param module_name: The name of the module using this wrapper.
"""
self._dispatcher = dispatcher
self._module_name = module_name
@property
def prefix(self) -> str:
"""The command prefix character (e.g., '!')."""
return self._dispatcher.prefix
@property
def module_commands(self) -> dict[str, CommandInfo]:
"""Commands registered by this module only."""
return self._dispatcher.get_by_module(self._module_name)
def register(
self,
name: str,
handler: CommandHandler,
*,
aliases: list[str] | tuple[str, ...] | None = None,
requires_authenticated: bool = False,
requires_moderator: bool = False,
cooldown: int | float = 0,
) -> None:
"""
Register a command handler for this module.
The module name is automatically supplied.
:param name: Primary command name (case-insensitive).
:param handler: Async function to handle the command.
:param aliases: Optional list of alternative names.
:param requires_authenticated: If True, user must be logged in.
:param requires_moderator: If True, user must have moderator privileges.
:param cooldown: Minimum seconds between invocations (0 to disable).
:raises ValueError: If command name or alias conflicts with existing command.
"""
self._dispatcher.register(
name=name,
handler=handler,
aliases=aliases,
requires_authenticated=requires_authenticated,
requires_moderator=requires_moderator,
cooldown=cooldown,
module_name=self._module_name,
)
def unregister(self, name: str) -> bool:
"""
Unregister a command and all its aliases.
Only commands registered by this module can be unregistered.
:param name: The primary command name or any alias.
:return: True if command was found and removed, False if not found or not owned.
"""
# Only allow unregistering commands owned by this module.
info = self._dispatcher.get(name)
if info is None or info.module_name != self._module_name:
return False
return self._dispatcher.unregister(name)
def get(self, trigger: str) -> CommandInfo | None:
"""
Look up a command by name or alias within this module's registrations.
:param trigger: Command name or alias (case-insensitive).
:return: CommandInfo if found and owned by this module, None otherwise.
"""
info = self._dispatcher.get(trigger)
if info is None or info.module_name != self._module_name:
return None
return info
def exists(self, trigger: str) -> bool:
"""
Check if a command is registered across all modules.
:param trigger: Command name or alias (case-insensitive).
:return: True if the command exists, False otherwise.
"""
return self._dispatcher.exists(trigger)
+512
View File
@@ -0,0 +1,512 @@
# 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.
"""Event handler registry, module-scoped wrapper, and dispatcher.
Internal infrastructure for managing event handler registration and dispatch.
"""
import asyncio
import logging
import time
from typing import TYPE_CHECKING, NamedTuple, cast
from ..api.context import PropagationState
from ..api.event_types import ChatEvent, Event, EventType, log_event
from ..api.events import EventHandler, EventMark, Priority
class HandlerEntry(NamedTuple):
"""A registered event handler with its module name and priority."""
handler: EventHandler
module_name: str
priority: int
type EventHandlerMap = dict[str, list[HandlerEntry]]
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from types import ModuleType
from ..api.context import EventContext, ModuleContext
logger = logging.getLogger("owlbot.events")
class EventRegistry:
"""
Holds all registered event handlers for a bot instance.
Instance-scoped to enable proper dependency injection and allow multiple
bot instances to coexist without sharing state.
"""
def __init__(self) -> None:
"""Initialize an empty handler registry."""
# Maps event type values (strings) to lists of
# (handler, module_name, priority) tuples.
self._handlers: EventHandlerMap = {}
def register(
self,
handler: EventHandler,
event_types: tuple[EventType, ...],
module_name: str,
priority: int = Priority.NORMAL,
) -> None:
"""
Register a handler for the given event types.
Called by the module loader after scanning for decorated functions.
:param handler: The handler function to register.
:param event_types: Tuple of EventType values the handler responds to.
:param module_name: Name of the module this handler belongs to.
:param priority: Dispatch priority (higher runs first).
Defaults to Priority.NORMAL.
"""
# A single handler can respond to multiple event types.
for event_type in event_types:
key = event_type.value
if key not in self._handlers:
self._handlers[key] = []
# Add the handler to the list. Multiple handlers
# for the same event are allowed.
self._handlers[key].append(HandlerEntry(handler, module_name, priority))
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
module_logger.debug(
f"Registered handler '{handler.__name__}' for {event_type.value} "
f"(priority={priority})."
)
def unregister(self, handler: EventHandler) -> bool:
"""
Unregister a handler from all event types it is registered for.
:param handler: The handler function to unregister.
:return: True if handler was found and removed, False otherwise.
"""
module_name = None
removed_from: list[str] = []
for key in self._handlers:
original = self._handlers[key]
kept = []
for entry in original:
if entry.handler is handler:
module_name = entry.module_name
removed_from.append(key)
else:
kept.append(entry)
self._handlers[key] = kept
if module_name and removed_from:
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
module_logger.debug(
f"Unregistered handler '{handler.__name__}' "
f"for {', '.join(removed_from)}."
)
return True
return False
def get(self, event_type: EventType) -> list[HandlerEntry]:
"""
Get all handlers registered for a specific event type.
:param event_type: The event type to look up.
:return: List of (handler, module_name, priority) tuples.
"""
return self._handlers.get(event_type.value, [])
def get_all(self) -> EventHandlerMap:
"""
Get a copy of the entire handler registry.
:return: Dict mapping event type values to handler lists.
"""
return self._handlers.copy()
def get_handler_module(self, handler: EventHandler) -> str | None:
"""
Look up which module registered a given handler.
:param handler: The handler function to look up.
:return: The module name if found, None otherwise.
"""
for handler_list in self._handlers.values():
for entry in handler_list:
if entry.handler is handler:
return entry.module_name
return None
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all handlers registered by a specific module.
:param module_name: The module whose handlers should be removed.
:return: Number of handlers removed.
"""
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.events")
seen: set[EventHandler] = set()
for key in self._handlers:
kept = []
for entry in self._handlers[key]:
if entry.module_name == module_name:
seen.add(entry.handler)
else:
kept.append(entry)
self._handlers[key] = kept
if seen:
module_logger.debug(f"Unregistered all handlers ({len(seen)} total).")
return len(seen)
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for @on_event-decorated functions and register them.
Looks for functions with the ``_owlbot_event`` attribute set by
the ``@on_event`` decorator and registers each one.
:param module: The loaded Python module to scan.
:param module_name: Name of the module (for ownership tracking).
"""
for obj in vars(module).values():
if callable(obj):
event_info = getattr(obj, "_owlbot_event", None)
if event_info is not None:
mark = cast("EventMark", event_info)
self.register(
obj, mark["event_types"], module_name, mark["priority"]
)
class EventDispatcher:
"""
Dispatches events to registered handlers sequentially by priority.
After all event handlers complete, command dispatch is triggered for
CHAT events (via the injected command_dispatch callback).
"""
def __init__(
self,
command_dispatch: Callable[[ChatEvent], Awaitable[None]],
get_module_context: Callable[[str], ModuleContext],
handler_timeout: float,
) -> None:
"""
Initialize the event dispatcher.
Creates and owns a :class:`EventRegistry` internally.
:param command_dispatch: Async callback to dispatch
commands for CHAT events.
:param get_module_context: Callable that looks up a
ModuleContext by module name.
:param handler_timeout: Timeout for individual handlers in seconds.
"""
self._handler_registry = EventRegistry()
self._command_dispatch = command_dispatch
self._get_module_context = get_module_context
self._handler_timeout = handler_timeout
def register(
self,
handler: EventHandler,
event_types: tuple[EventType, ...],
module_name: str,
priority: int = Priority.NORMAL,
) -> None:
"""
Register a handler for the given event types.
A single handler can respond to multiple event types; the registry
stores a separate entry per event type. This is the shared entry
point for both decorator-based registration (via ``register_from_module``)
and dynamic registration (via ``ModuleEvents.register``).
:param handler: The handler function to register.
:param event_types: Tuple of EventType values the handler responds to.
:param module_name: Name of the module this handler
belongs to.
:param priority: Dispatch priority (higher runs first).
Defaults to Priority.NORMAL.
"""
self._handler_registry.register(handler, event_types, module_name, priority)
def unregister(self, handler: EventHandler) -> bool:
"""
Unregister a handler from all event types it is registered for.
Unlike commands (looked up by name string), event handlers are
identified by object identity. A handler registered for multiple
event types is removed from every one of them in a single call.
:param handler: The handler function to unregister.
:return: True if handler was found and removed, False otherwise.
"""
return self._handler_registry.unregister(handler)
def get_by_module(self, module_name: str) -> EventHandlerMap:
"""
Get all handlers registered by a specific module, grouped by event type.
The registry stores handlers grouped by event type, not by module,
so this filters across all event types to collect a single module's
handlers.
:param module_name: The module whose handlers to return.
:return: Dict mapping event type values to handler lists for that module.
"""
result: EventHandlerMap = {}
for event_type_value, handler_list in self._handler_registry.get_all().items():
filtered = [
entry for entry in handler_list if entry.module_name == module_name
]
if filtered:
result[event_type_value] = filtered
return result
def get_handler_module(self, handler: EventHandler) -> str | None:
"""
Reverse-lookup which module registered a given handler.
Scans all event types since handlers are stored by event type,
not by module.
:param handler: The handler function to look up.
:return: The module name if found, None otherwise.
"""
return self._handler_registry.get_handler_module(handler)
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for ``@on_event``-decorated functions and register them.
This is the import-phase entry point: the module loader calls it once
per module. Decorator attributes are read here and passed as explicit
parameters to ``register()``, so no decorator markers are used after
this point.
:param module: The loaded Python module to scan.
:param module_name: Name of the module (for ownership tracking).
"""
self._handler_registry.register_from_module(module, module_name)
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all handlers registered by a specific module.
Used during module teardown to clean up all of a module's handlers
in one call, regardless of which event types they were registered for.
:param module_name: The module whose handlers should be removed.
:return: Number of handlers removed.
"""
return self._handler_registry.unregister_by_module(module_name)
async def dispatch(self, event_type: EventType, event: Event) -> None:
"""
Dispatch an event to handlers sequentially by priority, then to commands.
:param event_type: The type of event to dispatch.
:param event: The parsed event instance.
"""
log_event(event_type, event)
handler_entries = self._handler_registry.get(event_type)
# Shared state for propagation control (all handlers see the same instance).
propagation = PropagationState()
# Phase 1: Event handlers (sequential, sorted by priority descending).
if handler_entries:
# Sort handlers by priority (highest first).
# Python's sort is stable, so handlers at the same priority
# run in their original registration order.
sorted_handlers = sorted(
handler_entries,
key=lambda entry: entry.priority,
reverse=True,
)
logger.debug(
f"Dispatching {event_type} to {len(sorted_handlers)} handler(s)."
)
for handler, module_name, _priority in sorted_handlers:
# Has propagation been stopped by a previous handler?
if propagation.stopped:
reason = propagation.reason
logger.debug(
f"Propagation stopped{': ' + reason if reason else '.'}"
)
break
await self._call_handler(
handler, event, event_type, module_name, propagation
)
else:
logger.debug(f"No handlers registered for event type: {event_type}")
# Phase 2: Command dispatch (CHAT events only, if not cancelled).
if event_type == EventType.CHAT and isinstance(event, ChatEvent):
# Has propagation been stopped?
if propagation.stopped:
reason = propagation.reason
logger.debug(
f"Command dispatch skipped{': ' + reason if reason else '.'}"
)
else:
try:
await self._command_dispatch(event)
except Exception as e:
logger.exception(f"Command dispatch failed: {e}")
async def _call_handler(
self,
handler: EventHandler,
event: Event,
event_type: EventType,
module_name: str,
propagation: PropagationState,
) -> None:
"""
Call a single handler with timeout enforcement and transaction management.
:param handler: The handler function to call.
:param event: The event to pass to the handler.
:param event_type: The type of event being dispatched.
:param module_name: The module that owns this handler.
:param propagation: Shared propagation state for this dispatch cycle.
"""
from ..api.context import EventContext
handler_name = handler.__name__
logger.debug(f"Calling handler: {handler_name} from module: {module_name}")
module_ctx = self._get_module_context(module_name)
ctx: EventContext[Event] = EventContext(
event=event,
module=module_ctx,
_propagation=propagation,
)
# Call the handler with timeout enforcement to prevent
# runaway handlers from blocking everything.
# The checkout acquires a pooled connection for the
# duration of this handler invocation.
async with module_ctx.storage._checkout():
try:
start = time.perf_counter()
await asyncio.wait_for(handler(ctx), timeout=self._handler_timeout)
elapsed = (time.perf_counter() - start) * 1000
# Handler succeeded, commit any database changes.
await module_ctx.storage._commit()
logger.debug(f"Handler '{handler_name}' completed in {elapsed:.1f}ms.")
except TimeoutError:
# Handler took too long. Rollback any partial changes.
await module_ctx.storage._rollback()
logger.warning(
f"Handler '{handler_name}' from module '{module_name}' "
f"cancelled after {self._handler_timeout}s timeout."
)
except Exception as e:
# Handler raised an exception. Rollback any partial changes.
await module_ctx.storage._rollback()
logger.exception(
f"Handler '{handler_name}' from module "
f"'{module_name}' raised exception: {e}"
)
class ModuleEvents:
"""
Module-scoped wrapper around EventDispatcher.
This wrapper auto-supplies the module name for registration operations,
so modules don't need to pass their own name back into the API.
Follows the same pattern as ModuleCommands and ModuleRoutes.
"""
def __init__(self, dispatcher: EventDispatcher, module_name: str) -> None:
"""
Initialize the module-scoped handler wrapper.
:param dispatcher: The EventDispatcher that owns the handler registry.
:param module_name: The name of the module using this wrapper.
"""
self._dispatcher = dispatcher
self._module_name = module_name
@property
def module_events(self) -> EventHandlerMap:
"""Handlers registered by this module only, grouped by event type."""
return self._dispatcher.get_by_module(self._module_name)
def register(
self,
handler: EventHandler,
event_types: tuple[EventType, ...],
priority: int = Priority.NORMAL,
) -> None:
"""
Register an event handler for this module.
The module name is automatically supplied.
:param handler: The handler function to register.
:param event_types: Tuple of EventType values the handler responds to.
:param priority: Dispatch priority (higher runs first).
Defaults to Priority.NORMAL.
"""
self._dispatcher.register(
handler=handler,
event_types=event_types,
module_name=self._module_name,
priority=priority,
)
def unregister(self, handler: EventHandler) -> bool:
"""
Unregister a handler from all event types it is registered for.
Only handlers registered by this module can be unregistered.
:param handler: The handler function to unregister.
:return: True if handler was found and removed, False if not found or not owned.
"""
# Only allow unregistering handlers owned by this module.
if self._dispatcher.get_handler_module(handler) != self._module_name:
return False
return self._dispatcher.unregister(handler)
def get(self, event_type: EventType) -> list[HandlerEntry]:
"""
Get handlers registered by this module for a specific event type.
:param event_type: The event type to look up.
:return: List of (handler, module_name, priority) tuples for this module only.
"""
return self._dispatcher.get_by_module(self._module_name).get(
event_type.value, []
)
+545
View File
@@ -0,0 +1,545 @@
# 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.
"""Route registry, module-scoped wrapper, and dispatcher.
Internal infrastructure for managing HTTP route registration and dispatch.
"""
import asyncio
import logging
import time
from typing import TYPE_CHECKING, cast
from aiohttp import web
from aiohttp.web import DynamicResource
from ..api.routes import RouteHandler, RouteInfo, RouteMark
if TYPE_CHECKING:
from collections.abc import Callable
from types import ModuleType
from ..api.context import ModuleContext
logger = logging.getLogger("owlbot.web")
class RouteRegistry:
"""
Holds all registered routes for a bot instance.
Routes are namespaced by module to prevent conflicts. Supports path
patterns using aiohttp's ``{name}`` and ``{name:regex}`` syntax via
``DynamicResource`` for pattern compilation and matching.
"""
def __init__(self) -> None:
"""Initialize an empty route registry."""
# Ordered list of (DynamicResource, RouteInfo) for pattern matching.
# DynamicResource handles both plain and parameterized paths uniformly.
self._routes: list[tuple[DynamicResource, RouteInfo]] = []
# Maps module_name -> list of full_paths (for cleanup).
self._module_routes: dict[str, list[str]] = {}
logger.debug("RouteRegistry initialized.")
def register(
self,
path: str,
handler: RouteHandler,
*,
methods: list[str] | None = None,
module_name: str,
) -> RouteInfo:
"""
Register a route handler.
:param path: URL path relative to module namespace. Supports
``{name}`` and ``{name:regex}`` patterns.
:param handler: Async function to handle the route.
:param methods: List of HTTP methods. Default: ["GET"].
:param module_name: Name of the module registering this route.
:return: RouteInfo for the registered route.
:raises ValueError: If route conflicts with existing route.
"""
if methods is None:
methods = ["GET"]
if not path.startswith("/"):
path = "/" + path
full_path = f"/owlbot/{module_name}{path}"
if any(info.full_path == full_path for _, info in self._routes):
raise ValueError(f"Route '{full_path}' is already registered")
info = RouteInfo(
path=path,
full_path=full_path,
methods=frozenset(m.upper() for m in methods),
handler=handler,
module_name=module_name,
)
resource = DynamicResource(full_path)
self._routes.append((resource, info))
if module_name not in self._module_routes:
self._module_routes[module_name] = []
self._module_routes[module_name].append(full_path)
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
module_logger.debug(
f"Registered route '{full_path}' [{', '.join(info.methods)}]."
)
return info
def unregister(self, full_path: str) -> bool:
"""
Unregister a route by its full path.
:param full_path: The full route path including namespace.
:return: True if route was found and removed, False otherwise.
"""
for i, (_, info) in enumerate(self._routes):
if info.full_path == full_path:
self._routes.pop(i)
if info.module_name in self._module_routes:
self._module_routes[info.module_name] = [
p
for p in self._module_routes[info.module_name]
if p != full_path
]
module_logger = logging.getLogger(
f"owlbot.modules.{info.module_name}.routes"
)
module_logger.debug(f"Unregistered route '{full_path}'.")
return True
return False
def get(self, full_path: str) -> RouteInfo | None:
"""
Look up a route by its full path (exact match on registered pattern).
:param full_path: The full route path including namespace.
:return: RouteInfo if found, None otherwise.
"""
for _, info in self._routes:
if info.full_path == full_path:
return info
return None
def match(self, full_path: str) -> tuple[RouteInfo, dict[str, str]] | None:
"""
Match a request path against registered routes.
Scans routes in registration order (first match wins). Uses
``DynamicResource._match()`` for both plain and parameterized paths.
:param full_path: The request path to match.
:return: Tuple of (RouteInfo, match_info dict) if matched, None otherwise.
"""
for resource, info in self._routes:
match_dict = resource._match(full_path) # no public API alternative
if match_dict is not None:
return info, match_dict
return None
def get_all(self) -> dict[str, RouteInfo]:
"""
Get all registered routes.
:return: Dict mapping full paths to RouteInfo.
"""
return {info.full_path: info for _, info in self._routes}
def get_by_module(self, module_name: str) -> list[RouteInfo]:
"""
Get all routes registered by a specific module.
:param module_name: The module name.
:return: List of RouteInfo for that module.
"""
paths = set(self._module_routes.get(module_name, []))
return [info for _, info in self._routes if info.full_path in paths]
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all routes registered by a specific module.
:param module_name: The module whose routes should be removed.
:return: Number of routes removed.
"""
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
paths = list(self._module_routes.get(module_name, []))
if not paths:
return 0
module_logger.debug(f"Unregistering all routes ({len(paths)} total).")
count = 0
for path in paths:
if self.unregister(path):
count += 1
self._module_routes.pop(module_name, None)
return count
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for @on_route-decorated functions and register them.
Looks for functions with the ``_owlbot_route`` attribute set by
the ``@on_route`` decorator and registers each one.
:param module: The loaded Python module to scan.
:param module_name: Name of the module (for ownership tracking).
"""
for obj in vars(module).values():
if callable(obj):
route_info = getattr(obj, "_owlbot_route", None)
if route_info is not None:
mark = cast("RouteMark", route_info)
self.register(
path=mark["path"],
handler=obj,
methods=mark["methods"],
module_name=module_name,
)
class RouteDispatcher:
"""
Dispatches HTTP requests to registered module route handlers.
Looks up routes in the RouteRegistry, validates methods, creates
RouteContext, and calls the handler with timeout and transaction management.
"""
def __init__(
self,
get_module_context: Callable[[str], ModuleContext],
handler_timeout: float,
) -> None:
"""
Initialize the route dispatcher.
Creates and owns a :class:`RouteRegistry` internally.
:param get_module_context: Callable that looks up a
ModuleContext by module name.
:param handler_timeout: Timeout for route handlers in seconds.
"""
self._route_registry = RouteRegistry()
self._get_module_context = get_module_context
self._handler_timeout = handler_timeout
def register(
self,
path: str,
handler: RouteHandler,
*,
methods: list[str] | None = None,
module_name: str,
) -> RouteInfo:
"""
Register a route handler.
Delegates to the internal RouteRegistry.
:param path: URL path relative to module namespace.
:param handler: Async function to handle the route.
:param methods: List of HTTP methods. Default: ["GET"].
:param module_name: Name of the module registering this route.
:return: RouteInfo for the registered route.
:raises ValueError: If route conflicts with existing route.
"""
return self._route_registry.register(
path=path,
handler=handler,
methods=methods,
module_name=module_name,
)
def unregister(self, full_path: str) -> bool:
"""
Unregister a route by its full path.
Delegates to the internal RouteRegistry.
:param full_path: The full route path including namespace.
:return: True if route was found and removed, False otherwise.
"""
return self._route_registry.unregister(full_path)
def get(self, full_path: str) -> RouteInfo | None:
"""
Look up a route by its full path.
Delegates to the internal RouteRegistry.
:param full_path: The full route path including namespace.
:return: RouteInfo if found, None otherwise.
"""
return self._route_registry.get(full_path)
def get_by_module(self, module_name: str) -> list[RouteInfo]:
"""
Get all routes registered by a specific module.
Delegates to the internal RouteRegistry.
:param module_name: The module name.
:return: List of RouteInfo for that module.
"""
return self._route_registry.get_by_module(module_name)
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for @on_route-decorated functions and register them.
Delegates to the internal RouteRegistry.
:param module: The loaded Python module to scan.
:param module_name: Name of the module (for ownership tracking).
"""
self._route_registry.register_from_module(module, module_name)
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all routes registered by a specific module.
Delegates to the internal RouteRegistry.
:param module_name: The module whose routes should be removed.
:return: Number of routes removed.
"""
return self._route_registry.unregister_by_module(module_name)
async def dispatch(self, request: web.Request) -> web.Response:
"""
Dispatch an HTTP request to the appropriate module route handler.
Extracts module_name and path from the URL, matches it against
registered routes (supporting path patterns), validates the HTTP
method, and calls the handler.
:param request: The aiohttp request object.
:return: HTTP response.
"""
module_name = request.match_info["module_name"]
path = request.match_info.get("path", "")
relative_path = f"/{path}" if path else "/"
full_path = f"/owlbot/{module_name}{relative_path}"
result = self._route_registry.match(full_path)
mod_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
if result is None:
mod_logger.debug(f"No route registered for '{full_path}'.")
return web.Response(status=404)
route_info, match_info = result
if request.method not in route_info.methods:
allowed = ", ".join(sorted(route_info.methods))
mod_logger.debug(
f"Method {request.method} not allowed for '{full_path}' "
f"(allowed: {allowed})"
)
return web.Response(status=405, headers={"Allow": allowed})
return await self._handle_module_route(request, route_info, match_info)
async def _handle_module_route(
self,
request: web.Request,
route_info: RouteInfo,
match_info: dict[str, str] | None = None,
) -> web.Response:
"""
Handle an HTTP request to a module-registered route.
:param request: The aiohttp request object.
:param route_info: Information about the registered route.
:param match_info: Captured path parameters from pattern matching.
:return: HTTP response.
"""
# Import here to avoid circular imports at module load time.
from ..api.context import RouteContext
module_name = route_info.module_name
mod_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
module_ctx = self._get_module_context(module_name)
ctx = RouteContext(
request=request,
module=module_ctx,
match_info=match_info if match_info is not None else {},
)
logger.debug(
f"Calling route handler: {route_info.full_path} from module: {module_name}"
)
# The checkout acquires a pooled connection for the
# duration of this route invocation.
async with module_ctx.storage._checkout():
try:
start = time.perf_counter()
result = await asyncio.wait_for(
route_info.handler(ctx), timeout=self._handler_timeout
)
elapsed = (time.perf_counter() - start) * 1000
# Handler succeeded, commit any database changes.
await module_ctx.storage._commit()
mod_logger.debug(
f"Route handler '{route_info.full_path}' "
f"completed in {elapsed:.1f}ms."
)
if result is None:
return web.Response(status=204) # No Content.
elif isinstance(result, web.Response):
return result
elif isinstance(result, dict):
return web.json_response(result)
else: # pragma: no branch — defensive against untyped handlers
mod_logger.error( # type: ignore[unreachable]
f"Route handler '{route_info.full_path}' returned "
f"unsupported type: {type(result).__name__}"
)
return web.Response(status=500)
except TimeoutError:
await module_ctx.storage._rollback()
mod_logger.warning(
f"Route handler '{route_info.full_path}' timed out "
f"after {self._handler_timeout}s."
)
return web.Response(status=500)
except Exception as e:
await module_ctx.storage._rollback()
mod_logger.exception(
f"Route handler '{route_info.full_path}' raised exception: {e}"
)
return web.Response(status=500)
class ModuleRoutes:
"""
Module-scoped wrapper around RouteDispatcher.
This wrapper auto-supplies the module name for route operations,
so modules don't need to know the internal routing namespace.
Follows the same pattern as ModuleCommands.
"""
def __init__(
self, dispatcher: RouteDispatcher, module_name: str, public_base_url: str
) -> None:
"""
Initialize the module-scoped routes wrapper.
:param dispatcher: The RouteDispatcher that owns the route registry.
:param module_name: The name of the module using this wrapper.
:param public_base_url: The public base URL for building route URLs.
"""
self._dispatcher = dispatcher
self._module_name = module_name
self._public_base_url = public_base_url
@property
def module_routes(self) -> list[RouteInfo]:
"""Routes registered by this module."""
return self._dispatcher.get_by_module(self._module_name)
def url_for(self, path: str) -> str:
"""
Build a public URL for a route registered by this module.
:param path: The route path (e.g., "/list").
:return: Full public URL (e.g., "http://host/owlbot/quotes/list").
"""
return f"{self._public_base_url}{self._full_path(path)}"
def register(
self,
path: str,
handler: RouteHandler,
*,
methods: list[str] | None = None,
) -> RouteInfo:
"""
Register a route handler for this module.
The module name is automatically supplied.
:param path: URL path relative to module namespace (e.g., "/stats").
:param handler: Async function to handle the route.
:param methods: List of HTTP methods (e.g., ["GET"]). Default: ["GET"].
:return: RouteInfo for the registered route.
:raises ValueError: If route conflicts with existing route.
"""
return self._dispatcher.register(
path=path,
handler=handler,
methods=methods,
module_name=self._module_name,
)
def unregister(self, path: str) -> bool:
"""
Unregister a route by its relative path.
:param path: Relative route path (e.g., "/stats").
:return: True if route was found and removed, False otherwise.
"""
return self._dispatcher.unregister(self._full_path(path))
def get(self, path: str) -> RouteInfo | None:
"""
Look up a route by its relative path.
:param path: Relative route path (e.g., "/stats").
:return: RouteInfo if found, None otherwise.
"""
return self._dispatcher.get(self._full_path(path))
def exists(self, path: str) -> bool:
"""
Check if a route is registered at the given relative path.
:param path: Relative route path (e.g., "/stats").
:return: True if the route exists, False otherwise.
"""
return self.get(path) is not None
def _full_path(self, path: str) -> str:
"""
Normalize a relative path into the full namespaced path.
:param path: Relative route path (e.g., "/list" or "list").
:return: Full path (e.g., "/owlbot/quotes/list").
"""
if not path.startswith("/"):
path = "/" + path
return f"/owlbot/{self._module_name}{path}"
+80
View File
@@ -0,0 +1,80 @@
[project]
name = "owlbot"
dynamic = ["version"]
description = "An Owncast chat bot with a modular event-driven architecture"
requires-python = ">=3.14"
license = "Apache-2.0"
authors = [
{ name = "Logan Fick" },
]
dependencies = [
"aiohttp>=3.13.3",
"aiosqlite>=0.22.1",
"cronsim>=2.7",
"jinja2>=3.1.6",
"pyyaml>=6.0.3",
]
[project.scripts]
owlbot = "owlbot.__main__:main"
[project.urls]
Repository = "https://git.logal.dev/LogalDeveloper/Owlbot"
[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"mypy>=1.18.1",
"types-PyYAML>=6.0.12.20250915",
"ruff>=0.15.1",
]
[tool.uv]
extra-index-url = ["https://git.logal.dev/api/packages/LogalDeveloper/pypi/simple/"]
publish-url = "https://git.logal.dev/api/packages/LogalDeveloper/pypi"
[build-system]
requires = ["hatchling>=1.28.0", "hatch-vcs>=0.5.0"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.targets.wheel]
packages = ["owlbot"]
[tool.hatch.build.targets.wheel.force-include]
"config.example.yaml" = "owlbot/_defaults/config.example.yaml"
[tool.hatch.build.hooks.vcs]
version-file = "owlbot/_version.py"
[tool.mypy]
python_version = "3.14"
strict = true
warn_unreachable = true
explicit_package_bases = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.ruff]
target-version = "py314"
extend-exclude = ["owlbot/_version.py"] # auto-generated by hatch-vcs
[tool.ruff.lint]
select = [
"F", # Pyflakes
"E", # pycodestyle errors
"W", # pycodestyle warnings
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"RUF", # Ruff-specific rules
]
[tool.ruff.lint.per-file-ignores]
"owlbot/__init__.py" = ["E402"] # constants defined before imports intentionally
File diff suppressed because it is too large Load Diff
Generated
+576
View File
@@ -0,0 +1,576 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
]
[[package]]
name = "aiohttp"
version = "3.13.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cronsim"
version = "2.7"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/1a/02f105147f7f2e06ed4f734ff5a6439590bb275a53dd91fc73df6312298a/cronsim-2.7-py3-none-any.whl", hash = "sha256:1e1431fa08c51dc7f72e67e571c7c7a09af26420169b607badd4ca9677ffad1e", size = 14213, upload-time = "2025-10-21T16:38:20.431Z" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "librt"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/3f/4ca7dd7819bf8ff303aca39c3c60e5320e46e766ab7f7dd627d3b9c11bdf/librt-0.8.0.tar.gz", hash = "sha256:cb74cdcbc0103fc988e04e5c58b0b31e8e5dd2babb9182b6f9490488eb36324b", size = 177306, upload-time = "2026-02-12T14:53:54.743Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/13/79ebfe30cd273d7c0ce37a5f14dc489c5fb8b722a008983db2cfd57270bb/librt-0.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:421765e8c6b18e64d21c8ead315708a56fc24f44075059702e421d164575fdda", size = 66078, upload-time = "2026-02-12T14:53:06.085Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8f/d11eca40b62a8d5e759239a80636386ef88adecb10d1a050b38cc0da9f9e/librt-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:48f84830a8f8ad7918afd743fd7c4eb558728bceab7b0e38fd5a5cf78206a556", size = 68309, upload-time = "2026-02-12T14:53:07.121Z" },
{ url = "https://files.pythonhosted.org/packages/9c/b4/f12ee70a3596db40ff3c88ec9eaa4e323f3b92f77505b4d900746706ec6a/librt-0.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f09d4884f882baa39a7e36bbf3eae124c4ca2a223efb91e567381d1c55c6b06", size = 196804, upload-time = "2026-02-12T14:53:08.164Z" },
{ url = "https://files.pythonhosted.org/packages/8b/7e/70dbbdc0271fd626abe1671ad117bcd61a9a88cdc6a10ccfbfc703db1873/librt-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:693697133c3b32aa9b27f040e3691be210e9ac4d905061859a9ed519b1d5a376", size = 206915, upload-time = "2026-02-12T14:53:09.333Z" },
{ url = "https://files.pythonhosted.org/packages/79/13/6b9e05a635d4327608d06b3c1702166e3b3e78315846373446cf90d7b0bf/librt-0.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5512aae4648152abaf4d48b59890503fcbe86e85abc12fb9b096fe948bdd816", size = 221200, upload-time = "2026-02-12T14:53:10.68Z" },
{ url = "https://files.pythonhosted.org/packages/35/6c/e19a3ac53e9414de43a73d7507d2d766cd22d8ca763d29a4e072d628db42/librt-0.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:995d24caa6bbb34bcdd4a41df98ac6d1af637cfa8975cb0790e47d6623e70e3e", size = 214640, upload-time = "2026-02-12T14:53:12.342Z" },
{ url = "https://files.pythonhosted.org/packages/30/f0/23a78464788619e8c70f090cfd099cce4973eed142c4dccb99fc322283fd/librt-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b9aef96d7593584e31ef6ac1eb9775355b0099fee7651fae3a15bc8657b67b52", size = 221980, upload-time = "2026-02-12T14:53:13.603Z" },
{ url = "https://files.pythonhosted.org/packages/03/32/38e21420c5d7aa8a8bd2c7a7d5252ab174a5a8aaec8b5551968979b747bf/librt-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4f6e975377fbc4c9567cb33ea9ab826031b6c7ec0515bfae66a4fb110d40d6da", size = 215146, upload-time = "2026-02-12T14:53:14.8Z" },
{ url = "https://files.pythonhosted.org/packages/bb/00/bd9ecf38b1824c25240b3ad982fb62c80f0a969e6679091ba2b3afb2b510/librt-0.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:daae5e955764be8fd70a93e9e5133c75297f8bce1e802e1d3683b98f77e1c5ab", size = 215203, upload-time = "2026-02-12T14:53:16.087Z" },
{ url = "https://files.pythonhosted.org/packages/b9/60/7559bcc5279d37810b98d4a52616febd7b8eef04391714fd6bdf629598b1/librt-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7bd68cebf3131bb920d5984f75fe302d758db33264e44b45ad139385662d7bc3", size = 237937, upload-time = "2026-02-12T14:53:17.236Z" },
{ url = "https://files.pythonhosted.org/packages/41/cc/be3e7da88f1abbe2642672af1dc00a0bccece11ca60241b1883f3018d8d5/librt-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1e6811cac1dcb27ca4c74e0ca4a5917a8e06db0d8408d30daee3a41724bfde7a", size = 50685, upload-time = "2026-02-12T14:53:18.888Z" },
{ url = "https://files.pythonhosted.org/packages/38/27/e381d0df182a8f61ef1f6025d8b138b3318cc9d18ad4d5f47c3bf7492523/librt-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:178707cda89d910c3b28bf5aa5f69d3d4734e0f6ae102f753ad79edef83a83c7", size = 57872, upload-time = "2026-02-12T14:53:19.942Z" },
{ url = "https://files.pythonhosted.org/packages/c5/0c/ca9dfdf00554a44dea7d555001248269a4bab569e1590a91391feb863fa4/librt-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3e8b77b5f54d0937b26512774916041756c9eb3e66f1031971e626eea49d0bf4", size = 48056, upload-time = "2026-02-12T14:53:21.473Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ed/6cc9c4ad24f90c8e782193c7b4a857408fd49540800613d1356c63567d7b/librt-0.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:789911e8fa40a2e82f41120c936b1965f3213c67f5a483fc5a41f5839a05dcbb", size = 68307, upload-time = "2026-02-12T14:53:22.498Z" },
{ url = "https://files.pythonhosted.org/packages/84/d8/0e94292c6b3e00b6eeea39dd44d5703d1ec29b6dafce7eea19dc8f1aedbd/librt-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2b37437e7e4ef5e15a297b36ba9e577f73e29564131d86dd75875705e97402b5", size = 70999, upload-time = "2026-02-12T14:53:23.603Z" },
{ url = "https://files.pythonhosted.org/packages/0e/f4/6be1afcbdeedbdbbf54a7c9d73ad43e1bf36897cebf3978308cd64922e02/librt-0.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:671a6152edf3b924d98a5ed5e6982ec9cb30894085482acadce0975f031d4c5c", size = 220782, upload-time = "2026-02-12T14:53:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f0/8d/f306e8caa93cfaf5c6c9e0d940908d75dc6af4fd856baa5535c922ee02b1/librt-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8992ca186a1678107b0af3d0c9303d8c7305981b9914989b9788319ed4d89546", size = 235420, upload-time = "2026-02-12T14:53:27.047Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f2/65d86bd462e9c351326564ca805e8457442149f348496e25ccd94583ffa2/librt-0.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:001e5330093d887b8b9165823eca6c5c4db183fe4edea4fdc0680bbac5f46944", size = 246452, upload-time = "2026-02-12T14:53:28.341Z" },
{ url = "https://files.pythonhosted.org/packages/03/94/39c88b503b4cb3fcbdeb3caa29672b6b44ebee8dcc8a54d49839ac280f3f/librt-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d920789eca7ef71df7f31fd547ec0d3002e04d77f30ba6881e08a630e7b2c30e", size = 238891, upload-time = "2026-02-12T14:53:29.625Z" },
{ url = "https://files.pythonhosted.org/packages/e3/c6/6c0d68190893d01b71b9569b07a1c811e280c0065a791249921c83dc0290/librt-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:82fb4602d1b3e303a58bfe6165992b5a78d823ec646445356c332cd5f5bbaa61", size = 250249, upload-time = "2026-02-12T14:53:30.93Z" },
{ url = "https://files.pythonhosted.org/packages/52/7a/f715ed9e039035d0ea637579c3c0155ab3709a7046bc408c0fb05d337121/librt-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4d3e38797eb482485b486898f89415a6ab163bc291476bd95712e42cf4383c05", size = 240642, upload-time = "2026-02-12T14:53:32.174Z" },
{ url = "https://files.pythonhosted.org/packages/c2/3c/609000a333debf5992efe087edc6467c1fdbdddca5b610355569bbea9589/librt-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a905091a13e0884701226860836d0386b88c72ce5c2fdfba6618e14c72be9f25", size = 239621, upload-time = "2026-02-12T14:53:33.39Z" },
{ url = "https://files.pythonhosted.org/packages/b9/df/87b0673d5c395a8f34f38569c116c93142d4dc7e04af2510620772d6bd4f/librt-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:375eda7acfce1f15f5ed56cfc960669eefa1ec8732e3e9087c3c4c3f2066759c", size = 262986, upload-time = "2026-02-12T14:53:34.617Z" },
{ url = "https://files.pythonhosted.org/packages/09/7f/6bbbe9dcda649684773aaea78b87fff4d7e59550fbc2877faa83612087a3/librt-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:2ccdd20d9a72c562ffb73098ac411de351b53a6fbb3390903b2d33078ef90447", size = 51328, upload-time = "2026-02-12T14:53:36.15Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f3/e1981ab6fa9b41be0396648b5850267888a752d025313a9e929c4856208e/librt-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:25e82d920d4d62ad741592fcf8d0f3bda0e3fc388a184cb7d2f566c681c5f7b9", size = 58719, upload-time = "2026-02-12T14:53:37.183Z" },
{ url = "https://files.pythonhosted.org/packages/94/d1/433b3c06e78f23486fe4fdd19bc134657eb30997d2054b0dbf52bbf3382e/librt-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:92249938ab744a5890580d3cb2b22042f0dce71cdaa7c1369823df62bedf7cbc", size = 48753, upload-time = "2026-02-12T14:53:38.539Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "multidict"
version = "6.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "mypy"
version = "1.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
{ url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
{ url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
{ url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
{ url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "owlbot"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "aiosqlite" },
{ name = "cronsim" },
{ name = "jinja2" },
{ name = "pyyaml" },
]
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "types-pyyaml" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.3" },
{ name = "aiosqlite", specifier = ">=0.22.1" },
{ name = "cronsim", specifier = ">=2.7" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "pyyaml", specifier = ">=6.0.3" },
]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=1.18.1" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "ruff", specifier = ">=0.15.1" },
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "propcache"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
{ url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
{ url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
{ url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
{ url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
{ url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
{ url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
{ url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
{ url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
{ url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
{ url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
{ url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
{ url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
{ url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
{ url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
{ url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
{ url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
{ url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
{ url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
{ url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
{ url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
{ url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
{ url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
{ url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
{ url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20250915"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "yarl"
version = "1.22.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" },
{ url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" },
{ url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" },
{ url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" },
{ url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" },
{ url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" },
{ url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" },
{ url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" },
{ url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" },
{ url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" },
{ url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" },
{ url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" },
{ url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" },
{ url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" },
{ url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" },
{ url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" },
{ url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" },
{ url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" },
{ url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" },
{ url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" },
{ url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" },
{ url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" },
{ url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" },
{ url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" },
{ url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
]