Initial commit.
This commit is contained in:
+32
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "docs"]
|
||||
path = docs
|
||||
url = https://git.logal.dev/LogalDeveloper/Owlbot.wiki.git
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
+202
@@ -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.
|
||||
@@ -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
@@ -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__",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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")
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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}'.")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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, []
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user