Files
Owlbot/owlbot/__main__.py
T
LogalDeveloper 0ff3c7a6b4
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
Enabled all Ruff lint rules and resolved findings with justified inline suppressions.
2026-04-13 15:31:06 -04:00

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()