CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 14s
CI / Tests (Python 3.13) (push) Successful in 14s
CI / Tests (Python 3.14) (push) Successful in 11s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
300 lines
8.8 KiB
Python
300 lines
8.8 KiB
Python
# 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 contextlib
|
|
import importlib.resources
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import uvloop
|
|
|
|
from . import __version__
|
|
from .api.config import Config
|
|
from .bot import Owlbot, StartupError
|
|
|
|
|
|
def _sd_notify(state: str) -> None:
|
|
"""Send a notification to systemd, if running under a systemd service."""
|
|
addr = os.environ.get("NOTIFY_SOCKET")
|
|
if not addr:
|
|
return
|
|
try:
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
|
|
if addr[0] == "@":
|
|
addr = "\0" + addr[1:]
|
|
sock.sendto(state.encode(), addr)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _watchdog_interval() -> float | None:
|
|
"""Return the watchdog ping interval in seconds, or None if disabled.
|
|
|
|
systemd sets WATCHDOG_USEC to the total timeout. We ping at half that
|
|
interval so there's margin for scheduling jitter.
|
|
"""
|
|
usec = os.environ.get("WATCHDOG_USEC")
|
|
if not usec:
|
|
return None
|
|
try:
|
|
return int(usec) / 1_000_000 / 2
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
async def _watchdog_loop(interval: float, stop: asyncio.Event) -> None:
|
|
"""Periodically send WATCHDOG=1 until *stop* is set."""
|
|
while not stop.is_set():
|
|
_sd_notify("WATCHDOG=1")
|
|
with contextlib.suppress(TimeoutError):
|
|
await asyncio.wait_for(stop.wait(), timeout=interval)
|
|
|
|
|
|
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()
|
|
|
|
_cli_override_map = {
|
|
"host": "host",
|
|
"port": "port",
|
|
"modules": "modules_dir",
|
|
"storage_dir": "storage_dir",
|
|
"log_dir": "log_dir",
|
|
}
|
|
overrides: dict[str, Any] = {
|
|
v: getattr(args, k)
|
|
for k, v in _cli_override_map.items()
|
|
if getattr(args, k) is not None
|
|
}
|
|
|
|
if args.webhook_path:
|
|
try:
|
|
config = Config(args.config, overrides=overrides)
|
|
except Exception as e: # noqa: BLE001 # CLI boundary; any config error should print and exit
|
|
print(f"Error loading config: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
# Framework-internal raw config check; private to module authors.
|
|
if config._data.get("owlbot", {}).get("public_base_url"): # noqa: SLF001
|
|
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("Log level: %s", logging.getLevelName(log_level))
|
|
|
|
try:
|
|
bot = Owlbot(
|
|
config_path=args.config,
|
|
overrides=overrides,
|
|
skip_api_check=args.skip_api_check,
|
|
)
|
|
except StartupError:
|
|
logger.exception("Bot initialization failed.")
|
|
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("File logging enabled: %s", bot.config.log_dir / "owlbot.log")
|
|
except OSError as e:
|
|
logger.warning("Could not set up file logging: %s", 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:
|
|
_sd_notify("READY=1")
|
|
|
|
wd_interval = _watchdog_interval()
|
|
wd_task: asyncio.Task[None] | None = None
|
|
if wd_interval is not None:
|
|
logger.info(
|
|
"systemd watchdog enabled (pinging every %.1fs).", wd_interval
|
|
)
|
|
wd_task = asyncio.create_task(
|
|
_watchdog_loop(wd_interval, stop_event),
|
|
name="systemd-watchdog",
|
|
)
|
|
|
|
await stop_event.wait()
|
|
logger.info("Received shutdown signal.")
|
|
_sd_notify("STOPPING=1")
|
|
|
|
if wd_task is not None:
|
|
wd_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await wd_task
|
|
|
|
try:
|
|
uvloop.run(_run())
|
|
except StartupError:
|
|
logger.exception("Startup failed.")
|
|
sys.exit(1)
|
|
except Exception:
|
|
logger.exception("Unexpected error during Owlbot execution.")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|