Enabled all Ruff lint rules and resolved findings with justified inline suppressions.
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
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
This commit is contained in:
+23
-21
@@ -177,25 +177,27 @@ Examples:
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
overrides: dict[str, Any] = {}
|
_cli_override_map = {
|
||||||
if args.host is not None:
|
"host": "host",
|
||||||
overrides["host"] = args.host
|
"port": "port",
|
||||||
if args.port is not None:
|
"modules": "modules_dir",
|
||||||
overrides["port"] = args.port
|
"storage_dir": "storage_dir",
|
||||||
if args.modules is not None:
|
"log_dir": "log_dir",
|
||||||
overrides["modules_dir"] = args.modules
|
}
|
||||||
if args.storage_dir is not None:
|
overrides: dict[str, Any] = {
|
||||||
overrides["storage_dir"] = args.storage_dir
|
v: getattr(args, k)
|
||||||
if args.log_dir is not None:
|
for k, v in _cli_override_map.items()
|
||||||
overrides["log_dir"] = args.log_dir
|
if getattr(args, k) is not None
|
||||||
|
}
|
||||||
|
|
||||||
if args.webhook_path:
|
if args.webhook_path:
|
||||||
try:
|
try:
|
||||||
config = Config(args.config, overrides=overrides)
|
config = Config(args.config, overrides=overrides)
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001 # CLI boundary; any config error should print and exit
|
||||||
print(f"Error loading config: {e}", file=sys.stderr)
|
print(f"Error loading config: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if config._data.get("owlbot", {}).get("public_base_url"):
|
# 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
|
base = config.public_base_url
|
||||||
else:
|
else:
|
||||||
base = f"http://{config.host}:{config.port}"
|
base = f"http://{config.host}:{config.port}"
|
||||||
@@ -225,7 +227,7 @@ Examples:
|
|||||||
|
|
||||||
logger = logging.getLogger("owlbot")
|
logger = logging.getLogger("owlbot")
|
||||||
|
|
||||||
logger.info(f"Log level: {logging.getLevelName(log_level)}")
|
logger.info("Log level: %s", logging.getLevelName(log_level))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bot = Owlbot(
|
bot = Owlbot(
|
||||||
@@ -233,8 +235,8 @@ Examples:
|
|||||||
overrides=overrides,
|
overrides=overrides,
|
||||||
skip_api_check=args.skip_api_check,
|
skip_api_check=args.skip_api_check,
|
||||||
)
|
)
|
||||||
except StartupError as e:
|
except StartupError:
|
||||||
logger.error(str(e))
|
logger.exception("Bot initialization failed.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if bot.config.log_dir is not None:
|
if bot.config.log_dir is not None:
|
||||||
@@ -249,9 +251,9 @@ Examples:
|
|||||||
logging.Formatter(log_format, datefmt="%Y-%m-%d %H:%M:%S"),
|
logging.Formatter(log_format, datefmt="%Y-%m-%d %H:%M:%S"),
|
||||||
)
|
)
|
||||||
logging.getLogger().addHandler(file_handler)
|
logging.getLogger().addHandler(file_handler)
|
||||||
logger.info(f"File logging enabled: {bot.config.log_dir / 'owlbot.log'}")
|
logger.info("File logging enabled: %s", bot.config.log_dir / "owlbot.log")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning(f"Could not set up file logging: {e}")
|
logger.warning("Could not set up file logging: %s", e)
|
||||||
|
|
||||||
async def _run() -> None:
|
async def _run() -> None:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -267,7 +269,7 @@ Examples:
|
|||||||
wd_task: asyncio.Task[None] | None = None
|
wd_task: asyncio.Task[None] | None = None
|
||||||
if wd_interval is not None:
|
if wd_interval is not None:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"systemd watchdog enabled (pinging every {wd_interval:.1f}s)."
|
"systemd watchdog enabled (pinging every %.1fs).", wd_interval
|
||||||
)
|
)
|
||||||
wd_task = asyncio.create_task(
|
wd_task = asyncio.create_task(
|
||||||
_watchdog_loop(wd_interval, stop_event),
|
_watchdog_loop(wd_interval, stop_event),
|
||||||
@@ -285,8 +287,8 @@ Examples:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
uvloop.run(_run())
|
uvloop.run(_run())
|
||||||
except StartupError as e:
|
except StartupError:
|
||||||
logger.error(str(e))
|
logger.exception("Startup failed.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Unexpected error during Owlbot execution.")
|
logger.exception("Unexpected error during Owlbot execution.")
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ This package provides all the APIs modules use to interact with Owlbot:
|
|||||||
|
|
||||||
# Event types and dataclasses (pure data).
|
# Event types and dataclasses (pure data).
|
||||||
# Module-scoped service wrappers (re-exported from registries).
|
# Module-scoped service wrappers (re-exported from registries).
|
||||||
from ..registries.commands import ModuleCommands
|
from owlbot.registries.commands import ModuleCommands
|
||||||
from ..registries.events import ModuleEvents
|
from owlbot.registries.events import ModuleEvents
|
||||||
from ..registries.routes import ModuleRoutes
|
from owlbot.registries.routes import ModuleRoutes
|
||||||
|
|
||||||
# Command system (module-facing).
|
# Command system (module-facing).
|
||||||
from .commands import (
|
from .commands import (
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class CommandMark(TypedDict):
|
|||||||
aliases: list[str] | tuple[str, ...] | None
|
aliases: list[str] | tuple[str, ...] | None
|
||||||
requires_authenticated: bool
|
requires_authenticated: bool
|
||||||
requires_moderator: bool
|
requires_moderator: bool
|
||||||
cooldown: int | float
|
cooldown: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -67,7 +67,7 @@ class CommandInfo:
|
|||||||
aliases: frozenset[str] = field(default_factory=frozenset)
|
aliases: frozenset[str] = field(default_factory=frozenset)
|
||||||
requires_authenticated: bool = False
|
requires_authenticated: bool = False
|
||||||
requires_moderator: bool = False
|
requires_moderator: bool = False
|
||||||
cooldown: int | float = 0
|
cooldown: int = 0
|
||||||
all_triggers: frozenset[str] = field(init=False, repr=False)
|
all_triggers: frozenset[str] = field(init=False, repr=False)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
@@ -81,7 +81,7 @@ def on_command(
|
|||||||
aliases: list[str] | tuple[str, ...] | None = None,
|
aliases: list[str] | tuple[str, ...] | None = None,
|
||||||
requires_authenticated: bool = False,
|
requires_authenticated: bool = False,
|
||||||
requires_moderator: bool = False,
|
requires_moderator: bool = False,
|
||||||
cooldown: int | float = 0,
|
cooldown: int = 0,
|
||||||
) -> Callable[[CommandHandler], CommandHandler]:
|
) -> Callable[[CommandHandler], CommandHandler]:
|
||||||
"""Register a command handler.
|
"""Register a command handler.
|
||||||
|
|
||||||
@@ -97,7 +97,8 @@ def on_command(
|
|||||||
def decorator(func: CommandHandler) -> CommandHandler:
|
def decorator(func: CommandHandler) -> CommandHandler:
|
||||||
# Mark the function with command info for deferred registration.
|
# Mark the function with command info for deferred registration.
|
||||||
# The module loader will scan for this attribute and register commands.
|
# The module loader will scan for this attribute and register commands.
|
||||||
func._owlbot_command = CommandMark( # type: ignore[attr-defined]
|
# Framework decorator marker; private to module authors.
|
||||||
|
func._owlbot_command = CommandMark( # type: ignore[attr-defined] # noqa: SLF001
|
||||||
name=name,
|
name=name,
|
||||||
aliases=aliases,
|
aliases=aliases,
|
||||||
requires_authenticated=requires_authenticated,
|
requires_authenticated=requires_authenticated,
|
||||||
|
|||||||
+12
-12
@@ -63,7 +63,7 @@ class Config:
|
|||||||
self,
|
self,
|
||||||
config_path: str | Path = "config.yaml",
|
config_path: str | Path = "config.yaml",
|
||||||
overrides: dict[str, Any] | None = None,
|
overrides: dict[str, Any] | None = None,
|
||||||
):
|
) -> None:
|
||||||
"""Initialize the configuration manager.
|
"""Initialize the configuration manager.
|
||||||
|
|
||||||
:param config_path: Path to the YAML config file.
|
:param config_path: Path to the YAML config file.
|
||||||
@@ -340,13 +340,13 @@ class Config:
|
|||||||
try:
|
try:
|
||||||
with self.config_path.open() as f:
|
with self.config_path.open() as f:
|
||||||
self._data = _yaml.load(f) or {}
|
self._data = _yaml.load(f) or {}
|
||||||
except YAMLError as e:
|
except YAMLError:
|
||||||
logger.error(f"Failed to parse config file: {e}")
|
logger.exception("Failed to parse config file.")
|
||||||
raise
|
raise
|
||||||
except OSError as e:
|
except OSError:
|
||||||
logger.error(f"Failed to read config file: {e}")
|
logger.exception("Failed to read config file.")
|
||||||
raise
|
raise
|
||||||
logger.info(f"Configuration loaded from: {self.config_path.absolute()}")
|
logger.info("Configuration loaded from: %s", self.config_path.absolute())
|
||||||
else:
|
else:
|
||||||
raise FileNotFoundError(f"Config file not found: {self.config_path}")
|
raise FileNotFoundError(f"Config file not found: {self.config_path}")
|
||||||
|
|
||||||
@@ -403,13 +403,13 @@ class Config:
|
|||||||
try:
|
try:
|
||||||
with self.config_path.open("w") as f:
|
with self.config_path.open("w") as f:
|
||||||
_yaml.dump(self._data, f)
|
_yaml.dump(self._data, f)
|
||||||
except YAMLError as e:
|
except YAMLError:
|
||||||
logger.error(f"Failed to serialize config data: {e}")
|
logger.exception("Failed to serialize config data.")
|
||||||
raise
|
raise
|
||||||
except OSError as e:
|
except OSError:
|
||||||
logger.error(f"Failed to write config file: {e}")
|
logger.exception("Failed to write config file.")
|
||||||
raise
|
raise
|
||||||
logger.info(f"Configuration saved to: {self.config_path.absolute()}")
|
logger.info("Configuration saved to: %s", self.config_path.absolute())
|
||||||
|
|
||||||
def is_module_enabled(self, module_name: str) -> bool:
|
def is_module_enabled(self, module_name: str) -> bool:
|
||||||
"""Check if a module is enabled.
|
"""Check if a module is enabled.
|
||||||
@@ -505,7 +505,7 @@ class Config:
|
|||||||
class ModuleConfig:
|
class ModuleConfig:
|
||||||
"""Pre-scoped configuration for a specific module."""
|
"""Pre-scoped configuration for a specific module."""
|
||||||
|
|
||||||
def __init__(self, config: Config, module_name: str):
|
def __init__(self, config: Config, module_name: str) -> None:
|
||||||
"""Initialize a module-scoped configuration.
|
"""Initialize a module-scoped configuration.
|
||||||
|
|
||||||
:param config: The parent Config object.
|
:param config: The parent Config object.
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ from typing import TYPE_CHECKING, Any
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from ..registries.commands import ModuleCommands
|
from owlbot.registries.commands import ModuleCommands
|
||||||
from ..registries.events import ModuleEvents
|
from owlbot.registries.events import ModuleEvents
|
||||||
from ..registries.routes import ModuleRoutes
|
from owlbot.registries.routes import ModuleRoutes
|
||||||
|
|
||||||
from .commands import CommandEvent
|
from .commands import CommandEvent
|
||||||
from .config import ModuleConfig
|
from .config import ModuleConfig
|
||||||
from .event_types import ChatEvent, User
|
from .event_types import ChatEvent, User
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ def _parse_timestamp(ts: str | None) -> datetime | None:
|
|||||||
return datetime.fromisoformat(ts)
|
return datetime.fromisoformat(ts)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Malformed timestamp. Return None rather than crashing.
|
# Malformed timestamp. Return None rather than crashing.
|
||||||
logger.warning(f"Owncast API returned timestamp in unexpected format: {ts!r}")
|
logger.warning("Owncast API returned timestamp in unexpected format: %r", ts)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -438,22 +438,24 @@ def log_event(event_type: EventType, event: Event) -> None:
|
|||||||
"""
|
"""
|
||||||
match event:
|
match event:
|
||||||
case ChatEvent(user=user, message_id=mid, body=body):
|
case ChatEvent(user=user, message_id=mid, body=body):
|
||||||
logger.info(f"[{event_type}] {user.display_name} ({mid}): {body}")
|
logger.info("[%s] %s (%s): %s", event_type, user.display_name, mid, body)
|
||||||
case UserJoinedEvent(user=user):
|
case UserJoinedEvent(user=user):
|
||||||
logger.info(f"[{event_type}] {user.display_name} joined.")
|
logger.info("[%s] %s joined.", event_type, user.display_name)
|
||||||
case UserPartedEvent(user=user):
|
case UserPartedEvent(user=user):
|
||||||
logger.info(f"[{event_type}] {user.display_name} parted.")
|
logger.info("[%s] %s parted.", event_type, user.display_name)
|
||||||
case NameChangedEvent(user=user, new_name=new_name):
|
case NameChangedEvent(user=user, new_name=new_name):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[{event_type}] {user.display_name} changed name to {new_name}."
|
"[%s] %s changed name to %s.", event_type, user.display_name, new_name
|
||||||
)
|
)
|
||||||
case StreamStartedEvent(stream_title=title):
|
case StreamStartedEvent(stream_title=title):
|
||||||
logger.info(f'[{event_type}] Stream started: "{title}"')
|
logger.info('[%s] Stream started: "%s"', event_type, title)
|
||||||
case StreamStoppedEvent():
|
case StreamStoppedEvent():
|
||||||
logger.info(f"[{event_type}] Stream ended.")
|
logger.info("[%s] Stream ended.", event_type)
|
||||||
case StreamTitleUpdatedEvent(stream_title=title):
|
case StreamTitleUpdatedEvent(stream_title=title):
|
||||||
logger.info(f'[{event_type}] Title changed to "{title}"')
|
logger.info('[%s] Title changed to "%s"', event_type, title)
|
||||||
case VisibilityUpdateEvent(is_visible=visible, message_ids=ids):
|
case VisibilityUpdateEvent(is_visible=visible, message_ids=ids):
|
||||||
action = "shown" if visible else "hidden"
|
action = "shown" if visible else "hidden"
|
||||||
id_list = ", ".join(ids)
|
id_list = ", ".join(ids)
|
||||||
logger.info(f"[{event_type}] {len(ids)} message(s) {action}: {id_list}")
|
logger.info(
|
||||||
|
"[%s] %d message(s) %s: %s", event_type, len(ids), action, id_list
|
||||||
|
)
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ def on_event(
|
|||||||
def decorator(func: EventHandler) -> EventHandler:
|
def decorator(func: EventHandler) -> EventHandler:
|
||||||
# Mark the function with event info for deferred registration.
|
# Mark the function with event info for deferred registration.
|
||||||
# The module loader will scan for this attribute and register handlers.
|
# The module loader will scan for this attribute and register handlers.
|
||||||
func._owlbot_event = EventMark( # type: ignore[attr-defined]
|
# Framework decorator marker; private to module authors.
|
||||||
|
func._owlbot_event = EventMark( # type: ignore[attr-defined] # noqa: SLF001
|
||||||
event_types=event_types, priority=priority
|
event_types=event_types, priority=priority
|
||||||
)
|
)
|
||||||
return func
|
return func
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from typing import Any
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import orjson
|
import orjson
|
||||||
|
|
||||||
|
from owlbot._version import __version__
|
||||||
|
|
||||||
logger = logging.getLogger("owlbot.http")
|
logger = logging.getLogger("owlbot.http")
|
||||||
|
|
||||||
|
|
||||||
@@ -56,14 +58,7 @@ class HttpClient:
|
|||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
async def _start(self) -> None:
|
async def _start(self) -> None:
|
||||||
"""Create the underlying aiohttp session.
|
"""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)
|
connector = aiohttp.TCPConnector(keepalive_timeout=120)
|
||||||
timeout = aiohttp.ClientTimeout(connect=10, sock_connect=10, sock_read=10)
|
timeout = aiohttp.ClientTimeout(connect=10, sock_connect=10, sock_read=10)
|
||||||
self._session = aiohttp.ClientSession(
|
self._session = aiohttp.ClientSession(
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ def on_setup(func: LifecycleHandler) -> LifecycleHandler:
|
|||||||
:param func: The setup function to mark.
|
:param func: The setup function to mark.
|
||||||
:return: The same function, with ``_owlbot_setup`` attribute set.
|
:return: The same function, with ``_owlbot_setup`` attribute set.
|
||||||
"""
|
"""
|
||||||
func._owlbot_setup = True # type: ignore[attr-defined]
|
# Framework decorator marker; private to module authors.
|
||||||
|
func._owlbot_setup = True # type: ignore[attr-defined] # noqa: SLF001
|
||||||
return func
|
return func
|
||||||
|
|
||||||
|
|
||||||
@@ -65,5 +66,6 @@ def on_teardown(func: LifecycleHandler) -> LifecycleHandler:
|
|||||||
:param func: The teardown function to mark.
|
:param func: The teardown function to mark.
|
||||||
:return: The same function, with ``_owlbot_teardown`` attribute set.
|
:return: The same function, with ``_owlbot_teardown`` attribute set.
|
||||||
"""
|
"""
|
||||||
func._owlbot_teardown = True # type: ignore[attr-defined]
|
# Framework decorator marker; private to module authors.
|
||||||
|
func._owlbot_teardown = True # type: ignore[attr-defined] # noqa: SLF001
|
||||||
return func
|
return func
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, base_url: str, username: str, password: str, http_client: HttpClient
|
self, base_url: str, username: str, password: str, http_client: HttpClient
|
||||||
):
|
) -> None:
|
||||||
"""Initialize the admin client.
|
"""Initialize the admin client.
|
||||||
|
|
||||||
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
|
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
|
||||||
@@ -198,7 +198,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
self._headers = None
|
self._headers = None
|
||||||
self._auth = aiohttp.BasicAuth(username, password)
|
self._auth = aiohttp.BasicAuth(username, password)
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
f"Owncast admin API client initialized for: {self._base_url}"
|
"Owncast admin API client initialized for: %s", self._base_url
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_status(self) -> dict[str, Any]:
|
async def get_status(self) -> dict[str, Any]:
|
||||||
@@ -265,7 +265,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
return list(await self._get("/api/admin/chat/clients"))
|
return list(await self._get("/api/admin/chat/clients"))
|
||||||
|
|
||||||
async def set_message_visibility(
|
async def set_message_visibility(
|
||||||
self, message_ids: list[str], visible: bool
|
self, message_ids: list[str], *, visible: bool
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Hide or show chat messages.
|
"""Hide or show chat messages.
|
||||||
|
|
||||||
@@ -275,13 +275,13 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"""
|
"""
|
||||||
action = "Showing" if visible else "Hiding"
|
action = "Showing" if visible else "Hiding"
|
||||||
ids = ", ".join(message_ids)
|
ids = ", ".join(message_ids)
|
||||||
self._logger.info(f"{action} {len(message_ids)} message(s): {ids}")
|
self._logger.info("%s %d message(s): %s", action, len(message_ids), ids)
|
||||||
return await self._post(
|
return await self._post(
|
||||||
"/api/admin/chat/messagevisibility",
|
"/api/admin/chat/messagevisibility",
|
||||||
{"idArray": message_ids, "visible": visible},
|
{"idArray": message_ids, "visible": visible},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_user_enabled(self, user_id: str, enabled: bool) -> str:
|
async def set_user_enabled(self, user_id: str, *, enabled: bool) -> str:
|
||||||
"""Enable or disable a chat user.
|
"""Enable or disable a chat user.
|
||||||
|
|
||||||
:param user_id: The user ID to modify.
|
:param user_id: The user ID to modify.
|
||||||
@@ -289,7 +289,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
action = "Enabling" if enabled else "Disabling"
|
action = "Enabling" if enabled else "Disabling"
|
||||||
self._logger.info(f"{action} user {user_id}.")
|
self._logger.info("%s user %s.", action, user_id)
|
||||||
return await self._post(
|
return await self._post(
|
||||||
"/api/admin/chat/users/setenabled",
|
"/api/admin/chat/users/setenabled",
|
||||||
{"userId": user_id, "enabled": enabled},
|
{"userId": user_id, "enabled": enabled},
|
||||||
@@ -302,7 +302,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"""
|
"""
|
||||||
return list(await self._get("/api/admin/chat/users/disabled"))
|
return list(await self._get("/api/admin/chat/users/disabled"))
|
||||||
|
|
||||||
async def set_user_moderator(self, user_id: str, is_mod: bool) -> str:
|
async def set_user_moderator(self, user_id: str, *, is_mod: bool) -> str:
|
||||||
"""Grant or revoke moderator status for a user.
|
"""Grant or revoke moderator status for a user.
|
||||||
|
|
||||||
:param user_id: The user ID to modify.
|
:param user_id: The user ID to modify.
|
||||||
@@ -310,7 +310,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
action = "Granting" if is_mod else "Revoking"
|
action = "Granting" if is_mod else "Revoking"
|
||||||
self._logger.info(f"{action} moderator status for user {user_id}.")
|
self._logger.info("%s moderator status for user %s.", action, user_id)
|
||||||
return await self._post(
|
return await self._post(
|
||||||
"/api/admin/chat/users/setmoderator",
|
"/api/admin/chat/users/setmoderator",
|
||||||
{"userId": user_id, "isModerator": is_mod},
|
{"userId": user_id, "isModerator": is_mod},
|
||||||
@@ -329,7 +329,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:param ip: The IP address to ban.
|
:param ip: The IP address to ban.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
self._logger.info(f"Banning IP address: {ip}")
|
self._logger.info("Banning IP address: %s", ip)
|
||||||
return await self._post("/api/admin/chat/users/ipbans/create", {"value": ip})
|
return await self._post("/api/admin/chat/users/ipbans/create", {"value": ip})
|
||||||
|
|
||||||
async def unban_ip_address(self, ip: str) -> str:
|
async def unban_ip_address(self, ip: str) -> str:
|
||||||
@@ -338,7 +338,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:param ip: The IP address to unban.
|
:param ip: The IP address to unban.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
self._logger.info(f"Unbanning IP address: {ip}")
|
self._logger.info("Unbanning IP address: %s", ip)
|
||||||
return await self._post("/api/admin/chat/users/ipbans/remove", {"value": ip})
|
return await self._post("/api/admin/chat/users/ipbans/remove", {"value": ip})
|
||||||
|
|
||||||
async def get_ip_address_bans(self) -> list[dict[str, Any]]:
|
async def get_ip_address_bans(self) -> list[dict[str, Any]]:
|
||||||
@@ -488,7 +488,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"/api/admin/config/video/codec", codec, "video codec"
|
"/api/admin/config/video/codec", codec, "video codec"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_chat_disabled(self, disabled: bool) -> str:
|
async def set_chat_disabled(self, *, disabled: bool) -> str:
|
||||||
"""Enable or disable the chat.
|
"""Enable or disable the chat.
|
||||||
|
|
||||||
:param disabled: True to disable chat, False to enable it.
|
:param disabled: True to disable chat, False to enable it.
|
||||||
@@ -498,7 +498,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"/api/admin/config/chat/disable", disabled, "chat disabled"
|
"/api/admin/config/chat/disable", disabled, "chat disabled"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_chat_join_messages_enabled(self, enabled: bool) -> str:
|
async def set_chat_join_messages_enabled(self, *, enabled: bool) -> str:
|
||||||
"""Enable or disable chat join messages.
|
"""Enable or disable chat join messages.
|
||||||
|
|
||||||
:param enabled: True to show join messages, False to hide them.
|
:param enabled: True to show join messages, False to hide them.
|
||||||
@@ -508,7 +508,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"/api/admin/config/chat/joinmessagesenabled", enabled, "chat join messages"
|
"/api/admin/config/chat/joinmessagesenabled", enabled, "chat join messages"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_chat_established_mode(self, enabled: bool) -> str:
|
async def set_chat_established_mode(self, *, enabled: bool) -> str:
|
||||||
"""Enable or disable established user mode for chat.
|
"""Enable or disable established user mode for chat.
|
||||||
|
|
||||||
:param enabled: True to enable established mode.
|
:param enabled: True to enable established mode.
|
||||||
@@ -520,7 +520,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"chat established mode",
|
"chat established mode",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_chat_spam_protection(self, enabled: bool) -> str:
|
async def set_chat_spam_protection(self, *, enabled: bool) -> str:
|
||||||
"""Enable or disable chat spam protection.
|
"""Enable or disable chat spam protection.
|
||||||
|
|
||||||
:param enabled: True to enable spam protection.
|
:param enabled: True to enable spam protection.
|
||||||
@@ -532,7 +532,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"chat spam protection",
|
"chat spam protection",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_chat_slur_filter(self, enabled: bool) -> str:
|
async def set_chat_slur_filter(self, *, enabled: bool) -> str:
|
||||||
"""Enable or disable the chat slur filter.
|
"""Enable or disable the chat slur filter.
|
||||||
|
|
||||||
:param enabled: True to enable the slur filter.
|
:param enabled: True to enable the slur filter.
|
||||||
@@ -542,7 +542,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"/api/admin/config/chat/slurfilterenabled", enabled, "chat slur filter"
|
"/api/admin/config/chat/slurfilterenabled", enabled, "chat slur filter"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_nsfw(self, nsfw: bool) -> str:
|
async def set_nsfw(self, *, nsfw: bool) -> str:
|
||||||
"""Set the NSFW flag for the server.
|
"""Set the NSFW flag for the server.
|
||||||
|
|
||||||
:param nsfw: True to mark the server as NSFW.
|
:param nsfw: True to mark the server as NSFW.
|
||||||
@@ -550,7 +550,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"""
|
"""
|
||||||
return await self._set_config_value("/api/admin/config/nsfw", nsfw, "NSFW flag")
|
return await self._set_config_value("/api/admin/config/nsfw", nsfw, "NSFW flag")
|
||||||
|
|
||||||
async def set_directory_enabled(self, enabled: bool) -> str:
|
async def set_directory_enabled(self, *, enabled: bool) -> str:
|
||||||
"""Enable or disable listing in the Owncast directory.
|
"""Enable or disable listing in the Owncast directory.
|
||||||
|
|
||||||
:param enabled: True to enable directory listing.
|
:param enabled: True to enable directory listing.
|
||||||
@@ -560,7 +560,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"/api/admin/config/directoryenabled", enabled, "directory enabled"
|
"/api/admin/config/directoryenabled", enabled, "directory enabled"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_hide_viewer_count(self, hide: bool) -> str:
|
async def set_hide_viewer_count(self, *, hide: bool) -> str:
|
||||||
"""Show or hide the viewer count.
|
"""Show or hide the viewer count.
|
||||||
|
|
||||||
:param hide: True to hide the viewer count.
|
:param hide: True to hide the viewer count.
|
||||||
@@ -570,7 +570,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"/api/admin/config/hideviewercount", hide, "hide viewer count"
|
"/api/admin/config/hideviewercount", hide, "hide viewer count"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_disable_search_indexing(self, disabled: bool) -> str:
|
async def set_disable_search_indexing(self, *, disabled: bool) -> str:
|
||||||
"""Enable or disable search engine indexing.
|
"""Enable or disable search engine indexing.
|
||||||
|
|
||||||
:param disabled: True to disable search indexing.
|
:param disabled: True to disable search indexing.
|
||||||
@@ -630,7 +630,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"""
|
"""
|
||||||
return await self._set_config_value(
|
return await self._set_config_value(
|
||||||
"/api/admin/config/streamkeys",
|
"/api/admin/config/streamkeys",
|
||||||
[k._to_dict() for k in keys],
|
[k._to_dict() for k in keys], # noqa: SLF001 # framework serialization; private to module authors
|
||||||
"stream keys",
|
"stream keys",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -642,7 +642,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"""
|
"""
|
||||||
return await self._set_config_value(
|
return await self._set_config_value(
|
||||||
"/api/admin/config/video/streamoutputvariants",
|
"/api/admin/config/video/streamoutputvariants",
|
||||||
[v._to_dict() for v in variants],
|
[v._to_dict() for v in variants], # noqa: SLF001 # framework serialization; private to module authors
|
||||||
"video variants",
|
"video variants",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -654,7 +654,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"""
|
"""
|
||||||
return await self._set_config_value(
|
return await self._set_config_value(
|
||||||
"/api/admin/config/socialhandles",
|
"/api/admin/config/socialhandles",
|
||||||
[h._to_dict() for h in handles],
|
[h._to_dict() for h in handles], # noqa: SLF001 # framework serialization; private to module authors
|
||||||
"social handles",
|
"social handles",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -666,12 +666,13 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"""
|
"""
|
||||||
return await self._set_config_value(
|
return await self._set_config_value(
|
||||||
"/api/admin/config/externalactions",
|
"/api/admin/config/externalactions",
|
||||||
[a._to_dict() for a in actions],
|
[a._to_dict() for a in actions], # noqa: SLF001 # framework serialization; private to module authors
|
||||||
"external actions",
|
"external actions",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_s3_config(
|
async def set_s3_config(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
access_key: str,
|
access_key: str,
|
||||||
@@ -704,6 +705,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
|
|
||||||
async def set_discord_notifications(
|
async def set_discord_notifications(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
webhook: str,
|
webhook: str,
|
||||||
go_live_message: str,
|
go_live_message: str,
|
||||||
@@ -727,6 +729,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
|
|
||||||
async def set_browser_notifications(
|
async def set_browser_notifications(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
go_live_message: str,
|
go_live_message: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -795,7 +798,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"/api/admin/config/webserverip", ip, "web server IP"
|
"/api/admin/config/webserverip", ip, "web server IP"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_federation_enabled(self, enabled: bool) -> str:
|
async def set_federation_enabled(self, *, enabled: bool) -> str:
|
||||||
"""Enable or disable federation (ActivityPub).
|
"""Enable or disable federation (ActivityPub).
|
||||||
|
|
||||||
:param enabled: True to enable federation.
|
:param enabled: True to enable federation.
|
||||||
@@ -845,7 +848,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:param data_base64: Base64-encoded image data for the emoji.
|
:param data_base64: Base64-encoded image data for the emoji.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
self._logger.info(f"Uploading emoji: {name}")
|
self._logger.info("Uploading emoji: %s", name)
|
||||||
return await self._post(
|
return await self._post(
|
||||||
"/api/admin/emoji/upload", {"name": name, "data": data_base64}
|
"/api/admin/emoji/upload", {"name": name, "data": data_base64}
|
||||||
)
|
)
|
||||||
@@ -856,7 +859,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:param name: The emoji name to delete.
|
:param name: The emoji name to delete.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
self._logger.info(f"Deleting emoji: {name}")
|
self._logger.info("Deleting emoji: %s", name)
|
||||||
return await self._post("/api/admin/emoji/delete", {"name": name})
|
return await self._post("/api/admin/emoji/delete", {"name": name})
|
||||||
|
|
||||||
async def get_webhooks(self) -> list[dict[str, Any]]:
|
async def get_webhooks(self) -> list[dict[str, Any]]:
|
||||||
@@ -873,7 +876,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:param events: List of event type strings to subscribe to.
|
:param events: List of event type strings to subscribe to.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
self._logger.info(f"Creating webhook for {url} with events: {events}")
|
self._logger.info("Creating webhook for %s with events: %s", url, events)
|
||||||
return await self._post(
|
return await self._post(
|
||||||
"/api/admin/webhooks/create", {"url": url, "events": events}
|
"/api/admin/webhooks/create", {"url": url, "events": events}
|
||||||
)
|
)
|
||||||
@@ -884,7 +887,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:param webhook_id: The ID of the webhook to delete.
|
:param webhook_id: The ID of the webhook to delete.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
self._logger.info(f"Deleting webhook: {webhook_id}")
|
self._logger.info("Deleting webhook: %d", webhook_id)
|
||||||
return await self._post("/api/admin/webhooks/delete", {"id": webhook_id})
|
return await self._post("/api/admin/webhooks/delete", {"id": webhook_id})
|
||||||
|
|
||||||
async def get_access_tokens(self) -> list[dict[str, Any]]:
|
async def get_access_tokens(self) -> list[dict[str, Any]]:
|
||||||
@@ -901,7 +904,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:param scopes: List of permission scope strings.
|
:param scopes: List of permission scope strings.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
self._logger.info(f"Creating access token: {name}")
|
self._logger.info("Creating access token: %s", name)
|
||||||
return await self._post(
|
return await self._post(
|
||||||
"/api/admin/accesstokens/create", {"name": name, "scopes": scopes}
|
"/api/admin/accesstokens/create", {"name": name, "scopes": scopes}
|
||||||
)
|
)
|
||||||
@@ -912,7 +915,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:param token: The token string to delete.
|
:param token: The token string to delete.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
self._logger.info(f"Deleting access token ending in '...{token[-4:]}'.")
|
self._logger.info("Deleting access token ending in '...%s'.", token[-4:])
|
||||||
|
|
||||||
return await self._post("/api/admin/accesstokens/delete", {"token": token})
|
return await self._post("/api/admin/accesstokens/delete", {"token": token})
|
||||||
|
|
||||||
@@ -943,7 +946,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
"""
|
"""
|
||||||
return list(await self._get("/api/admin/followers/blocked"))
|
return list(await self._get("/api/admin/followers/blocked"))
|
||||||
|
|
||||||
async def approve_follower(self, actor_iri: str, approved: bool) -> str:
|
async def approve_follower(self, actor_iri: str, *, approved: bool) -> str:
|
||||||
"""Approve or reject a follow request.
|
"""Approve or reject a follow request.
|
||||||
|
|
||||||
:param actor_iri: The ActivityPub actor IRI of the follower.
|
:param actor_iri: The ActivityPub actor IRI of the follower.
|
||||||
@@ -951,7 +954,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
action = "Approving" if approved else "Rejecting"
|
action = "Approving" if approved else "Rejecting"
|
||||||
self._logger.info(f"{action} follower {actor_iri}.")
|
self._logger.info("%s follower %s.", action, actor_iri)
|
||||||
return await self._post(
|
return await self._post(
|
||||||
"/api/admin/followers/approve",
|
"/api/admin/followers/approve",
|
||||||
{"actorIRI": actor_iri, "approved": approved},
|
{"actorIRI": actor_iri, "approved": approved},
|
||||||
@@ -963,7 +966,7 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:param message: The message text to send.
|
:param message: The message text to send.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
self._logger.info(f"Sending federated message: {message}")
|
self._logger.info("Sending federated message: %s", message)
|
||||||
return await self._post("/api/admin/federation/send", {"value": message})
|
return await self._post("/api/admin/federation/send", {"value": message})
|
||||||
|
|
||||||
async def get_logs(self) -> list[dict[str, Any]]:
|
async def get_logs(self) -> list[dict[str, Any]]:
|
||||||
@@ -998,5 +1001,5 @@ class OwncastAdminClient(OwncastClient):
|
|||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
:raises OwncastError: If the request fails.
|
:raises OwncastError: If the request fails.
|
||||||
"""
|
"""
|
||||||
self._logger.info(f"Setting config: {description}")
|
self._logger.info("Setting config: %s", description)
|
||||||
return await self._post(endpoint, {"value": value})
|
return await self._post(endpoint, {"value": value})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from http import HTTPStatus
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -52,7 +53,7 @@ def _extract_error(text: str) -> str:
|
|||||||
class OwncastError(Exception):
|
class OwncastError(Exception):
|
||||||
"""Raised when an Owncast API request fails."""
|
"""Raised when an Owncast API request fails."""
|
||||||
|
|
||||||
def __init__(self, status: int, message: str):
|
def __init__(self, status: int, message: str) -> None:
|
||||||
"""Initialize the error.
|
"""Initialize the error.
|
||||||
|
|
||||||
:param status: HTTP status code from the failed request, or 0 if the
|
:param status: HTTP status code from the failed request, or 0 if the
|
||||||
@@ -71,7 +72,9 @@ class OwncastClient:
|
|||||||
transport, with per-request Bearer token authentication.
|
transport, with per-request Bearer token authentication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, base_url: str, access_token: str, http_client: HttpClient):
|
def __init__(
|
||||||
|
self, base_url: str, access_token: str, http_client: HttpClient
|
||||||
|
) -> None:
|
||||||
"""Initialize the Owncast client.
|
"""Initialize the Owncast client.
|
||||||
|
|
||||||
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
|
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
|
||||||
@@ -118,7 +121,7 @@ class OwncastClient:
|
|||||||
"""
|
"""
|
||||||
if not unsanitized:
|
if not unsanitized:
|
||||||
body = str(_escape_html(body))
|
body = str(_escape_html(body))
|
||||||
self._logger.info(f"Sending chat message: {body}")
|
self._logger.info("Sending chat message: %s", body)
|
||||||
return await self._post("/api/integrations/chat/send", {"body": body})
|
return await self._post("/api/integrations/chat/send", {"body": body})
|
||||||
|
|
||||||
async def send_system_message(self, body: str, *, unsanitized: bool = False) -> str:
|
async def send_system_message(self, body: str, *, unsanitized: bool = False) -> str:
|
||||||
@@ -136,7 +139,7 @@ class OwncastClient:
|
|||||||
"""
|
"""
|
||||||
if not unsanitized:
|
if not unsanitized:
|
||||||
body = str(_escape_html(body))
|
body = str(_escape_html(body))
|
||||||
self._logger.info(f"Sending system message: {body}")
|
self._logger.info("Sending system message: %s", body)
|
||||||
return await self._post("/api/integrations/chat/system", {"body": body})
|
return await self._post("/api/integrations/chat/system", {"body": body})
|
||||||
|
|
||||||
async def send_action(self, body: str, *, unsanitized: bool = False) -> str:
|
async def send_action(self, body: str, *, unsanitized: bool = False) -> str:
|
||||||
@@ -154,7 +157,7 @@ class OwncastClient:
|
|||||||
"""
|
"""
|
||||||
if not unsanitized:
|
if not unsanitized:
|
||||||
body = str(_escape_html(body))
|
body = str(_escape_html(body))
|
||||||
self._logger.info(f"Sending action: {body}")
|
self._logger.info("Sending action: %s", body)
|
||||||
return await self._post("/api/integrations/chat/action", {"body": body})
|
return await self._post("/api/integrations/chat/action", {"body": body})
|
||||||
|
|
||||||
async def send_system_message_to_client(
|
async def send_system_message_to_client(
|
||||||
@@ -175,13 +178,13 @@ class OwncastClient:
|
|||||||
"""
|
"""
|
||||||
if not unsanitized:
|
if not unsanitized:
|
||||||
body = str(_escape_html(body))
|
body = str(_escape_html(body))
|
||||||
self._logger.info(f"Sending system message to client {client_id}: {body}")
|
self._logger.info("Sending system message to client %d: %s", client_id, body)
|
||||||
return await self._post(
|
return await self._post(
|
||||||
f"/api/integrations/chat/system/client/{client_id}", {"body": body}
|
f"/api/integrations/chat/system/client/{client_id}", {"body": body}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_message_visibility(
|
async def set_message_visibility(
|
||||||
self, message_ids: list[str], visible: bool
|
self, message_ids: list[str], *, visible: bool
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Hide or show chat messages (moderation).
|
"""Hide or show chat messages (moderation).
|
||||||
|
|
||||||
@@ -194,7 +197,7 @@ class OwncastClient:
|
|||||||
"""
|
"""
|
||||||
action = "Showing" if visible else "Hiding"
|
action = "Showing" if visible else "Hiding"
|
||||||
ids = ", ".join(message_ids)
|
ids = ", ".join(message_ids)
|
||||||
self._logger.info(f"{action} {len(message_ids)} message(s): {ids}")
|
self._logger.info("%s %d message(s): %s", action, len(message_ids), ids)
|
||||||
return await self._post(
|
return await self._post(
|
||||||
"/api/integrations/chat/messagevisibility",
|
"/api/integrations/chat/messagevisibility",
|
||||||
{"idArray": message_ids, "visible": visible},
|
{"idArray": message_ids, "visible": visible},
|
||||||
@@ -222,7 +225,7 @@ class OwncastClient:
|
|||||||
:param title: The new stream title.
|
:param title: The new stream title.
|
||||||
:return: Success message from the server.
|
:return: Success message from the server.
|
||||||
"""
|
"""
|
||||||
self._logger.info(f"Setting stream title: {title}")
|
self._logger.info("Setting stream title: %s", title)
|
||||||
return await self._post("/api/integrations/streamtitle", {"value": title})
|
return await self._post("/api/integrations/streamtitle", {"value": title})
|
||||||
|
|
||||||
async def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> str:
|
async def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> str:
|
||||||
@@ -246,11 +249,11 @@ class OwncastClient:
|
|||||||
auth=self._auth,
|
auth=self._auth,
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
) as response:
|
) as response:
|
||||||
if response.status >= 400:
|
if response.status >= HTTPStatus.BAD_REQUEST:
|
||||||
text = await response.text()
|
text = await response.text()
|
||||||
message = _extract_error(text)
|
message = _extract_error(text)
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
f"Error {response.status} on POST {endpoint}: {message}"
|
"Error %d on POST %s: %s", response.status, endpoint, message
|
||||||
)
|
)
|
||||||
raise OwncastError(response.status, message)
|
raise OwncastError(response.status, message)
|
||||||
self._logger.debug("POST %s -> %d", endpoint, response.status)
|
self._logger.debug("POST %s -> %d", endpoint, response.status)
|
||||||
@@ -259,32 +262,36 @@ class OwncastClient:
|
|||||||
result = await response.json(loads=orjson.loads)
|
result = await response.json(loads=orjson.loads)
|
||||||
except (ValueError, ContentTypeError):
|
except (ValueError, ContentTypeError):
|
||||||
text = await response.text()
|
text = await response.text()
|
||||||
self._logger.error(f"Invalid JSON on POST {endpoint}: {text}")
|
self._logger.exception(
|
||||||
|
"Invalid JSON on POST %s: %s", endpoint, text
|
||||||
|
)
|
||||||
raise OwncastError(response.status, text) from None
|
raise OwncastError(response.status, text) from None
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
# Is there an error field? Owncast returns
|
# Is there an error field? Owncast returns
|
||||||
# {"error": "..."} for internal errors.
|
# {"error": "..."} for internal errors.
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
f"Error on POST {endpoint}: {result['error']}"
|
"Error on POST %s: %s", endpoint, result["error"]
|
||||||
)
|
)
|
||||||
raise OwncastError(response.status, result["error"])
|
raise OwncastError(response.status, result["error"])
|
||||||
# Does the success flag indicate failure?
|
# Does the success flag indicate failure?
|
||||||
if "success" in result and not result["success"]:
|
if "success" in result and not result["success"]:
|
||||||
message = result.get("message", "unknown error")
|
message = result.get("message", "unknown error")
|
||||||
self._logger.error(f"Error on POST {endpoint}: {message}")
|
self._logger.error(
|
||||||
|
"Error on POST %s: %s", endpoint, message
|
||||||
|
)
|
||||||
raise OwncastError(response.status, message)
|
raise OwncastError(response.status, message)
|
||||||
# Is this a simple success response? Extract the message.
|
# Is this a simple success response? Extract the message.
|
||||||
if "success" in result:
|
if "success" in result:
|
||||||
return str(result.get("message", ""))
|
return str(result.get("message", ""))
|
||||||
# Unknown response shape from Owncast.
|
# Unknown response shape from Owncast.
|
||||||
self._logger.warning(
|
self._logger.warning(
|
||||||
f"Unknown response on POST {endpoint}: {result}"
|
"Unknown response on POST %s: %s", endpoint, result
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
return ""
|
return ""
|
||||||
except aiohttp.ClientError as e:
|
except aiohttp.ClientError as e:
|
||||||
self._logger.error(f"Connection error on POST {endpoint}: {e}")
|
self._logger.exception("Connection error on POST %s.", endpoint)
|
||||||
raise OwncastError(0, str(e)) from e
|
raise OwncastError(0, str(e)) from e
|
||||||
|
|
||||||
async def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
async def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||||
@@ -305,11 +312,11 @@ class OwncastClient:
|
|||||||
auth=self._auth,
|
auth=self._auth,
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
) as response:
|
) as response:
|
||||||
if response.status >= 400:
|
if response.status >= HTTPStatus.BAD_REQUEST:
|
||||||
text = await response.text()
|
text = await response.text()
|
||||||
message = _extract_error(text)
|
message = _extract_error(text)
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
f"Error {response.status} on GET {endpoint}: {message}"
|
"Error %d on GET %s: %s", response.status, endpoint, message
|
||||||
)
|
)
|
||||||
raise OwncastError(response.status, message)
|
raise OwncastError(response.status, message)
|
||||||
self._logger.debug("GET %s -> %d", endpoint, response.status)
|
self._logger.debug("GET %s -> %d", endpoint, response.status)
|
||||||
@@ -317,14 +324,14 @@ class OwncastClient:
|
|||||||
result = await response.json(loads=orjson.loads)
|
result = await response.json(loads=orjson.loads)
|
||||||
except (ValueError, ContentTypeError):
|
except (ValueError, ContentTypeError):
|
||||||
text = await response.text()
|
text = await response.text()
|
||||||
self._logger.error(f"Invalid JSON on GET {endpoint}: {text}")
|
self._logger.exception("Invalid JSON on GET %s: %s", endpoint, text)
|
||||||
raise OwncastError(response.status, text) from None
|
raise OwncastError(response.status, text) from None
|
||||||
# Is there an error field? Owncast returns
|
# Is there an error field? Owncast returns
|
||||||
# {"error": "..."} for internal errors.
|
# {"error": "..."} for internal errors.
|
||||||
if isinstance(result, dict) and "error" in result:
|
if isinstance(result, dict) and "error" in result:
|
||||||
self._logger.error(f"Error on GET {endpoint}: {result['error']}")
|
self._logger.error("Error on GET %s: %s", endpoint, result["error"])
|
||||||
raise OwncastError(response.status, result["error"])
|
raise OwncastError(response.status, result["error"])
|
||||||
return result
|
return result
|
||||||
except aiohttp.ClientError as e:
|
except aiohttp.ClientError as e:
|
||||||
self._logger.error(f"Connection error on GET {endpoint}: {e}")
|
self._logger.exception("Connection error on GET %s.", endpoint)
|
||||||
raise OwncastError(0, str(e)) from e
|
raise OwncastError(0, str(e)) from e
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ def on_route(
|
|||||||
def decorator(func: RouteHandler) -> RouteHandler:
|
def decorator(func: RouteHandler) -> RouteHandler:
|
||||||
# Mark the function with route info for deferred registration.
|
# Mark the function with route info for deferred registration.
|
||||||
# methods=None is resolved to ["GET"] by RouteRegistry.register().
|
# methods=None is resolved to ["GET"] by RouteRegistry.register().
|
||||||
func._owlbot_route = RouteMark( # type: ignore[attr-defined]
|
# Framework decorator marker; private to module authors.
|
||||||
|
func._owlbot_route = RouteMark( # type: ignore[attr-defined] # noqa: SLF001
|
||||||
path=path, methods=methods, streaming=streaming
|
path=path, methods=methods, streaming=streaming
|
||||||
)
|
)
|
||||||
return func
|
return func
|
||||||
|
|||||||
+13
-20
@@ -22,7 +22,7 @@ import contextvars
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Self
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ class ModuleStorage:
|
|||||||
unit.
|
unit.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, storage_dir: Path | None, module_name: str):
|
def __init__(self, storage_dir: Path | None, module_name: str) -> None:
|
||||||
"""Initialize the storage API.
|
"""Initialize the storage API.
|
||||||
|
|
||||||
:param storage_dir: Directory where module databases are stored.
|
:param storage_dir: Directory where module databases are stored.
|
||||||
@@ -71,7 +71,7 @@ class ModuleStorage:
|
|||||||
)
|
)
|
||||||
self._logger = logging.getLogger(f"owlbot.modules.{module_name}.storage")
|
self._logger = logging.getLogger(f"owlbot.modules.{module_name}.storage")
|
||||||
|
|
||||||
async def __aenter__(self) -> ModuleStorage:
|
async def __aenter__(self) -> Self:
|
||||||
"""Enter an async context manager that closes the storage on exit.
|
"""Enter an async context manager that closes the storage on exit.
|
||||||
|
|
||||||
Enables ``async with ModuleStorage(...) as s:`` for scoped usage
|
Enables ``async with ModuleStorage(...) as s:`` for scoped usage
|
||||||
@@ -141,13 +141,11 @@ class ModuleStorage:
|
|||||||
:raises StorageError: If execution fails.
|
:raises StorageError: If execution fails.
|
||||||
"""
|
"""
|
||||||
async with self._connection() as conn:
|
async with self._connection() as conn:
|
||||||
self._logger.debug(
|
self._logger.debug("Execute: %s", sql)
|
||||||
"Execute: %s%s", sql[:80], "..." if len(sql) > 80 else ""
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
return await conn.execute(sql, parameters)
|
return await conn.execute(sql, parameters)
|
||||||
except aiosqlite.Error as e:
|
except aiosqlite.Error as e:
|
||||||
self._logger.error(f"SQL error: {e}")
|
self._logger.exception("SQL error.")
|
||||||
raise StorageError(f"SQL execution failed: {e}") from e
|
raise StorageError(f"SQL execution failed: {e}") from e
|
||||||
|
|
||||||
async def execute_many(
|
async def execute_many(
|
||||||
@@ -166,15 +164,14 @@ class ModuleStorage:
|
|||||||
"""
|
"""
|
||||||
async with self._connection() as conn:
|
async with self._connection() as conn:
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
"Execute many (%d rows): %s%s",
|
"Execute many (%d rows): %s",
|
||||||
len(parameters),
|
len(parameters),
|
||||||
sql[:80],
|
sql,
|
||||||
"..." if len(sql) > 80 else "",
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return await conn.executemany(sql, parameters)
|
return await conn.executemany(sql, parameters)
|
||||||
except aiosqlite.Error as e:
|
except aiosqlite.Error as e:
|
||||||
self._logger.error(f"SQL error in executemany: {e}")
|
self._logger.exception("SQL error in executemany.")
|
||||||
raise StorageError(f"SQL execution failed: {e}") from e
|
raise StorageError(f"SQL execution failed: {e}") from e
|
||||||
|
|
||||||
async def fetch_one(
|
async def fetch_one(
|
||||||
@@ -191,14 +188,12 @@ class ModuleStorage:
|
|||||||
:raises StorageError: If execution fails.
|
:raises StorageError: If execution fails.
|
||||||
"""
|
"""
|
||||||
async with self._connection() as conn:
|
async with self._connection() as conn:
|
||||||
self._logger.debug(
|
self._logger.debug("Fetch one: %s", sql)
|
||||||
"Fetch one: %s%s", sql[:80], "..." if len(sql) > 80 else ""
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
cursor = await conn.execute(sql, parameters)
|
cursor = await conn.execute(sql, parameters)
|
||||||
return await cursor.fetchone()
|
return await cursor.fetchone()
|
||||||
except aiosqlite.Error as e:
|
except aiosqlite.Error as e:
|
||||||
self._logger.error(f"SQL error: {e}")
|
self._logger.exception("SQL error.")
|
||||||
raise StorageError(f"SQL fetch failed: {e}") from e
|
raise StorageError(f"SQL fetch failed: {e}") from e
|
||||||
|
|
||||||
async def fetch_all(
|
async def fetch_all(
|
||||||
@@ -215,14 +210,12 @@ class ModuleStorage:
|
|||||||
:raises StorageError: If execution fails.
|
:raises StorageError: If execution fails.
|
||||||
"""
|
"""
|
||||||
async with self._connection() as conn:
|
async with self._connection() as conn:
|
||||||
self._logger.debug(
|
self._logger.debug("Fetch all: %s", sql)
|
||||||
"Fetch all: %s%s", sql[:80], "..." if len(sql) > 80 else ""
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
cursor = await conn.execute(sql, parameters)
|
cursor = await conn.execute(sql, parameters)
|
||||||
return list(await cursor.fetchall())
|
return list(await cursor.fetchall())
|
||||||
except aiosqlite.Error as e:
|
except aiosqlite.Error as e:
|
||||||
self._logger.error(f"SQL error: {e}")
|
self._logger.exception("SQL error.")
|
||||||
raise StorageError(f"SQL fetch failed: {e}") from e
|
raise StorageError(f"SQL fetch failed: {e}") from e
|
||||||
|
|
||||||
async def fetch_value(
|
async def fetch_value(
|
||||||
@@ -264,7 +257,7 @@ class ModuleStorage:
|
|||||||
await asyncio.shield(conn.close())
|
await asyncio.shield(conn.close())
|
||||||
raise
|
raise
|
||||||
self._conn = conn
|
self._conn = conn
|
||||||
self._logger.info(f"Database opened at: {self._db_path}")
|
self._logger.info("Database opened at: %s", self._db_path)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|||||||
+46
-33
@@ -17,7 +17,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Self
|
||||||
|
|
||||||
from ruamel.yaml.error import YAMLError
|
from ruamel.yaml.error import YAMLError
|
||||||
|
|
||||||
@@ -45,15 +45,16 @@ class Owlbot:
|
|||||||
self,
|
self,
|
||||||
config_path: str | Path = "config.yaml",
|
config_path: str | Path = "config.yaml",
|
||||||
overrides: dict[str, Any] | None = None,
|
overrides: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
skip_api_check: bool = False,
|
skip_api_check: bool = False,
|
||||||
):
|
) -> None:
|
||||||
"""Initialize Owlbot.
|
"""Initialize Owlbot.
|
||||||
|
|
||||||
:param config_path: Path to the YAML config file.
|
:param config_path: Path to the YAML config file.
|
||||||
:param overrides: CLI overrides passed to the config manager.
|
:param overrides: CLI overrides passed to the config manager.
|
||||||
:param skip_api_check: Skip API accessibility checks during startup.
|
:param skip_api_check: Skip API accessibility checks during startup.
|
||||||
"""
|
"""
|
||||||
logger.info(f"Owlbot v{__version__} - A logal.dev project")
|
logger.info("Owlbot v%s - A logal.dev project", __version__)
|
||||||
|
|
||||||
self.skip_api_check = skip_api_check
|
self.skip_api_check = skip_api_check
|
||||||
|
|
||||||
@@ -66,8 +67,9 @@ class Owlbot:
|
|||||||
self.modules_dir = self.config.modules_dir
|
self.modules_dir = self.config.modules_dir
|
||||||
if not self.modules_dir.exists():
|
if not self.modules_dir.exists():
|
||||||
logger.info(
|
logger.info(
|
||||||
f"User modules directory not found: {self.modules_dir} "
|
"User modules directory not found: %s "
|
||||||
f"(only built-in modules will be loaded)"
|
"(only built-in modules will be loaded)",
|
||||||
|
self.modules_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Shared HTTP client (owns the aiohttp session lifecycle).
|
# Shared HTTP client (owns the aiohttp session lifecycle).
|
||||||
@@ -88,12 +90,12 @@ class Owlbot:
|
|||||||
|
|
||||||
logger.debug("Owlbot initialization complete.")
|
logger.debug("Owlbot initialization complete.")
|
||||||
|
|
||||||
async def __aenter__(self) -> Owlbot:
|
async def __aenter__(self) -> Self:
|
||||||
"""Start the bot as an async context manager."""
|
"""Start the bot as an async context manager."""
|
||||||
await self.start()
|
await self.start()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, *exc_info: Any) -> None:
|
async def __aexit__(self, *exc_info: object) -> None:
|
||||||
"""Stop the bot when exiting the async context manager."""
|
"""Stop the bot when exiting the async context manager."""
|
||||||
await self.stop()
|
await self.stop()
|
||||||
|
|
||||||
@@ -114,7 +116,8 @@ class Owlbot:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Auth is handled per-request by each Owncast client.
|
# Auth is handled per-request by each Owncast client.
|
||||||
await self.http_client._start()
|
# Framework lifecycle; private to module authors.
|
||||||
|
await self.http_client._start() # noqa: SLF001
|
||||||
|
|
||||||
if self.skip_api_check:
|
if self.skip_api_check:
|
||||||
logger.info("Skipping startup connection tests.")
|
logger.info("Skipping startup connection tests.")
|
||||||
@@ -155,7 +158,8 @@ class Owlbot:
|
|||||||
await self.module_loader.unload_all_modules()
|
await self.module_loader.unload_all_modules()
|
||||||
|
|
||||||
# Close the shared HTTP client (safe to call if startup failed early).
|
# Close the shared HTTP client (safe to call if startup failed early).
|
||||||
await self.http_client._close()
|
# Framework lifecycle; private to module authors.
|
||||||
|
await self.http_client._close() # noqa: SLF001
|
||||||
|
|
||||||
logger.info("Owlbot shutdown complete.")
|
logger.info("Owlbot shutdown complete.")
|
||||||
|
|
||||||
@@ -176,16 +180,19 @@ class Owlbot:
|
|||||||
status = await owncast_client.get_status()
|
status = await owncast_client.get_status()
|
||||||
except OwncastError as e:
|
except OwncastError as e:
|
||||||
if e.status == 0:
|
if e.status == 0:
|
||||||
logger.error(
|
logger.exception(
|
||||||
"Startup connection test failed: "
|
"Startup connection test failed: Could not connect to %s: %s",
|
||||||
f"Could not connect to {base_url}: {e.message}"
|
base_url,
|
||||||
|
e.message,
|
||||||
)
|
)
|
||||||
raise StartupError(
|
raise StartupError(
|
||||||
f"Could not connect to {base_url}: {e.message}"
|
f"Could not connect to {base_url}: {e.message}"
|
||||||
) from e
|
) from e
|
||||||
logger.error(
|
logger.exception(
|
||||||
"Startup connection test failed: "
|
"Startup connection test failed: %s returned HTTP %s: %s",
|
||||||
f"{base_url} returned HTTP {e.status}: {e.message}"
|
base_url,
|
||||||
|
e.status,
|
||||||
|
e.message,
|
||||||
)
|
)
|
||||||
raise StartupError(
|
raise StartupError(
|
||||||
"Owncast version check failed: "
|
"Owncast version check failed: "
|
||||||
@@ -195,9 +202,10 @@ class Owlbot:
|
|||||||
version = status.get("versionNumber") if isinstance(status, dict) else None
|
version = status.get("versionNumber") if isinstance(status, dict) else None
|
||||||
if not version:
|
if not version:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Startup connection test failed: {base_url} "
|
"Startup connection test failed: %s "
|
||||||
"does not appear to be an Owncast instance "
|
"does not appear to be an Owncast instance "
|
||||||
"(no versionNumber in response)"
|
"(no versionNumber in response)",
|
||||||
|
base_url,
|
||||||
)
|
)
|
||||||
raise StartupError(
|
raise StartupError(
|
||||||
f"{base_url} does not appear to be an Owncast instance "
|
f"{base_url} does not appear to be an Owncast instance "
|
||||||
@@ -207,12 +215,15 @@ class Owlbot:
|
|||||||
# Warn on version mismatch but continue startup.
|
# Warn on version mismatch but continue startup.
|
||||||
if version != OWNCAST_TARGET_VERSION:
|
if version != OWNCAST_TARGET_VERSION:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Owncast version {version} detected at {base_url}, "
|
"Owncast version %s detected at %s, "
|
||||||
f"but this build of Owlbot is designed for {OWNCAST_TARGET_VERSION}. "
|
"but this build of Owlbot is designed for %s. "
|
||||||
"Startup will continue, but some things may not behave as expected."
|
"Startup will continue, but some things may not behave as expected.",
|
||||||
|
version,
|
||||||
|
base_url,
|
||||||
|
OWNCAST_TARGET_VERSION,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"Owncast version {version} detected at {base_url}.")
|
logger.info("Owncast version %s detected at %s.", version, base_url)
|
||||||
|
|
||||||
logger.debug("Checking Owncast integrations API accessibility...")
|
logger.debug("Checking Owncast integrations API accessibility...")
|
||||||
try:
|
try:
|
||||||
@@ -220,17 +231,18 @@ class Owlbot:
|
|||||||
logger.debug("Owncast integrations API is accessible.")
|
logger.debug("Owncast integrations API is accessible.")
|
||||||
except OwncastError as e:
|
except OwncastError as e:
|
||||||
if e.status == 0:
|
if e.status == 0:
|
||||||
logger.error(
|
logger.exception(
|
||||||
"Startup connection test failed: Could not "
|
"Startup connection test failed: Could not "
|
||||||
f"connect to integrations API: {e.message}"
|
"connect to integrations API: %s",
|
||||||
|
e.message,
|
||||||
)
|
)
|
||||||
raise StartupError(
|
raise StartupError(
|
||||||
f"Could not connect to integrations API: {e.message}"
|
f"Could not connect to integrations API: {e.message}"
|
||||||
) from e
|
) from e
|
||||||
logger.error(
|
logger.exception(
|
||||||
"Startup connection test failed: "
|
"Startup connection test failed: Integrations API returned HTTP %s: %s",
|
||||||
"Integrations API returned "
|
e.status,
|
||||||
f"HTTP {e.status}: {e.message}"
|
e.message,
|
||||||
)
|
)
|
||||||
raise StartupError(
|
raise StartupError(
|
||||||
"Owncast integrations API check failed: "
|
"Owncast integrations API check failed: "
|
||||||
@@ -244,17 +256,18 @@ class Owlbot:
|
|||||||
logger.info("Owncast admin API is enabled.")
|
logger.info("Owncast admin API is enabled.")
|
||||||
except OwncastError as e:
|
except OwncastError as e:
|
||||||
if e.status == 0:
|
if e.status == 0:
|
||||||
logger.error(
|
logger.exception(
|
||||||
"Startup connection test failed: Could "
|
"Startup connection test failed: Could "
|
||||||
f"not connect to admin API: {e.message}"
|
"not connect to admin API: %s",
|
||||||
|
e.message,
|
||||||
)
|
)
|
||||||
raise StartupError(
|
raise StartupError(
|
||||||
f"Could not connect to admin API: {e.message}"
|
f"Could not connect to admin API: {e.message}"
|
||||||
) from e
|
) from e
|
||||||
logger.error(
|
logger.exception(
|
||||||
"Startup connection test failed: "
|
"Startup connection test failed: Admin API returned HTTP %s: %s",
|
||||||
"Admin API returned "
|
e.status,
|
||||||
f"HTTP {e.status}: {e.message}"
|
e.message,
|
||||||
)
|
)
|
||||||
raise StartupError(
|
raise StartupError(
|
||||||
"Owncast admin API check failed: "
|
"Owncast admin API check failed: "
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ stream in real time and provides a browser-based clip editor.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ async def setup(ctx: ModuleContext) -> None:
|
|||||||
processor = VideoProcessor(ctx.logger)
|
processor = VideoProcessor(ctx.logger)
|
||||||
|
|
||||||
clips_dir = Path(str(ctx.config.get("clips_dir")))
|
clips_dir = Path(str(ctx.config.get("clips_dir")))
|
||||||
clips_dir.mkdir(parents=True, exist_ok=True) # noqa: ASYNC240
|
await asyncio.to_thread(clips_dir.mkdir, parents=True, exist_ok=True)
|
||||||
|
|
||||||
manager = ClipManager(ctx, repo, processor, clips_dir)
|
manager = ClipManager(ctx, repo, processor, clips_dir)
|
||||||
ctx.state["manager"] = manager
|
ctx.state["manager"] = manager
|
||||||
@@ -106,7 +107,7 @@ async def setup(ctx: ModuleContext) -> None:
|
|||||||
if status.get("online", False):
|
if status.get("online", False):
|
||||||
ctx.logger.info("Stream is already live. Starting HLS cache.")
|
ctx.logger.info("Stream is already live. Starting HLS cache.")
|
||||||
await manager.start_caching()
|
await manager.start_caching()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 # best-effort; non-fatal if Owncast is unreachable during setup
|
||||||
ctx.logger.warning(
|
ctx.logger.warning(
|
||||||
"Could not check Owncast status during setup.", exc_info=True
|
"Could not check Owncast status during setup.", exc_info=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
@@ -140,7 +141,7 @@ class ChunkCache:
|
|||||||
with contextlib.suppress(asyncio.CancelledError):
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await self._task
|
await self._task
|
||||||
self._task = None
|
self._task = None
|
||||||
await asyncio.to_thread(shutil.rmtree, self._cache_dir, True)
|
await asyncio.to_thread(shutil.rmtree, self._cache_dir, ignore_errors=True)
|
||||||
self._sequences.clear()
|
self._sequences.clear()
|
||||||
|
|
||||||
async def _download_segment(self, segment_url: str, seq: int) -> bool:
|
async def _download_segment(self, segment_url: str, seq: int) -> bool:
|
||||||
@@ -152,7 +153,7 @@ class ChunkCache:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with self._http.get(segment_url) as seg_resp:
|
async with self._http.get(segment_url) as seg_resp:
|
||||||
if seg_resp.status != 200:
|
if seg_resp.status != HTTPStatus.OK:
|
||||||
return False
|
return False
|
||||||
data = await seg_resp.read()
|
data = await seg_resp.read()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -177,7 +178,7 @@ class ChunkCache:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
async with self._http.get(self._variant_url) as resp:
|
async with self._http.get(self._variant_url) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != HTTPStatus.OK:
|
||||||
self._logger.warning(
|
self._logger.warning(
|
||||||
"Variant playlist returned HTTP %d.", resp.status
|
"Variant playlist returned HTTP %d.", resp.status
|
||||||
)
|
)
|
||||||
@@ -233,7 +234,7 @@ class ChunkCache:
|
|||||||
oldest = min(self._sequences)
|
oldest = min(self._sequences)
|
||||||
self._sequences.discard(oldest)
|
self._sequences.discard(oldest)
|
||||||
path = self._segment_path(oldest)
|
path = self._segment_path(oldest)
|
||||||
await asyncio.to_thread(path.unlink, True)
|
await asyncio.to_thread(path.unlink, missing_ok=True)
|
||||||
if len(self._sequences) < pre_prune:
|
if len(self._sequences) < pre_prune:
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
"Pruned %d segment(s). Cache: %d chunks, %.1fs total.",
|
"Pruned %d segment(s). Cache: %d chunks, %.1fs total.",
|
||||||
@@ -245,7 +246,7 @@ class ChunkCache:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
self._logger.error("Error in HLS polling loop.", exc_info=True)
|
self._logger.exception("Error in HLS polling loop.")
|
||||||
|
|
||||||
await asyncio.sleep(self._target_duration)
|
await asyncio.sleep(self._target_duration)
|
||||||
|
|
||||||
@@ -338,11 +339,12 @@ async def start_caching(
|
|||||||
|
|
||||||
cache.start()
|
cache.start()
|
||||||
logger.info("HLS caching started. Cache dir: %s", cache_dir)
|
logger.info("HLS caching started. Cache dir: %s", cache_dir)
|
||||||
return cache
|
|
||||||
|
|
||||||
except BaseException:
|
except BaseException:
|
||||||
shutil.rmtree(cache_dir, ignore_errors=True)
|
shutil.rmtree(cache_dir, ignore_errors=True)
|
||||||
raise
|
raise
|
||||||
|
else:
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_playlist(
|
async def _fetch_playlist(
|
||||||
@@ -364,7 +366,7 @@ async def _fetch_playlist(
|
|||||||
exc_info = False
|
exc_info = False
|
||||||
try:
|
try:
|
||||||
async with http.get(url) as resp:
|
async with http.get(url) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != HTTPStatus.OK:
|
||||||
reason = f"HTTP {resp.status}"
|
reason = f"HTTP {resp.status}"
|
||||||
else:
|
else:
|
||||||
content = await resp.text()
|
content = await resp.text()
|
||||||
@@ -409,7 +411,7 @@ async def _download_init_segment(
|
|||||||
exc_info = False
|
exc_info = False
|
||||||
try:
|
try:
|
||||||
async with http.get(init_url) as resp:
|
async with http.get(init_url) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != HTTPStatus.OK:
|
||||||
reason = f"HTTP {resp.status}"
|
reason = f"HTTP {resp.status}"
|
||||||
else:
|
else:
|
||||||
data = await resp.read()
|
data = await resp.read()
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async def clip_command(ctx: CommandContext) -> None:
|
|||||||
"Not enough stream data is available yet. Please try again later."
|
"Not enough stream data is available yet. Please try again later."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 # catch-all after specific exceptions; ffmpeg/IO failures are unpredictable
|
||||||
await ctx.owncast_client.send_message(
|
await ctx.owncast_client.send_message(
|
||||||
"Failed to create clip preview. Please try again."
|
"Failed to create clip preview. Please try again."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -192,8 +192,8 @@ class ClipManager:
|
|||||||
preview_path, self._cache
|
preview_path, self._cache
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._ctx.logger.error("Failed to generate clip preview.", exc_info=True)
|
self._ctx.logger.exception("Failed to generate clip preview.")
|
||||||
await asyncio.to_thread(shutil.rmtree, work_dir, True)
|
await asyncio.to_thread(shutil.rmtree, work_dir, ignore_errors=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
@@ -303,7 +303,7 @@ class ClipManager:
|
|||||||
thumbnail_path,
|
thumbnail_path,
|
||||||
duration=actual_duration,
|
duration=actual_duration,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 # best-effort; clip already saved, thumbnail is optional
|
||||||
self._ctx.logger.warning(
|
self._ctx.logger.warning(
|
||||||
"Thumbnail generation failed for clip %d.",
|
"Thumbnail generation failed for clip %d.",
|
||||||
clip.id,
|
clip.id,
|
||||||
@@ -311,14 +311,12 @@ class ClipManager:
|
|||||||
)
|
)
|
||||||
thumbnail_path.unlink(missing_ok=True)
|
thumbnail_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
return clip
|
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Clean up partial state to avoid orphaned DB rows or files.
|
# Clean up partial state to avoid orphaned DB rows or files.
|
||||||
if clip_id is not None:
|
if clip_id is not None:
|
||||||
try:
|
try:
|
||||||
await self._repo.delete(clip_id)
|
await self._repo.delete(clip_id)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 # last-resort; cleanup itself failed, just log it
|
||||||
self._ctx.logger.warning(
|
self._ctx.logger.warning(
|
||||||
"Failed to clean up DB row for clip %d.",
|
"Failed to clean up DB row for clip %d.",
|
||||||
clip_id,
|
clip_id,
|
||||||
@@ -327,8 +325,10 @@ class ClipManager:
|
|||||||
if clip_path is not None:
|
if clip_path is not None:
|
||||||
clip_path.unlink(missing_ok=True)
|
clip_path.unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
|
else:
|
||||||
|
return clip
|
||||||
finally:
|
finally:
|
||||||
await asyncio.to_thread(shutil.rmtree, session.work_dir, True)
|
await asyncio.to_thread(shutil.rmtree, session.work_dir, ignore_errors=True)
|
||||||
|
|
||||||
async def cleanup_session(self, token: str) -> None:
|
async def cleanup_session(self, token: str) -> None:
|
||||||
"""Remove a session and clean up its working directory.
|
"""Remove a session and clean up its working directory.
|
||||||
@@ -339,7 +339,7 @@ class ClipManager:
|
|||||||
if session is not None:
|
if session is not None:
|
||||||
if session.expiry_task is not None:
|
if session.expiry_task is not None:
|
||||||
session.expiry_task.cancel()
|
session.expiry_task.cancel()
|
||||||
await asyncio.to_thread(shutil.rmtree, session.work_dir, True)
|
await asyncio.to_thread(shutil.rmtree, session.work_dir, ignore_errors=True)
|
||||||
|
|
||||||
async def start_caching(self) -> None:
|
async def start_caching(self) -> None:
|
||||||
"""Start HLS caching from the Owncast stream.
|
"""Start HLS caching from the Owncast stream.
|
||||||
@@ -357,7 +357,7 @@ class ClipManager:
|
|||||||
logger=self._ctx.logger,
|
logger=self._ctx.logger,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._ctx.logger.error("Failed to start HLS caching.", exc_info=True)
|
self._ctx.logger.exception("Failed to start HLS caching.")
|
||||||
|
|
||||||
async def stop_caching(self) -> None:
|
async def stop_caching(self) -> None:
|
||||||
"""Stop HLS caching and clean up cached files."""
|
"""Stop HLS caching and clean up cached files."""
|
||||||
@@ -434,5 +434,5 @@ def get_manager(ctx: ModuleContext) -> ClipManager:
|
|||||||
manager = ctx.state.get("manager")
|
manager = ctx.state.get("manager")
|
||||||
if not isinstance(manager, ClipManager):
|
if not isinstance(manager, ClipManager):
|
||||||
msg = "ClipManager is not initialized."
|
msg = "ClipManager is not initialized."
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg) # noqa: TRY004 # state error, not a type error
|
||||||
return manager
|
return manager
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ async def editor_preview_video(ctx: RouteContext) -> web.StreamResponse:
|
|||||||
|
|
||||||
|
|
||||||
@on_route("/static/editor.js", methods=["GET"])
|
@on_route("/static/editor.js", methods=["GET"])
|
||||||
async def editor_js(ctx: RouteContext) -> web.StreamResponse:
|
async def editor_js(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
|
||||||
"""Serve the clip editor JavaScript.
|
"""Serve the clip editor JavaScript.
|
||||||
|
|
||||||
:param ctx: The route context.
|
:param ctx: The route context.
|
||||||
@@ -155,7 +155,7 @@ async def editor_submit(ctx: RouteContext) -> web.Response:
|
|||||||
except InvalidClipParamsError as e:
|
except InvalidClipParamsError as e:
|
||||||
return _error_page(ctx, 400, e.reason)
|
return _error_page(ctx, 400, e.reason)
|
||||||
except Exception:
|
except Exception:
|
||||||
ctx.logger.error("Failed to create clip.", exc_info=True)
|
ctx.logger.exception("Failed to create clip.")
|
||||||
return _error_page(ctx, 500, "Clip processing failed.")
|
return _error_page(ctx, 500, "Clip processing failed.")
|
||||||
|
|
||||||
clip_url = ctx.routes.url_for(f"/view/{clip.id}")
|
clip_url = ctx.routes.url_for(f"/view/{clip.id}")
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async def addcommand(ctx: CommandContext) -> None:
|
|||||||
"""
|
"""
|
||||||
prefix = ctx.commands.prefix
|
prefix = ctx.commands.prefix
|
||||||
args = ctx.args_list
|
args = ctx.args_list
|
||||||
if len(args) < 2:
|
if len(args) < 2: # noqa: PLR2004 # just checking argument count
|
||||||
await ctx.owncast_client.send_message(
|
await ctx.owncast_client.send_message(
|
||||||
f"Usage: {prefix}addcommand {prefix}<name> <response text>"
|
f"Usage: {prefix}addcommand {prefix}<name> <response text>"
|
||||||
)
|
)
|
||||||
@@ -53,7 +53,7 @@ async def addcommand(ctx: CommandContext) -> None:
|
|||||||
|
|
||||||
name = _clean_name(args[0], prefix)
|
name = _clean_name(args[0], prefix)
|
||||||
parts = ctx.args.split(maxsplit=1)
|
parts = ctx.args.split(maxsplit=1)
|
||||||
if len(parts) < 2:
|
if len(parts) < 2: # noqa: PLR2004 # just checking argument count
|
||||||
await ctx.owncast_client.send_message(
|
await ctx.owncast_client.send_message(
|
||||||
f"Usage: {prefix}addcommand {prefix}<name> <response text>"
|
f"Usage: {prefix}addcommand {prefix}<name> <response text>"
|
||||||
)
|
)
|
||||||
@@ -87,7 +87,7 @@ async def editcommand(ctx: CommandContext) -> None:
|
|||||||
"""
|
"""
|
||||||
prefix = ctx.commands.prefix
|
prefix = ctx.commands.prefix
|
||||||
args = ctx.args_list
|
args = ctx.args_list
|
||||||
if len(args) < 2:
|
if len(args) < 2: # noqa: PLR2004 # just checking argument count
|
||||||
await ctx.owncast_client.send_message(
|
await ctx.owncast_client.send_message(
|
||||||
f"Usage: {prefix}editcommand {prefix}<name> <new response>"
|
f"Usage: {prefix}editcommand {prefix}<name> <new response>"
|
||||||
)
|
)
|
||||||
@@ -95,7 +95,7 @@ async def editcommand(ctx: CommandContext) -> None:
|
|||||||
|
|
||||||
name = _clean_name(args[0], prefix)
|
name = _clean_name(args[0], prefix)
|
||||||
parts = ctx.args.split(maxsplit=1)
|
parts = ctx.args.split(maxsplit=1)
|
||||||
if len(parts) < 2:
|
if len(parts) < 2: # noqa: PLR2004 # just checking argument count
|
||||||
await ctx.owncast_client.send_message(
|
await ctx.owncast_client.send_message(
|
||||||
f"Usage: {prefix}editcommand {prefix}<name> <new response>"
|
f"Usage: {prefix}editcommand {prefix}<name> <new response>"
|
||||||
)
|
)
|
||||||
@@ -166,7 +166,7 @@ async def commandmodonly(ctx: CommandContext) -> None:
|
|||||||
"""
|
"""
|
||||||
prefix = ctx.commands.prefix
|
prefix = ctx.commands.prefix
|
||||||
args = ctx.args_list
|
args = ctx.args_list
|
||||||
if len(args) < 2:
|
if len(args) < 2: # noqa: PLR2004 # just checking argument count
|
||||||
await ctx.owncast_client.send_message(
|
await ctx.owncast_client.send_message(
|
||||||
f"Usage: {prefix}commandmodonly {prefix}<name> <on|off>"
|
f"Usage: {prefix}commandmodonly {prefix}<name> <on|off>"
|
||||||
)
|
)
|
||||||
@@ -241,7 +241,7 @@ async def editcounter(ctx: CommandContext) -> None:
|
|||||||
"""
|
"""
|
||||||
prefix = ctx.commands.prefix
|
prefix = ctx.commands.prefix
|
||||||
args = ctx.args_list
|
args = ctx.args_list
|
||||||
if len(args) != 2:
|
if len(args) != 2: # noqa: PLR2004 # just checking argument count
|
||||||
await ctx.owncast_client.send_message(
|
await ctx.owncast_client.send_message(
|
||||||
f"Usage: {prefix}editcounter <name> <value>"
|
f"Usage: {prefix}editcounter <name> <value>"
|
||||||
)
|
)
|
||||||
@@ -291,7 +291,7 @@ async def commandcooldown(ctx: CommandContext) -> None:
|
|||||||
"""
|
"""
|
||||||
prefix = ctx.commands.prefix
|
prefix = ctx.commands.prefix
|
||||||
args = ctx.args_list
|
args = ctx.args_list
|
||||||
if len(args) < 2:
|
if len(args) < 2: # noqa: PLR2004 # just checking argument count
|
||||||
await ctx.owncast_client.send_message(
|
await ctx.owncast_client.send_message(
|
||||||
f"Usage: {prefix}commandcooldown {prefix}<name> <seconds>"
|
f"Usage: {prefix}commandcooldown {prefix}<name> <seconds>"
|
||||||
)
|
)
|
||||||
@@ -344,7 +344,7 @@ async def addalias(ctx: CommandContext) -> None:
|
|||||||
"""
|
"""
|
||||||
prefix = ctx.commands.prefix
|
prefix = ctx.commands.prefix
|
||||||
args = ctx.args_list
|
args = ctx.args_list
|
||||||
if len(args) < 2:
|
if len(args) < 2: # noqa: PLR2004 # just checking argument count
|
||||||
await ctx.owncast_client.send_message(
|
await ctx.owncast_client.send_message(
|
||||||
f"Usage: {prefix}addalias {prefix}<command> {prefix}<alias>"
|
f"Usage: {prefix}addalias {prefix}<command> {prefix}<alias>"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -127,16 +127,12 @@ class CommandManager:
|
|||||||
raise CommandAlreadyExistsError(name)
|
raise CommandAlreadyExistsError(name)
|
||||||
|
|
||||||
default_cooldown: int = self._ctx.config.get("default_cooldown", 5)
|
default_cooldown: int = self._ctx.config.get("default_cooldown", 5)
|
||||||
|
command = await self._repo.create(name, response, default_cooldown)
|
||||||
self._commands.register(
|
self._commands.register(
|
||||||
name=name,
|
name=name,
|
||||||
handler=custom_command_handler,
|
handler=custom_command_handler,
|
||||||
cooldown=default_cooldown,
|
cooldown=default_cooldown,
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
command = await self._repo.create(name, response, default_cooldown)
|
|
||||||
except Exception:
|
|
||||||
self._commands.unregister(name)
|
|
||||||
raise
|
|
||||||
|
|
||||||
self._ctx.logger.info("Custom command '%s' created.", name)
|
self._ctx.logger.info("Custom command '%s' created.", name)
|
||||||
return command
|
return command
|
||||||
@@ -336,7 +332,7 @@ def get_manager(ctx: ModuleContext) -> CommandManager:
|
|||||||
"""
|
"""
|
||||||
manager = ctx.state.get("manager")
|
manager = ctx.state.get("manager")
|
||||||
if not isinstance(manager, CommandManager):
|
if not isinstance(manager, CommandManager):
|
||||||
raise RuntimeError("CommandManager is not initialized.")
|
raise RuntimeError("CommandManager is not initialized.") # noqa: TRY004 # state error, not a type error
|
||||||
return manager
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ def _parse_placeholder_date(date_str: str) -> datetime | None:
|
|||||||
"""
|
"""
|
||||||
# Split off the timezone abbreviation (last token).
|
# Split off the timezone abbreviation (last token).
|
||||||
parts = date_str.rsplit(maxsplit=1)
|
parts = date_str.rsplit(maxsplit=1)
|
||||||
if len(parts) != 2:
|
if len(parts) != 2: # noqa: PLR2004 # just checking argument count
|
||||||
return None
|
return None
|
||||||
|
|
||||||
date_part, tz_abbrev = parts
|
date_part, tz_abbrev = parts
|
||||||
@@ -321,7 +321,7 @@ async def _evaluate_arg(
|
|||||||
|
|
||||||
|
|
||||||
async def _evaluate_user(
|
async def _evaluate_user(
|
||||||
name: str,
|
name: str, # noqa: ARG001 # required by placeholder handler signature
|
||||||
args: list[str],
|
args: list[str],
|
||||||
ctx: PlaceholderContext,
|
ctx: PlaceholderContext,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -332,7 +332,7 @@ async def _evaluate_user(
|
|||||||
|
|
||||||
|
|
||||||
async def _evaluate_count(
|
async def _evaluate_count(
|
||||||
name: str,
|
name: str, # noqa: ARG001 # required by placeholder handler signature
|
||||||
args: list[str],
|
args: list[str],
|
||||||
ctx: PlaceholderContext,
|
ctx: PlaceholderContext,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -350,7 +350,7 @@ async def _evaluate_count(
|
|||||||
)
|
)
|
||||||
modifier_str = args[1] if len(args) > 1 else "+1"
|
modifier_str = args[1] if len(args) > 1 else "+1"
|
||||||
|
|
||||||
if len(args) > 2:
|
if len(args) > 2: # noqa: PLR2004 # just checking argument count
|
||||||
raise PlaceholderError(
|
raise PlaceholderError(
|
||||||
"Invalid $(count): too many arguments, expected $(count name [modifier])"
|
"Invalid $(count): too many arguments, expected $(count name [modifier])"
|
||||||
)
|
)
|
||||||
@@ -369,7 +369,7 @@ async def _evaluate_count(
|
|||||||
|
|
||||||
|
|
||||||
async def _evaluate_getcount(
|
async def _evaluate_getcount(
|
||||||
name: str,
|
name: str, # noqa: ARG001 # required by placeholder handler signature
|
||||||
args: list[str],
|
args: list[str],
|
||||||
ctx: PlaceholderContext,
|
ctx: PlaceholderContext,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -391,16 +391,16 @@ async def _evaluate_getcount(
|
|||||||
|
|
||||||
|
|
||||||
async def _evaluate_rand(
|
async def _evaluate_rand(
|
||||||
name: str,
|
name: str, # noqa: ARG001 # required by placeholder handler signature
|
||||||
args: list[str],
|
args: list[str],
|
||||||
ctx: PlaceholderContext,
|
ctx: PlaceholderContext, # noqa: ARG001 # required by placeholder handler signature
|
||||||
) -> str:
|
) -> str:
|
||||||
"""``$(rand start stop)`` -- random integer in range."""
|
"""``$(rand start stop)`` -- random integer in range."""
|
||||||
if len(args) < 2:
|
if len(args) < 2: # noqa: PLR2004 # just checking argument count
|
||||||
raise PlaceholderError(
|
raise PlaceholderError(
|
||||||
"Invalid $(rand): too few arguments, expected $(rand start stop)"
|
"Invalid $(rand): too few arguments, expected $(rand start stop)"
|
||||||
)
|
)
|
||||||
if len(args) > 2:
|
if len(args) > 2: # noqa: PLR2004 # just checking argument count
|
||||||
raise PlaceholderError(
|
raise PlaceholderError(
|
||||||
"Invalid $(rand): too many arguments, expected $(rand start stop)"
|
"Invalid $(rand): too many arguments, expected $(rand start stop)"
|
||||||
)
|
)
|
||||||
@@ -411,13 +411,13 @@ async def _evaluate_rand(
|
|||||||
raise PlaceholderError(
|
raise PlaceholderError(
|
||||||
"Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)"
|
"Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)"
|
||||||
) from e
|
) from e
|
||||||
return str(random.randint(min(start, stop), max(start, stop))) # noqa: S311
|
return str(random.randint(min(start, stop), max(start, stop))) # noqa: S311 # not security-sensitive; chat command RNG
|
||||||
|
|
||||||
|
|
||||||
async def _evaluate_countdown(
|
async def _evaluate_countdown(
|
||||||
name: str,
|
name: str,
|
||||||
args: list[str],
|
args: list[str],
|
||||||
ctx: PlaceholderContext,
|
ctx: PlaceholderContext, # noqa: ARG001 # required by placeholder handler signature
|
||||||
) -> str:
|
) -> str:
|
||||||
"""``$(countdown date)`` / ``$(countup date)`` -- time delta."""
|
"""``$(countdown date)`` / ``$(countup date)`` -- time delta."""
|
||||||
date_str = " ".join(args)
|
date_str = " ".join(args)
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ def _parse_nodes(
|
|||||||
template: str,
|
template: str,
|
||||||
pos: int,
|
pos: int,
|
||||||
depth: int,
|
depth: int,
|
||||||
|
*,
|
||||||
inside_placeholder: bool,
|
inside_placeholder: bool,
|
||||||
max_depth: int,
|
max_depth: int,
|
||||||
) -> tuple[list[Node], int, bool]:
|
) -> tuple[list[Node], int, bool]:
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
|||||||
await asyncio.sleep(15)
|
await asyncio.sleep(15)
|
||||||
try:
|
try:
|
||||||
await response.write(b": keepalive\n\n")
|
await response.write(b": keepalive\n\n")
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 # SSE disconnect; no single exception covers all transport failures
|
||||||
ctx.logger.debug("SSE keepalive failed, disconnecting client.")
|
ctx.logger.debug("SSE keepalive failed, disconnecting client.")
|
||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
@@ -383,7 +383,7 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
|||||||
|
|
||||||
|
|
||||||
@on_route("/static/wall.js", methods=["GET"])
|
@on_route("/static/wall.js", methods=["GET"])
|
||||||
async def static_wall_js(ctx: RouteContext) -> web.FileResponse:
|
async def static_wall_js(ctx: RouteContext) -> web.FileResponse: # noqa: ARG001 # required by route handler signature
|
||||||
"""Serve the emoji wall JavaScript file.
|
"""Serve the emoji wall JavaScript file.
|
||||||
|
|
||||||
:param ctx: The route context.
|
:param ctx: The route context.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from .types import (
|
|||||||
MAX_QUESTION_LENGTH,
|
MAX_QUESTION_LENGTH,
|
||||||
MIN_DURATION,
|
MIN_DURATION,
|
||||||
MIN_OPTIONS,
|
MIN_OPTIONS,
|
||||||
|
REMINDER_THRESHOLD,
|
||||||
RESULT_EXPIRY,
|
RESULT_EXPIRY,
|
||||||
STREAM_GRACE_PERIOD,
|
STREAM_GRACE_PERIOD,
|
||||||
ActivePoll,
|
ActivePoll,
|
||||||
@@ -256,10 +257,10 @@ class PollManager:
|
|||||||
# so clearing the creation reference has no effect on it.
|
# so clearing the creation reference has no effect on it.
|
||||||
self.cancel_creation_token()
|
self.cancel_creation_token()
|
||||||
|
|
||||||
# Start auto-end timer task with 60-second reminder.
|
# Start auto-end timer task with reminder before expiry.
|
||||||
async def _poll_timer() -> None:
|
async def _poll_timer() -> None:
|
||||||
if duration > 60:
|
if duration > REMINDER_THRESHOLD:
|
||||||
await asyncio.sleep(duration - 60)
|
await asyncio.sleep(duration - REMINDER_THRESHOLD)
|
||||||
if poll.allow_multiple:
|
if poll.allow_multiple:
|
||||||
instructions = "Use <strong>!vote</strong> to get a voting link."
|
instructions = "Use <strong>!vote</strong> to get a voting link."
|
||||||
else:
|
else:
|
||||||
@@ -269,13 +270,13 @@ class PollManager:
|
|||||||
|
|
||||||
escaped_question = escape(poll.question)
|
escaped_question = escape(poll.question)
|
||||||
reminder = (
|
reminder = (
|
||||||
f"<strong>60 seconds remaining:</strong>"
|
f"<strong>{REMINDER_THRESHOLD} seconds remaining:</strong>"
|
||||||
f" {escaped_question}<br>{instructions}"
|
f" {escaped_question}<br>{instructions}"
|
||||||
)
|
)
|
||||||
await self._ctx.owncast_client.send_system_message(
|
await self._ctx.owncast_client.send_system_message(
|
||||||
reminder, unsanitized=True
|
reminder, unsanitized=True
|
||||||
)
|
)
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(REMINDER_THRESHOLD)
|
||||||
else:
|
else:
|
||||||
await asyncio.sleep(duration)
|
await asyncio.sleep(duration)
|
||||||
await self.end()
|
await self.end()
|
||||||
@@ -283,7 +284,7 @@ class PollManager:
|
|||||||
poll.timer_task = asyncio.create_task(_poll_timer())
|
poll.timer_task = asyncio.create_task(_poll_timer())
|
||||||
|
|
||||||
# Announce in chat.
|
# Announce in chat.
|
||||||
if duration >= 60:
|
if duration >= REMINDER_THRESHOLD:
|
||||||
minutes = duration // 60
|
minutes = duration // 60
|
||||||
time_str = f"{minutes} minute{'s' if minutes != 1 else ''}"
|
time_str = f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||||
else:
|
else:
|
||||||
@@ -692,5 +693,5 @@ def get_manager(ctx: ModuleContext) -> PollManager:
|
|||||||
"""
|
"""
|
||||||
manager = ctx.state.get("manager")
|
manager = ctx.state.get("manager")
|
||||||
if not isinstance(manager, PollManager):
|
if not isinstance(manager, PollManager):
|
||||||
raise RuntimeError("PollManager is not initialized.")
|
raise RuntimeError("PollManager is not initialized.") # noqa: TRY004 # state error, not a type error
|
||||||
return manager
|
return manager
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
|||||||
remaining = poll.time_remaining
|
remaining = poll.time_remaining
|
||||||
payload = sse_payload(SSEEvent.KEEPALIVE, {"time_remaining": remaining})
|
payload = sse_payload(SSEEvent.KEEPALIVE, {"time_remaining": remaining})
|
||||||
await response.write(payload)
|
await response.write(payload)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 # SSE disconnect; no single exception covers all transport failures
|
||||||
ctx.logger.debug("SSE keepalive failed for %s...", token[:8])
|
ctx.logger.debug("SSE keepalive failed for %s...", token[:8])
|
||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
@@ -439,7 +439,7 @@ async def results_page(ctx: RouteContext) -> web.Response:
|
|||||||
|
|
||||||
|
|
||||||
@on_route("/static/polls.css", methods=["GET"])
|
@on_route("/static/polls.css", methods=["GET"])
|
||||||
async def static_polls_css(ctx: RouteContext) -> web.StreamResponse:
|
async def static_polls_css(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
|
||||||
"""Serve the polls stylesheet.
|
"""Serve the polls stylesheet.
|
||||||
|
|
||||||
:param ctx: The route context.
|
:param ctx: The route context.
|
||||||
@@ -452,7 +452,7 @@ async def static_polls_css(ctx: RouteContext) -> web.StreamResponse:
|
|||||||
|
|
||||||
|
|
||||||
@on_route("/static/polls.js", methods=["GET"])
|
@on_route("/static/polls.js", methods=["GET"])
|
||||||
async def static_polls_js(ctx: RouteContext) -> web.StreamResponse:
|
async def static_polls_js(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
|
||||||
"""Serve the polls JavaScript file.
|
"""Serve the polls JavaScript file.
|
||||||
|
|
||||||
:param ctx: The route context.
|
:param ctx: The route context.
|
||||||
@@ -465,7 +465,7 @@ async def static_polls_js(ctx: RouteContext) -> web.StreamResponse:
|
|||||||
|
|
||||||
|
|
||||||
@on_route("/static/create.js", methods=["GET"])
|
@on_route("/static/create.js", methods=["GET"])
|
||||||
async def static_create_js(ctx: RouteContext) -> web.StreamResponse:
|
async def static_create_js(ctx: RouteContext) -> web.StreamResponse: # noqa: ARG001 # required by route handler signature
|
||||||
"""Serve the create form JavaScript file.
|
"""Serve the create form JavaScript file.
|
||||||
|
|
||||||
:param ctx: The route context.
|
:param ctx: The route context.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ MAX_OPTIONS = 10
|
|||||||
DEFAULT_DURATION = 240
|
DEFAULT_DURATION = 240
|
||||||
MIN_DURATION = 30
|
MIN_DURATION = 30
|
||||||
MAX_DURATION = 600
|
MAX_DURATION = 600
|
||||||
|
REMINDER_THRESHOLD = 60
|
||||||
CREATION_TOKEN_TIMEOUT = 15 * 60
|
CREATION_TOKEN_TIMEOUT = 15 * 60
|
||||||
STREAM_GRACE_PERIOD = 5 * 60
|
STREAM_GRACE_PERIOD = 5 * 60
|
||||||
RESULT_EXPIRY = 3600
|
RESULT_EXPIRY = 3600
|
||||||
|
|||||||
@@ -92,5 +92,5 @@ def get_manager(ctx: ModuleContext) -> QuoteManager:
|
|||||||
"""
|
"""
|
||||||
manager = ctx.state.get("manager")
|
manager = ctx.state.get("manager")
|
||||||
if not isinstance(manager, QuoteManager):
|
if not isinstance(manager, QuoteManager):
|
||||||
raise RuntimeError("QuoteManager is not initialized.")
|
raise RuntimeError("QuoteManager is not initialized.") # noqa: TRY004 # state error, not a type error
|
||||||
return manager
|
return manager
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ async def setup(ctx: ModuleContext) -> None:
|
|||||||
ctx.logger.info("Stream is live. Timers started.")
|
ctx.logger.info("Stream is live. Timers started.")
|
||||||
else:
|
else:
|
||||||
ctx.logger.info("Stream is offline. Timers will start on stream start.")
|
ctx.logger.info("Stream is offline. Timers will start on stream start.")
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 # best-effort; non-fatal if Owncast is unreachable during setup
|
||||||
ctx.logger.warning(
|
ctx.logger.warning(
|
||||||
"Could not check stream status. Timers will start on stream start.",
|
"Could not check stream status. Timers will start on stream start.",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async def settimermessage(ctx: CommandContext) -> None:
|
|||||||
:param ctx: The command context.
|
:param ctx: The command context.
|
||||||
"""
|
"""
|
||||||
parts = ctx.args.split(maxsplit=1)
|
parts = ctx.args.split(maxsplit=1)
|
||||||
if len(parts) < 2 or not parts[1].strip():
|
if len(parts) < 2 or not parts[1].strip(): # noqa: PLR2004 # just checking argument count
|
||||||
await ctx.owncast_client.send_message(
|
await ctx.owncast_client.send_message(
|
||||||
"Usage: !settimermessage <id|name> <message>"
|
"Usage: !settimermessage <id|name> <message>"
|
||||||
)
|
)
|
||||||
@@ -95,7 +95,7 @@ async def settimerinterval(ctx: CommandContext) -> None:
|
|||||||
:param ctx: The command context.
|
:param ctx: The command context.
|
||||||
"""
|
"""
|
||||||
parts = ctx.args.split(maxsplit=1)
|
parts = ctx.args.split(maxsplit=1)
|
||||||
if len(parts) < 2:
|
if len(parts) < 2: # noqa: PLR2004 # just checking argument count
|
||||||
await ctx.owncast_client.send_message(
|
await ctx.owncast_client.send_message(
|
||||||
"Usage: !settimerinterval <id|name> <interval>"
|
"Usage: !settimerinterval <id|name> <interval>"
|
||||||
)
|
)
|
||||||
@@ -132,7 +132,7 @@ async def settimerlines(ctx: CommandContext) -> None:
|
|||||||
:param ctx: The command context.
|
:param ctx: The command context.
|
||||||
"""
|
"""
|
||||||
args = ctx.args_list
|
args = ctx.args_list
|
||||||
if len(args) < 2:
|
if len(args) < 2: # noqa: PLR2004 # just checking argument count
|
||||||
await ctx.owncast_client.send_message("Usage: !settimerlines <id|name> <count>")
|
await ctx.owncast_client.send_message("Usage: !settimerlines <id|name> <count>")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -455,5 +455,5 @@ def get_manager(ctx: ModuleContext) -> TimerManager:
|
|||||||
"""
|
"""
|
||||||
manager = ctx.state.get("manager")
|
manager = ctx.state.get("manager")
|
||||||
if not isinstance(manager, TimerManager):
|
if not isinstance(manager, TimerManager):
|
||||||
raise RuntimeError("TimerManager is not initialized.")
|
raise RuntimeError("TimerManager is not initialized.") # noqa: TRY004 # state error, not a type error
|
||||||
return manager
|
return manager
|
||||||
|
|||||||
+10
-8
@@ -60,7 +60,7 @@ class HttpServer:
|
|||||||
config: Config,
|
config: Config,
|
||||||
event_dispatch: WebhookCallback,
|
event_dispatch: WebhookCallback,
|
||||||
route_dispatcher: RouteDispatcher,
|
route_dispatcher: RouteDispatcher,
|
||||||
):
|
) -> None:
|
||||||
"""Initialize the web server.
|
"""Initialize the web server.
|
||||||
|
|
||||||
:param config: Bot configuration.
|
:param config: Bot configuration.
|
||||||
@@ -112,11 +112,11 @@ class HttpServer:
|
|||||||
logger.debug("Binding web server to %s:%s...", host, port)
|
logger.debug("Binding web server to %s:%s...", host, port)
|
||||||
try:
|
try:
|
||||||
await site.start()
|
await site.start()
|
||||||
except OSError as e:
|
except OSError:
|
||||||
logger.error(f"Failed to bind web server to {host}:{port}: {e}")
|
logger.exception("Failed to bind web server to %s:%s.", host, port)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info(f"Web server started at: http://{host}:{port}")
|
logger.info("Web server started at: http://%s:%s", host, port)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the HTTP server."""
|
"""Stop the HTTP server."""
|
||||||
@@ -129,8 +129,8 @@ class HttpServer:
|
|||||||
"""Wait for all pending webhook dispatch tasks to complete."""
|
"""Wait for all pending webhook dispatch tasks to complete."""
|
||||||
if self._pending_tasks:
|
if self._pending_tasks:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Waiting for {len(self._pending_tasks)} "
|
"Waiting for %d pending event(s) to complete...",
|
||||||
"pending event(s) to complete..."
|
len(self._pending_tasks),
|
||||||
)
|
)
|
||||||
await asyncio.gather(*self._pending_tasks)
|
await asyncio.gather(*self._pending_tasks)
|
||||||
logger.debug("All pending events drained.")
|
logger.debug("All pending events drained.")
|
||||||
@@ -146,7 +146,7 @@ class HttpServer:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Invalid JSON received. This shouldn't happen
|
# Invalid JSON received. This shouldn't happen
|
||||||
# with legitimate Owncast webhooks.
|
# with legitimate Owncast webhooks.
|
||||||
logger.warning(f"Failed to parse webhook JSON: {e}")
|
logger.warning("Failed to parse webhook JSON: %s", e)
|
||||||
return web.Response(status=400)
|
return web.Response(status=400)
|
||||||
|
|
||||||
event_type = data.get("type", "unknown")
|
event_type = data.get("type", "unknown")
|
||||||
@@ -156,7 +156,9 @@ class HttpServer:
|
|||||||
if result is None:
|
if result is None:
|
||||||
# Owncast may have added a new webhook we don't handle yet.
|
# Owncast may have added a new webhook we don't handle yet.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Owncast sent unrecognized event type: {event_type!r}. Payload: {data}"
|
"Owncast sent unrecognized event type: %r. Payload: %s",
|
||||||
|
event_type,
|
||||||
|
data,
|
||||||
)
|
)
|
||||||
return web.Response(status=400)
|
return web.Response(status=400)
|
||||||
|
|
||||||
|
|||||||
+41
-34
@@ -22,6 +22,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
from . import builtin_modules
|
||||||
from .api.config import Config, ModuleConfig
|
from .api.config import Config, ModuleConfig
|
||||||
from .api.context import ModuleContext
|
from .api.context import ModuleContext
|
||||||
from .api.owncast_admin_client import OwncastAdminClient
|
from .api.owncast_admin_client import OwncastAdminClient
|
||||||
@@ -56,7 +57,7 @@ class ModuleLoader:
|
|||||||
modules_dir: str | Path,
|
modules_dir: str | Path,
|
||||||
config: Config,
|
config: Config,
|
||||||
http_client: HttpClient,
|
http_client: HttpClient,
|
||||||
):
|
) -> None:
|
||||||
"""Initialize the module loader.
|
"""Initialize the module loader.
|
||||||
|
|
||||||
:param modules_dir: Path to the user modules directory.
|
:param modules_dir: Path to the user modules directory.
|
||||||
@@ -105,7 +106,8 @@ class ModuleLoader:
|
|||||||
self._core_template_dir = Path(__file__).resolve().parent / "templates"
|
self._core_template_dir = Path(__file__).resolve().parent / "templates"
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"ModuleLoader initialized (user modules directory: {self.modules_dir})"
|
"ModuleLoader initialized (user modules directory: %s)",
|
||||||
|
self.modules_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_module_context(self, module_name: str) -> ModuleContext | None:
|
def get_module_context(self, module_name: str) -> ModuleContext | None:
|
||||||
@@ -146,15 +148,16 @@ class ModuleLoader:
|
|||||||
reserved = user_names & RESERVED_MODULE_NAMES
|
reserved = user_names & RESERVED_MODULE_NAMES
|
||||||
for name in sorted(reserved):
|
for name in sorted(reserved):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"User module '{name}' uses a reserved name and will be skipped."
|
"User module '%s' uses a reserved name and will be skipped.", name
|
||||||
)
|
)
|
||||||
user_names -= reserved
|
user_names -= reserved
|
||||||
|
|
||||||
overrides = user_names & BUILTIN_MODULE_NAMES
|
overrides = user_names & BUILTIN_MODULE_NAMES
|
||||||
for name in sorted(overrides):
|
for name in sorted(overrides):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"User module '{name}' found; built-in module of "
|
"User module '%s' found; built-in module of "
|
||||||
f"the same name will be skipped."
|
"the same name will be skipped.",
|
||||||
|
name,
|
||||||
)
|
)
|
||||||
|
|
||||||
merged = BUILTIN_MODULE_NAMES | user_names
|
merged = BUILTIN_MODULE_NAMES | user_names
|
||||||
@@ -162,9 +165,11 @@ class ModuleLoader:
|
|||||||
builtin_count = len(BUILTIN_MODULE_NAMES - user_names)
|
builtin_count = len(BUILTIN_MODULE_NAMES - user_names)
|
||||||
user_count = len(user_names)
|
user_count = len(user_names)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Discovered {len(modules)} module(s) "
|
"Discovered %d module(s) (%d built-in, %d user): %s",
|
||||||
f"({builtin_count} built-in, {user_count} user): "
|
len(modules),
|
||||||
f"{', '.join(modules) if modules else 'none'}"
|
builtin_count,
|
||||||
|
user_count,
|
||||||
|
", ".join(modules) if modules else "none",
|
||||||
)
|
)
|
||||||
return modules
|
return modules
|
||||||
|
|
||||||
@@ -200,7 +205,7 @@ class ModuleLoader:
|
|||||||
|
|
||||||
if found:
|
if found:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Found {len(found)} user module(s): {', '.join(sorted(found))}"
|
"Found %d user module(s): %s", len(found), ", ".join(sorted(found))
|
||||||
)
|
)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
@@ -220,7 +225,7 @@ class ModuleLoader:
|
|||||||
:return: Names of successfully loaded modules.
|
:return: Names of successfully loaded modules.
|
||||||
"""
|
"""
|
||||||
discovered = self.discover_module_names()
|
discovered = self.discover_module_names()
|
||||||
logger.info(f"Loading {len(discovered)} module(s)...")
|
logger.info("Loading %d module(s)...", len(discovered))
|
||||||
|
|
||||||
imported: list[str] = []
|
imported: list[str] = []
|
||||||
loaded: list[str] = []
|
loaded: list[str] = []
|
||||||
@@ -236,21 +241,21 @@ class ModuleLoader:
|
|||||||
if "disabled in config" in str(e):
|
if "disabled in config" in str(e):
|
||||||
disabled_count += 1
|
disabled_count += 1
|
||||||
else:
|
else:
|
||||||
logger.error(str(e))
|
logger.exception("Module import failed.")
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
|
|
||||||
# Phase 2: Run @on_setup hooks for each imported module.
|
# Phase 2: Run @on_setup hooks for each imported module.
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Import phase complete ({len(imported)} imported). "
|
"Import phase complete (%d imported). Running setup handlers...",
|
||||||
f"Running setup handlers..."
|
len(imported),
|
||||||
)
|
)
|
||||||
for module_name in imported:
|
for module_name in imported:
|
||||||
try:
|
try:
|
||||||
await self._run_module_setup(module_name)
|
await self._run_module_setup(module_name)
|
||||||
loaded.append(module_name)
|
loaded.append(module_name)
|
||||||
logger.info(f"Loaded module '{module_name}'.")
|
logger.info("Loaded module '%s'.", module_name)
|
||||||
except ModuleLoadError as e:
|
except ModuleLoadError:
|
||||||
logger.error(str(e))
|
logger.exception("Module setup failed.")
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
|
|
||||||
parts = [f"{len(loaded)} loaded"]
|
parts = [f"{len(loaded)} loaded"]
|
||||||
@@ -258,7 +263,7 @@ class ModuleLoader:
|
|||||||
parts.append(f"{disabled_count} disabled")
|
parts.append(f"{disabled_count} disabled")
|
||||||
if failed_count:
|
if failed_count:
|
||||||
parts.append(f"{failed_count} failed")
|
parts.append(f"{failed_count} failed")
|
||||||
logger.info(f"Module loading complete: {', '.join(parts)}")
|
logger.info("Module loading complete: %s", ", ".join(parts))
|
||||||
return loaded
|
return loaded
|
||||||
|
|
||||||
async def load_module(self, module_name: str, *, _run_setup: bool = True) -> None:
|
async def load_module(self, module_name: str, *, _run_setup: bool = True) -> None:
|
||||||
@@ -346,7 +351,7 @@ class ModuleLoader:
|
|||||||
|
|
||||||
if _run_setup:
|
if _run_setup:
|
||||||
await self._run_module_setup(module_name)
|
await self._run_module_setup(module_name)
|
||||||
logger.info(f"Loaded module '{module_name}'.")
|
logger.info("Loaded module '%s'.", module_name)
|
||||||
else:
|
else:
|
||||||
logger.debug("Imported module '%s' (setup deferred).", module_name)
|
logger.debug("Imported module '%s' (setup deferred).", module_name)
|
||||||
|
|
||||||
@@ -357,11 +362,13 @@ class ModuleLoader:
|
|||||||
cleanup_ctx = self._module_contexts.get(module_name)
|
cleanup_ctx = self._module_contexts.get(module_name)
|
||||||
if cleanup_ctx:
|
if cleanup_ctx:
|
||||||
try:
|
try:
|
||||||
await cleanup_ctx.storage._close()
|
# Framework lifecycle; private to module authors.
|
||||||
except Exception as close_err:
|
await cleanup_ctx.storage._close() # noqa: SLF001
|
||||||
logger.error(
|
except Exception:
|
||||||
f"Failed to close storage for module '{module_name}' "
|
logger.exception(
|
||||||
f"during load error cleanup: {close_err}"
|
"Failed to close storage for module '%s' "
|
||||||
|
"during load error cleanup.",
|
||||||
|
module_name,
|
||||||
)
|
)
|
||||||
self._cleanup_module(module_name)
|
self._cleanup_module(module_name)
|
||||||
raise ModuleLoadError(f"Failed to load module '{module_name}': {e}") from e
|
raise ModuleLoadError(f"Failed to load module '{module_name}': {e}") from e
|
||||||
@@ -374,7 +381,7 @@ class ModuleLoader:
|
|||||||
"""
|
"""
|
||||||
logger.debug("Unloading module '%s'", module_name)
|
logger.debug("Unloading module '%s'", module_name)
|
||||||
if module_name not in self.loaded_modules:
|
if module_name not in self.loaded_modules:
|
||||||
logger.warning(f"Cannot unload '{module_name}': not loaded")
|
logger.warning("Cannot unload '%s': not loaded", module_name)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
module_ctx = self._module_contexts.get(module_name)
|
module_ctx = self._module_contexts.get(module_name)
|
||||||
@@ -391,21 +398,22 @@ class ModuleLoader:
|
|||||||
logger.debug("Running @on_teardown for module: %s", module_name)
|
logger.debug("Running @on_teardown for module: %s", module_name)
|
||||||
await teardown_func(module_ctx)
|
await teardown_func(module_ctx)
|
||||||
logger.debug("Teardown completed for module: %s", module_name)
|
logger.debug("Teardown completed for module: %s", module_name)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(f"Teardown failed for module '{module_name}': {e}")
|
logger.exception("Teardown failed for module '%s'.", module_name)
|
||||||
else:
|
else:
|
||||||
logger.debug("Module '%s' has no @on_teardown handlers.", module_name)
|
logger.debug("Module '%s' has no @on_teardown handlers.", module_name)
|
||||||
|
|
||||||
if module_ctx:
|
if module_ctx:
|
||||||
try:
|
try:
|
||||||
await module_ctx.storage._close()
|
# Framework lifecycle; private to module authors.
|
||||||
except Exception as e:
|
await module_ctx.storage._close() # noqa: SLF001
|
||||||
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Failed to close storage for module '{module_name}': {e}"
|
"Failed to close storage for module '%s'.", module_name
|
||||||
)
|
)
|
||||||
|
|
||||||
self._cleanup_module(module_name)
|
self._cleanup_module(module_name)
|
||||||
logger.info(f"Unloaded module '{module_name}'.")
|
logger.info("Unloaded module '%s'.", module_name)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def unload_all_modules(self) -> None:
|
async def unload_all_modules(self) -> None:
|
||||||
@@ -416,7 +424,7 @@ class ModuleLoader:
|
|||||||
if not self.loaded_modules:
|
if not self.loaded_modules:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"Unloading {len(self.loaded_modules)} module(s)...")
|
logger.info("Unloading %d module(s)...", len(self.loaded_modules))
|
||||||
for module_name in list(self.loaded_modules):
|
for module_name in list(self.loaded_modules):
|
||||||
await self.unload_module(module_name)
|
await self.unload_module(module_name)
|
||||||
logger.info("All module unload complete.")
|
logger.info("All module unload complete.")
|
||||||
@@ -446,7 +454,8 @@ class ModuleLoader:
|
|||||||
await setup_func(module_ctx)
|
await setup_func(module_ctx)
|
||||||
logger.debug("Setup completed for module: %s", module_name)
|
logger.debug("Setup completed for module: %s", module_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await module_ctx.storage._close()
|
# Framework lifecycle; private to module authors.
|
||||||
|
await module_ctx.storage._close() # noqa: SLF001
|
||||||
self._cleanup_module(module_name)
|
self._cleanup_module(module_name)
|
||||||
raise ModuleLoadError(
|
raise ModuleLoadError(
|
||||||
f"Setup failed for module '{module_name}': {e}"
|
f"Setup failed for module '{module_name}': {e}"
|
||||||
@@ -486,8 +495,6 @@ class ModuleLoader:
|
|||||||
:param module_name: Name of the built-in module.
|
:param module_name: Name of the built-in module.
|
||||||
:return: Path to the module's ``__init__.py``.
|
:return: Path to the module's ``__init__.py``.
|
||||||
"""
|
"""
|
||||||
from . import builtin_modules
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Path(builtin_modules.__file__).resolve().parent
|
Path(builtin_modules.__file__).resolve().parent
|
||||||
/ module_name
|
/ module_name
|
||||||
|
|||||||
@@ -26,15 +26,16 @@ import time
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from ..api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
|
from owlbot._version import __version__
|
||||||
from ..api.context import CommandContext, EventContext
|
from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
|
||||||
from ..api.event_types import ChatEvent
|
from owlbot.api.context import CommandContext, EventContext
|
||||||
|
from owlbot.api.event_types import ChatEvent
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from ..api.context import ModuleContext
|
from owlbot.api.context import ModuleContext
|
||||||
from ..api.owncast_client import OwncastClient
|
from owlbot.api.owncast_client import OwncastClient
|
||||||
|
|
||||||
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
|
type BuiltinCommandHandler = Callable[[ChatEvent, "OwncastClient"], Awaitable[None]]
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ class CommandRegistry:
|
|||||||
aliases: list[str] | tuple[str, ...] | None = None,
|
aliases: list[str] | tuple[str, ...] | None = None,
|
||||||
requires_authenticated: bool = False,
|
requires_authenticated: bool = False,
|
||||||
requires_moderator: bool = False,
|
requires_moderator: bool = False,
|
||||||
cooldown: int | float = 0,
|
cooldown: int = 0,
|
||||||
module_name: str,
|
module_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register a command handler.
|
"""Register a command handler.
|
||||||
@@ -317,7 +318,7 @@ class CommandDispatcher:
|
|||||||
aliases: list[str] | tuple[str, ...] | None = None,
|
aliases: list[str] | tuple[str, ...] | None = None,
|
||||||
requires_authenticated: bool = False,
|
requires_authenticated: bool = False,
|
||||||
requires_moderator: bool = False,
|
requires_moderator: bool = False,
|
||||||
cooldown: int | float = 0,
|
cooldown: int = 0,
|
||||||
module_name: str,
|
module_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register a command handler.
|
"""Register a command handler.
|
||||||
@@ -438,14 +439,17 @@ class CommandDispatcher:
|
|||||||
user = event.user
|
user = event.user
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Command '{command_info.name}' invoked by {user.display_name}"
|
"Command '%s' invoked by %s with args: %r",
|
||||||
f" with args: {args!r}"
|
command_info.name,
|
||||||
|
user.display_name,
|
||||||
|
args,
|
||||||
)
|
)
|
||||||
|
|
||||||
if command_info.requires_authenticated and not user.is_authenticated:
|
if command_info.requires_authenticated and not user.is_authenticated:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Command '{command_name}' denied for {user.display_name}: "
|
"Command '%s' denied for %s: authentication required",
|
||||||
"authentication required"
|
command_name,
|
||||||
|
user.display_name,
|
||||||
)
|
)
|
||||||
await self._owncast_client.send_system_message_to_client(
|
await self._owncast_client.send_system_message_to_client(
|
||||||
event.client_id,
|
event.client_id,
|
||||||
@@ -455,8 +459,9 @@ class CommandDispatcher:
|
|||||||
|
|
||||||
if command_info.requires_moderator and not user.is_moderator:
|
if command_info.requires_moderator and not user.is_moderator:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Command '{command_name}' denied for {user.display_name}: "
|
"Command '%s' denied for %s: moderator required",
|
||||||
"moderator required"
|
command_name,
|
||||||
|
user.display_name,
|
||||||
)
|
)
|
||||||
await self._owncast_client.send_system_message_to_client(
|
await self._owncast_client.send_system_message_to_client(
|
||||||
event.client_id,
|
event.client_id,
|
||||||
@@ -470,8 +475,10 @@ class CommandDispatcher:
|
|||||||
if last is not None and now - last < command_info.cooldown:
|
if last is not None and now - last < command_info.cooldown:
|
||||||
remaining = math.ceil(command_info.cooldown - (now - last))
|
remaining = math.ceil(command_info.cooldown - (now - last))
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Command '{command_name}' denied for {user.display_name}: "
|
"Command '%s' denied for %s: on cooldown (%ds remaining)",
|
||||||
f"on cooldown ({remaining}s remaining)"
|
command_name,
|
||||||
|
user.display_name,
|
||||||
|
remaining,
|
||||||
)
|
)
|
||||||
await self._owncast_client.send_system_message_to_client(
|
await self._owncast_client.send_system_message_to_client(
|
||||||
event.client_id,
|
event.client_id,
|
||||||
@@ -501,12 +508,14 @@ class CommandDispatcher:
|
|||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Built-in command '{command_info.name}' "
|
"Built-in command '%s' cancelled after %ss timeout.",
|
||||||
f"cancelled after {self._handler_timeout}s timeout."
|
command_info.name,
|
||||||
|
self._handler_timeout,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Built-in command '{command_info.name}' raised exception: {e}"
|
"Built-in command '%s' raised exception.",
|
||||||
|
command_info.name,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -544,26 +553,25 @@ class CommandDispatcher:
|
|||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Command handler '{command_info.name}' "
|
"Command handler '%s' from module '%s' cancelled after %ss timeout.",
|
||||||
f"from module '{command_info.module_name}' "
|
command_info.name,
|
||||||
f"cancelled after "
|
command_info.module_name,
|
||||||
f"{self._handler_timeout}s timeout."
|
self._handler_timeout,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Command handler '{command_info.name}' "
|
"Command handler '%s' from module '%s' raised exception.",
|
||||||
f"from module '{command_info.module_name}' "
|
command_info.name,
|
||||||
f"raised exception: {e}"
|
command_info.module_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _register_builtin_commands(self) -> None:
|
def _register_builtin_commands(self) -> None:
|
||||||
"""Register all built-in commands."""
|
"""Register all built-in commands."""
|
||||||
from .._version import __version__
|
|
||||||
|
|
||||||
loaded_modules = self._loaded_modules
|
loaded_modules = self._loaded_modules
|
||||||
|
|
||||||
async def about_with_modules(
|
async def about_with_modules(
|
||||||
event: ChatEvent, owncast_client: OwncastClient
|
event: ChatEvent, # noqa: ARG001 # required by builtin handler signature
|
||||||
|
owncast_client: OwncastClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
module_count = len(loaded_modules)
|
module_count = len(loaded_modules)
|
||||||
module_list = (
|
module_list = (
|
||||||
@@ -612,7 +620,7 @@ class ModuleCommands:
|
|||||||
aliases: list[str] | tuple[str, ...] | None = None,
|
aliases: list[str] | tuple[str, ...] | None = None,
|
||||||
requires_authenticated: bool = False,
|
requires_authenticated: bool = False,
|
||||||
requires_moderator: bool = False,
|
requires_moderator: bool = False,
|
||||||
cooldown: int | float = 0,
|
cooldown: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register a command handler for this module.
|
"""Register a command handler for this module.
|
||||||
|
|
||||||
|
|||||||
+15
-16
@@ -26,9 +26,9 @@ import time
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import TYPE_CHECKING, NamedTuple, cast
|
from typing import TYPE_CHECKING, NamedTuple, cast
|
||||||
|
|
||||||
from ..api.context import EventContext, PropagationState
|
from owlbot.api.context import EventContext, PropagationState
|
||||||
from ..api.event_types import ChatEvent, Event, EventType, log_event
|
from owlbot.api.event_types import ChatEvent, Event, EventType, log_event
|
||||||
from ..api.events import EventHandler, EventMark, Priority
|
from owlbot.api.events import EventHandler, EventMark, Priority
|
||||||
|
|
||||||
|
|
||||||
class HandlerEntry(NamedTuple):
|
class HandlerEntry(NamedTuple):
|
||||||
@@ -45,7 +45,7 @@ if TYPE_CHECKING:
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from ..api.context import ModuleContext
|
from owlbot.api.context import ModuleContext
|
||||||
|
|
||||||
logger = logging.getLogger("owlbot.events")
|
logger = logging.getLogger("owlbot.events")
|
||||||
|
|
||||||
@@ -344,9 +344,7 @@ class EventDispatcher:
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
await self._call_handler(
|
await self._call_handler(handler, event, module_name, propagation)
|
||||||
handler, event, event_type, module_name, propagation
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
logger.debug("No handlers registered for event type: %s", event_type)
|
logger.debug("No handlers registered for event type: %s", event_type)
|
||||||
|
|
||||||
@@ -362,14 +360,13 @@ class EventDispatcher:
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
await self._command_dispatch(event)
|
await self._command_dispatch(event)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(f"Command dispatch failed: {e}")
|
logger.exception("Command dispatch failed.")
|
||||||
|
|
||||||
async def _call_handler(
|
async def _call_handler(
|
||||||
self,
|
self,
|
||||||
handler: EventHandler,
|
handler: EventHandler,
|
||||||
event: Event,
|
event: Event,
|
||||||
event_type: EventType,
|
|
||||||
module_name: str,
|
module_name: str,
|
||||||
propagation: PropagationState,
|
propagation: PropagationState,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -377,7 +374,6 @@ class EventDispatcher:
|
|||||||
|
|
||||||
:param handler: The handler function to call.
|
:param handler: The handler function to call.
|
||||||
:param event: The event to pass to the handler.
|
: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 module_name: The module that owns this handler.
|
||||||
:param propagation: Shared propagation state for this dispatch cycle.
|
:param propagation: Shared propagation state for this dispatch cycle.
|
||||||
"""
|
"""
|
||||||
@@ -400,13 +396,16 @@ class EventDispatcher:
|
|||||||
logger.debug("Handler '%s' completed in %.1fms.", handler_name, elapsed)
|
logger.debug("Handler '%s' completed in %.1fms.", handler_name, elapsed)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Handler '{handler_name}' from module '{module_name}' "
|
"Handler '%s' from module '%s' cancelled after %ss timeout.",
|
||||||
f"cancelled after {self._handler_timeout}s timeout."
|
handler_name,
|
||||||
|
module_name,
|
||||||
|
self._handler_timeout,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Handler '{handler_name}' from module "
|
"Handler '%s' from module '%s' raised exception.",
|
||||||
f"'{module_name}' raised exception: {e}"
|
handler_name,
|
||||||
|
module_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -30,14 +30,14 @@ import orjson
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web import DynamicResource
|
from aiohttp.web import DynamicResource
|
||||||
|
|
||||||
from ..api.context import RouteContext
|
from owlbot.api.context import RouteContext
|
||||||
from ..api.routes import RouteHandler, RouteInfo, RouteMark
|
from owlbot.api.routes import RouteHandler, RouteInfo, RouteMark
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from ..api.context import ModuleContext
|
from owlbot.api.context import ModuleContext
|
||||||
|
|
||||||
logger = logging.getLogger("owlbot.web")
|
logger = logging.getLogger("owlbot.web")
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@ class RouteRegistry:
|
|||||||
"""
|
"""
|
||||||
method = method.upper()
|
method = method.upper()
|
||||||
for group in self._groups:
|
for group in self._groups:
|
||||||
match_dict = group.resource._match(full_path) # no public API alternative
|
match_dict = group.resource._match(full_path) # noqa: SLF001 # no public API alternative
|
||||||
if match_dict is not None:
|
if match_dict is not None:
|
||||||
for handler in group.handlers:
|
for handler in group.handlers:
|
||||||
if method in handler.methods:
|
if method in handler.methods:
|
||||||
@@ -615,20 +615,23 @@ class RouteDispatcher:
|
|||||||
)
|
)
|
||||||
# pragma: no branch — defensive against untyped handlers
|
# pragma: no branch — defensive against untyped handlers
|
||||||
mod_logger.error( # type: ignore[unreachable]
|
mod_logger.error( # type: ignore[unreachable]
|
||||||
f"Route handler '{route_info.full_path}' returned "
|
"Route handler '%s' returned unsupported type: %s",
|
||||||
f"unsupported type: {type(result).__name__}"
|
route_info.full_path,
|
||||||
|
type(result).__name__,
|
||||||
)
|
)
|
||||||
return web.Response(status=500)
|
return web.Response(status=500)
|
||||||
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
mod_logger.warning(
|
mod_logger.warning(
|
||||||
f"Route handler '{route_info.full_path}' timed out "
|
"Route handler '%s' timed out after %ss.",
|
||||||
f"after {self._handler_timeout}s."
|
route_info.full_path,
|
||||||
|
self._handler_timeout,
|
||||||
)
|
)
|
||||||
return web.Response(status=500)
|
return web.Response(status=500)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
mod_logger.exception(
|
mod_logger.exception(
|
||||||
f"Route handler '{route_info.full_path}' raised exception: {e}"
|
"Route handler '%s' raised exception.",
|
||||||
|
route_info.full_path,
|
||||||
)
|
)
|
||||||
return web.Response(status=500)
|
return web.Response(status=500)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+20
-48
@@ -72,60 +72,32 @@ target-version = "py312"
|
|||||||
extend-exclude = ["owlbot/_version.py"] # auto-generated by hatch-vcs
|
extend-exclude = ["owlbot/_version.py"] # auto-generated by hatch-vcs
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = [
|
select = ["ALL"]
|
||||||
# Core
|
|
||||||
"F", # Pyflakes
|
|
||||||
"E", # pycodestyle errors
|
|
||||||
"W", # pycodestyle warnings
|
|
||||||
"N", # pep8-naming
|
|
||||||
"D", # pydocstyle
|
|
||||||
"I", # isort
|
|
||||||
"ICN", # flake8-import-conventions
|
|
||||||
|
|
||||||
# Correctness & bugs
|
|
||||||
"B", # flake8-bugbear
|
|
||||||
"ASYNC", # flake8-async
|
|
||||||
"DTZ", # flake8-datetimez
|
|
||||||
"RSE", # flake8-raise
|
|
||||||
"RET", # flake8-return
|
|
||||||
"A", # flake8-builtins
|
|
||||||
"PIE", # flake8-pie
|
|
||||||
|
|
||||||
# Modernization & simplification
|
|
||||||
"UP", # pyupgrade
|
|
||||||
"SIM", # flake8-simplify
|
|
||||||
"C4", # flake8-comprehensions
|
|
||||||
"FLY", # flynt (f-string conversion)
|
|
||||||
"PTH", # flake8-use-pathlib
|
|
||||||
|
|
||||||
# Performance
|
|
||||||
"PERF", # Perflint
|
|
||||||
|
|
||||||
# Security
|
|
||||||
"S", # flake8-bandit
|
|
||||||
|
|
||||||
# Code hygiene
|
|
||||||
"T10", # flake8-debugger
|
|
||||||
"T20", # flake8-print
|
|
||||||
"ERA", # eradicate
|
|
||||||
"PGH", # pygrep-hooks
|
|
||||||
"TCH", # flake8-type-checking
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
"PT", # flake8-pytest-style
|
|
||||||
|
|
||||||
# Ruff-specific
|
|
||||||
"RUF", # Ruff-specific rules
|
|
||||||
]
|
|
||||||
ignore = [
|
ignore = [
|
||||||
"D203", # incompatible with D211 (no blank line before class docstring)
|
"ANN401", # Any is valid at system boundaries; mypy strict handles real issues
|
||||||
"D213", # incompatible with D212 (summary on first line)
|
"C901", # McCabe complexity: noisy and not actionable
|
||||||
|
"COM812", # handled by the formatter
|
||||||
|
"D203", # incompatible with D211 (no blank line before class docstring)
|
||||||
|
"D213", # incompatible with D212 (summary on first line)
|
||||||
|
"EM", # exception message style: inline literals are fine
|
||||||
|
"PLR0911", # too many return statements: flat early-returns are clear
|
||||||
|
"PLR0912", # too many branches: inherent in parsers, validators, CLI
|
||||||
|
"PLR0913", # too many arguments: API surfaces and constructors need them
|
||||||
|
"PLR0915", # too many statements: inherent in parsers, validators, CLI
|
||||||
|
"TRY003", # inline exception messages are fine (complements EM ignore)
|
||||||
|
"TRY301", # raise inside try: guard clauses don't need helper functions
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"owlbot/__init__.py" = ["E402"] # constants defined before imports intentionally
|
"owlbot/__init__.py" = ["E402"] # constants defined before imports intentionally
|
||||||
"owlbot/__main__.py" = ["T201"] # CLI entry point uses print() for user output
|
"owlbot/__main__.py" = ["T201"] # CLI entry point uses print() for user output
|
||||||
"tests/**" = ["S101", "S311"] # assert is standard for pytest; random is fine in tests
|
"tests/**" = [
|
||||||
|
"S101", # assert is standard for pytest
|
||||||
|
"S311", # pseudo-random generators are fine in tests
|
||||||
|
"SLF001", # tests legitimately access private members for verification
|
||||||
|
"ARG001", # unused args are normal for fixtures and handler stubs
|
||||||
|
"PLR2004", # magic values are clear in test assertions
|
||||||
|
]
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
skip = "owlbot/_version.py,uv.lock"
|
skip = "owlbot/_version.py,uv.lock"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Owlbot test suite."""
|
||||||
@@ -116,6 +116,7 @@ class TestOnCommandDecorator:
|
|||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
aliases: list[str] | None,
|
aliases: list[str] | None,
|
||||||
|
*,
|
||||||
auth: bool,
|
auth: bool,
|
||||||
mod: bool,
|
mod: bool,
|
||||||
cooldown: int,
|
cooldown: int,
|
||||||
@@ -503,7 +504,7 @@ class TestCommandDispatcherDispatch:
|
|||||||
module_ctx = make_module_context(storage, module_name)
|
module_ctx = make_module_context(storage, module_name)
|
||||||
|
|
||||||
dispatcher = CommandDispatcher(
|
dispatcher = CommandDispatcher(
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
owncast_client=stub, # type: ignore[arg-type]
|
owncast_client=stub, # type: ignore[arg-type]
|
||||||
handler_timeout=handler_timeout,
|
handler_timeout=handler_timeout,
|
||||||
loaded_modules=set(),
|
loaded_modules=set(),
|
||||||
@@ -761,7 +762,7 @@ class TestCommandDispatcherDispatch:
|
|||||||
row = await storage.fetch_one("SELECT * FROM items WHERE id = ?", (1,))
|
row = await storage.fetch_one("SELECT * FROM items WHERE id = ?", (1,))
|
||||||
assert row is not None
|
assert row is not None
|
||||||
assert row["name"] == "apple"
|
assert row["name"] == "apple"
|
||||||
assert "raised exception: boom" in caplog.text
|
assert "raised exception." in caplog.text
|
||||||
|
|
||||||
async def test_timeout_is_logged(
|
async def test_timeout_is_logged(
|
||||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||||
@@ -854,7 +855,7 @@ class TestCommandDispatcherDispatch:
|
|||||||
with caplog.at_level(logging.ERROR, logger="owlbot.commands"):
|
with caplog.at_level(logging.ERROR, logger="owlbot.commands"):
|
||||||
await dispatcher.dispatch(_make_chat_event(body="!test"))
|
await dispatcher.dispatch(_make_chat_event(body="!test"))
|
||||||
|
|
||||||
assert "raised exception: builtin-fail" in caplog.text
|
assert "raised exception." in caplog.text
|
||||||
|
|
||||||
async def test_builtin_command_timeout(
|
async def test_builtin_command_timeout(
|
||||||
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
self, storage: ModuleStorage, caplog: pytest.LogCaptureFixture
|
||||||
@@ -963,6 +964,7 @@ class TestModuleCommands:
|
|||||||
other_setup: str | None,
|
other_setup: str | None,
|
||||||
call_module: str,
|
call_module: str,
|
||||||
trigger: str,
|
trigger: str,
|
||||||
|
*,
|
||||||
expected: bool,
|
expected: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""ModuleCommands.unregister respects ownership."""
|
"""ModuleCommands.unregister respects ownership."""
|
||||||
@@ -1059,7 +1061,7 @@ class TestCommandContext:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_property_proxying(
|
def test_property_proxying(
|
||||||
self, storage: ModuleStorage, prop: str, use_is: bool
|
self, storage: ModuleStorage, prop: str, *, use_is: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
"""CommandContext properties proxy to the underlying ModuleContext."""
|
"""CommandContext properties proxy to the underlying ModuleContext."""
|
||||||
cmd_ctx, module_ctx, _, _ = self._make_command_context(storage)
|
cmd_ctx, module_ctx, _, _ = self._make_command_context(storage)
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ class _StubModuleCommands:
|
|||||||
aliases: list[str] | tuple[str, ...] | None = None,
|
aliases: list[str] | tuple[str, ...] | None = None,
|
||||||
requires_authenticated: bool = False,
|
requires_authenticated: bool = False,
|
||||||
requires_moderator: bool = False,
|
requires_moderator: bool = False,
|
||||||
cooldown: int | float = 0,
|
cooldown: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
all_new = {name} | set(aliases or [])
|
all_new = {name} | set(aliases or [])
|
||||||
for trigger in all_new:
|
for trigger in all_new:
|
||||||
@@ -551,6 +551,7 @@ class _StubModuleCommands:
|
|||||||
self._registered[name] = {
|
self._registered[name] = {
|
||||||
"handler": handler,
|
"handler": handler,
|
||||||
"aliases": set(aliases or []),
|
"aliases": set(aliases or []),
|
||||||
|
"requires_authenticated": requires_authenticated,
|
||||||
"requires_moderator": requires_moderator,
|
"requires_moderator": requires_moderator,
|
||||||
"cooldown": cooldown,
|
"cooldown": cooldown,
|
||||||
}
|
}
|
||||||
@@ -702,7 +703,7 @@ class TestManagerCreateCommand:
|
|||||||
cmd_ctx: ModuleContext,
|
cmd_ctx: ModuleContext,
|
||||||
stub_commands: _StubModuleCommands,
|
stub_commands: _StubModuleCommands,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""If DB insert fails, the registry registration is rolled back."""
|
"""If DB insert fails, the command is not registered."""
|
||||||
# Create a manager with a repo whose storage is closed.
|
# Create a manager with a repo whose storage is closed.
|
||||||
closed_storage = ModuleStorage(None, "closed")
|
closed_storage = ModuleStorage(None, "closed")
|
||||||
bad_repo = CommandRepository(closed_storage)
|
bad_repo = CommandRepository(closed_storage)
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ class TestUser:
|
|||||||
pytest.param(["MOD", "MODERATOR"], True, id="moderator-among-others"),
|
pytest.param(["MOD", "MODERATOR"], True, id="moderator-among-others"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_is_moderator(self, scopes: list[str], expected: bool) -> None:
|
def test_is_moderator(self, scopes: list[str], *, expected: bool) -> None:
|
||||||
"""is_moderator reflects whether MODERATOR is in scopes."""
|
"""is_moderator reflects whether MODERATOR is in scopes."""
|
||||||
user = User.from_dict({"scopes": scopes})
|
user = User.from_dict({"scopes": scopes})
|
||||||
assert user.is_moderator is expected
|
assert user.is_moderator is expected
|
||||||
|
|||||||
+23
-22
@@ -305,6 +305,7 @@ class TestEventRegistry:
|
|||||||
def test_unregister(
|
def test_unregister(
|
||||||
self,
|
self,
|
||||||
setup_types: tuple[EventType, ...] | None,
|
setup_types: tuple[EventType, ...] | None,
|
||||||
|
*,
|
||||||
expected_return: bool,
|
expected_return: bool,
|
||||||
expected_remaining: int,
|
expected_remaining: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -437,7 +438,7 @@ class TestEventDispatcherDispatch:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
|
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
|
||||||
@@ -466,7 +467,7 @@ class TestEventDispatcherDispatch:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
dispatcher.register(high_handler, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
dispatcher.register(high_handler, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
||||||
@@ -492,7 +493,7 @@ class TestEventDispatcherDispatch:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
dispatcher.register(first, (EventType.CHAT,), "mod_a", Priority.NORMAL)
|
dispatcher.register(first, (EventType.CHAT,), "mod_a", Priority.NORMAL)
|
||||||
@@ -511,7 +512,7 @@ class TestEventDispatcherDispatch:
|
|||||||
module_ctx = make_module_context(storage, "mod_a")
|
module_ctx = make_module_context(storage, "mod_a")
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
with caplog.at_level(logging.DEBUG, logger="owlbot.events"):
|
with caplog.at_level(logging.DEBUG, logger="owlbot.events"):
|
||||||
@@ -539,7 +540,7 @@ class TestEventDispatcherDispatch:
|
|||||||
module_ctx = make_module_context(storage, "mod_a")
|
module_ctx = make_module_context(storage, "mod_a")
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
chat_event = _make_chat_event(body="hello")
|
chat_event = _make_chat_event(body="hello")
|
||||||
@@ -567,7 +568,7 @@ class TestEventDispatcherDispatch:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
||||||
@@ -593,7 +594,7 @@ class TestEventDispatcherDispatch:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
dispatcher.register(handler_a, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
||||||
@@ -624,7 +625,7 @@ class TestEventDispatcherDispatch:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
dispatcher.register(stopper, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
dispatcher.register(stopper, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
||||||
@@ -657,7 +658,7 @@ class TestEventDispatcherStorage:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
|
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
|
||||||
@@ -687,7 +688,7 @@ class TestEventDispatcherStorage:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
|
dispatcher.register(handler, (EventType.CHAT,), "mod_a")
|
||||||
@@ -697,7 +698,7 @@ class TestEventDispatcherStorage:
|
|||||||
row = await storage.fetch_one("SELECT val FROM test_tbl")
|
row = await storage.fetch_one("SELECT val FROM test_tbl")
|
||||||
assert row is not None
|
assert row is not None
|
||||||
assert row[0] == "persisted"
|
assert row[0] == "persisted"
|
||||||
assert any("raised exception: boom" in r.message for r in caplog.records)
|
assert any("raised exception." in r.message for r in caplog.records)
|
||||||
|
|
||||||
async def test_exception_does_not_stop_next_handler(
|
async def test_exception_does_not_stop_next_handler(
|
||||||
self, storage: ModuleStorage
|
self, storage: ModuleStorage
|
||||||
@@ -719,7 +720,7 @@ class TestEventDispatcherStorage:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
dispatcher.register(failing_handler, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
dispatcher.register(failing_handler, (EventType.CHAT,), "mod_a", Priority.HIGH)
|
||||||
@@ -742,7 +743,7 @@ class TestEventDispatcherStorage:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=0.05,
|
handler_timeout=0.05,
|
||||||
)
|
)
|
||||||
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
|
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
|
||||||
@@ -772,7 +773,7 @@ class TestEventDispatcherStorage:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=0.05,
|
handler_timeout=0.05,
|
||||||
)
|
)
|
||||||
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
|
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
|
||||||
@@ -802,7 +803,7 @@ class TestEventDispatcherStorage:
|
|||||||
|
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=0.05,
|
handler_timeout=0.05,
|
||||||
)
|
)
|
||||||
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
|
dispatcher.register(slow_handler, (EventType.CHAT,), "mod_a")
|
||||||
@@ -820,7 +821,7 @@ class TestCommandDispatchPhase:
|
|||||||
[
|
[
|
||||||
pytest.param(
|
pytest.param(
|
||||||
EventType.CHAT,
|
EventType.CHAT,
|
||||||
lambda: _make_chat_event(),
|
_make_chat_event,
|
||||||
1,
|
1,
|
||||||
id="chat-triggers",
|
id="chat-triggers",
|
||||||
),
|
),
|
||||||
@@ -853,7 +854,7 @@ class TestCommandDispatchPhase:
|
|||||||
module_ctx = make_module_context(storage, "mod_a")
|
module_ctx = make_module_context(storage, "mod_a")
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
event = event_factory()
|
event = event_factory()
|
||||||
@@ -877,7 +878,7 @@ class TestCommandDispatchPhase:
|
|||||||
module_ctx = make_module_context(storage, "mod_a")
|
module_ctx = make_module_context(storage, "mod_a")
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
dispatcher.register(stopper, (EventType.CHAT,), "mod_a")
|
dispatcher.register(stopper, (EventType.CHAT,), "mod_a")
|
||||||
@@ -899,12 +900,12 @@ class TestCommandDispatchPhase:
|
|||||||
module_ctx = make_module_context(storage, "mod_a")
|
module_ctx = make_module_context(storage, "mod_a")
|
||||||
dispatcher = EventDispatcher(
|
dispatcher = EventDispatcher(
|
||||||
command_dispatch=command_dispatch,
|
command_dispatch=command_dispatch,
|
||||||
get_module_context=lambda name: module_ctx,
|
get_module_context=lambda _name: module_ctx,
|
||||||
handler_timeout=5.0,
|
handler_timeout=5.0,
|
||||||
)
|
)
|
||||||
with caplog.at_level(logging.ERROR, logger="owlbot.events"):
|
with caplog.at_level(logging.ERROR, logger="owlbot.events"):
|
||||||
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
await dispatcher.dispatch(EventType.CHAT, _make_chat_event())
|
||||||
assert any("Command dispatch failed: fail" in r.message for r in caplog.records)
|
assert any("Command dispatch failed." in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
class TestModuleEvents:
|
class TestModuleEvents:
|
||||||
@@ -974,7 +975,7 @@ class TestModuleEvents:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_unregister(
|
def test_unregister(
|
||||||
self, register_on: str | None, unregister_from: str, expected: bool
|
self, register_on: str | None, unregister_from: str, *, expected: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
"""ModuleEvents.unregister() respects ownership checks."""
|
"""ModuleEvents.unregister() respects ownership checks."""
|
||||||
|
|
||||||
@@ -1061,7 +1062,7 @@ class TestEventContext:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_property_proxying(
|
def test_property_proxying(
|
||||||
self, storage: ModuleStorage, prop: str, use_is: bool
|
self, storage: ModuleStorage, prop: str, *, use_is: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
"""EventContext properties proxy to the underlying ModuleContext."""
|
"""EventContext properties proxy to the underlying ModuleContext."""
|
||||||
module_ctx = make_module_context(storage, "mod_a")
|
module_ctx = make_module_context(storage, "mod_a")
|
||||||
|
|||||||
+17
-14
@@ -24,6 +24,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import types
|
import types
|
||||||
|
from http import HTTPStatus
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -86,6 +87,7 @@ class TestOnRouteDecorator:
|
|||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
methods: list[str] | None,
|
methods: list[str] | None,
|
||||||
|
*,
|
||||||
streaming: bool,
|
streaming: bool,
|
||||||
expected_path: str,
|
expected_path: str,
|
||||||
expected_methods: list[str] | None,
|
expected_methods: list[str] | None,
|
||||||
@@ -123,7 +125,7 @@ class TestRouteInfo:
|
|||||||
pytest.param(False, id="streaming-false"),
|
pytest.param(False, id="streaming-false"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_stores_streaming(self, streaming: bool) -> None:
|
def test_stores_streaming(self, *, streaming: bool) -> None:
|
||||||
"""RouteInfo stores the streaming flag."""
|
"""RouteInfo stores the streaming flag."""
|
||||||
info = RouteInfo(
|
info = RouteInfo(
|
||||||
path="/x",
|
path="/x",
|
||||||
@@ -506,7 +508,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = "api"
|
request.match_info["path"] = "api"
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 200
|
assert response.status == HTTPStatus.OK
|
||||||
assert response.content_type == "application/json"
|
assert response.content_type == "application/json"
|
||||||
|
|
||||||
async def test_dispatch_web_response(self) -> None:
|
async def test_dispatch_web_response(self) -> None:
|
||||||
@@ -523,7 +525,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = "page"
|
request.match_info["path"] = "page"
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 201
|
assert response.status == HTTPStatus.CREATED
|
||||||
|
|
||||||
async def test_dispatch_none_response(self) -> None:
|
async def test_dispatch_none_response(self) -> None:
|
||||||
"""Handler returning None produces 204 No Content."""
|
"""Handler returning None produces 204 No Content."""
|
||||||
@@ -539,7 +541,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = "hook"
|
request.match_info["path"] = "hook"
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 204
|
assert response.status == HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
async def test_dispatch_404(self) -> None:
|
async def test_dispatch_404(self) -> None:
|
||||||
"""Dispatch to unregistered path returns 404."""
|
"""Dispatch to unregistered path returns 404."""
|
||||||
@@ -550,7 +552,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = "nope"
|
request.match_info["path"] = "nope"
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 404
|
assert response.status == HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
async def test_dispatch_405(self) -> None:
|
async def test_dispatch_405(self) -> None:
|
||||||
"""Dispatch with wrong method returns 405 with Allow header."""
|
"""Dispatch with wrong method returns 405 with Allow header."""
|
||||||
@@ -562,7 +564,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = "data"
|
request.match_info["path"] = "data"
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 405
|
assert response.status == HTTPStatus.METHOD_NOT_ALLOWED
|
||||||
assert "GET" in response.headers["Allow"]
|
assert "GET" in response.headers["Allow"]
|
||||||
|
|
||||||
async def test_dispatch_handler_exception(self) -> None:
|
async def test_dispatch_handler_exception(self) -> None:
|
||||||
@@ -579,7 +581,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = "bad"
|
request.match_info["path"] = "bad"
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 500
|
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
async def test_dispatch_unsupported_return_type(self) -> None:
|
async def test_dispatch_unsupported_return_type(self) -> None:
|
||||||
"""Handler returning unsupported type produces 500."""
|
"""Handler returning unsupported type produces 500."""
|
||||||
@@ -595,7 +597,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = "bad"
|
request.match_info["path"] = "bad"
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 500
|
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
async def test_dispatch_timeout(self) -> None:
|
async def test_dispatch_timeout(self) -> None:
|
||||||
"""Non-streaming handler exceeding timeout returns 500."""
|
"""Non-streaming handler exceeding timeout returns 500."""
|
||||||
@@ -612,7 +614,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = "slow"
|
request.match_info["path"] = "slow"
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 500
|
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
async def test_dispatch_streaming_bypasses_timeout(self) -> None:
|
async def test_dispatch_streaming_bypasses_timeout(self) -> None:
|
||||||
"""Streaming handler exceeding timeout is not cancelled."""
|
"""Streaming handler exceeding timeout is not cancelled."""
|
||||||
@@ -629,7 +631,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = "stream"
|
request.match_info["path"] = "stream"
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 200
|
assert response.status == HTTPStatus.OK
|
||||||
|
|
||||||
async def test_dispatch_streaming_exception(self) -> None:
|
async def test_dispatch_streaming_exception(self) -> None:
|
||||||
"""Streaming handler that raises returns 500."""
|
"""Streaming handler that raises returns 500."""
|
||||||
@@ -645,7 +647,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = "bad"
|
request.match_info["path"] = "bad"
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 500
|
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
async def test_dispatch_streaming_normal_return(self) -> None:
|
async def test_dispatch_streaming_normal_return(self) -> None:
|
||||||
"""Streaming handler returning dict produces JSON response."""
|
"""Streaming handler returning dict produces JSON response."""
|
||||||
@@ -661,7 +663,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = "fast"
|
request.match_info["path"] = "fast"
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 200
|
assert response.status == HTTPStatus.OK
|
||||||
|
|
||||||
async def test_dispatch_path_params(self) -> None:
|
async def test_dispatch_path_params(self) -> None:
|
||||||
"""Path parameters are populated in RouteContext.match_info."""
|
"""Path parameters are populated in RouteContext.match_info."""
|
||||||
@@ -695,7 +697,7 @@ class TestRouteDispatcherDispatch:
|
|||||||
request.match_info["path"] = ""
|
request.match_info["path"] = ""
|
||||||
|
|
||||||
response = await dispatcher.dispatch(request)
|
response = await dispatcher.dispatch(request)
|
||||||
assert response.status == 200
|
assert response.status == HTTPStatus.OK
|
||||||
|
|
||||||
|
|
||||||
class TestRouteDispatcherDrainHandlers:
|
class TestRouteDispatcherDrainHandlers:
|
||||||
@@ -1012,6 +1014,7 @@ class TestModuleRoutes:
|
|||||||
)
|
)
|
||||||
def test_exists(
|
def test_exists(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
register: bool,
|
register: bool,
|
||||||
check_method: str | None,
|
check_method: str | None,
|
||||||
expected: bool,
|
expected: bool,
|
||||||
@@ -1084,7 +1087,7 @@ class TestRouteContext:
|
|||||||
pytest.param("templates", True, id="templates"),
|
pytest.param("templates", True, id="templates"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_property_proxying(self, prop: str, use_is: bool) -> None:
|
def test_property_proxying(self, prop: str, *, use_is: bool) -> None:
|
||||||
"""RouteContext properties proxy to the underlying ModuleContext."""
|
"""RouteContext properties proxy to the underlying ModuleContext."""
|
||||||
ctx, module_ctx = self._make_route_context()
|
ctx, module_ctx = self._make_route_context()
|
||||||
ctx_val = getattr(ctx, prop)
|
ctx_val = getattr(ctx, prop)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class _StubOwncastClient:
|
|||||||
self.sent: list[str] = []
|
self.sent: list[str] = []
|
||||||
self._fail = fail
|
self._fail = fail
|
||||||
|
|
||||||
async def send_message(self, body: str, *, unsanitized: bool = False) -> str:
|
async def send_message(self, body: str, **_kwargs: bool) -> str:
|
||||||
"""Record *body* or raise if configured to fail."""
|
"""Record *body* or raise if configured to fail."""
|
||||||
if self._fail:
|
if self._fail:
|
||||||
raise RuntimeError("network error")
|
raise RuntimeError("network error")
|
||||||
|
|||||||
Reference in New Issue
Block a user