Added pydocstyle (D) rules to Ruff and fixed all violations.
CI / Formatting (push) Successful in 12s
CI / Linting (push) Successful in 13s
CI / Tests (Python 3.12) (push) Successful in 26s
CI / Tests (Python 3.13) (push) Successful in 25s
CI / Tests (Python 3.14) (push) Successful in 25s
CI / Type Checking (push) Successful in 26s

This commit is contained in:
2026-02-19 11:47:47 -05:00
parent 33dd49e20a
commit ca4adbcebf
30 changed files with 309 additions and 591 deletions
+1 -2
View File
@@ -12,8 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Owlbot - A modular chat bot for Owncast.
"""Owlbot - A modular chat bot for Owncast.
The public API for building modules is in the `owlbot.api` subpackage:
+1 -2
View File
@@ -83,8 +83,7 @@ def on_command(
requires_moderator: bool = False,
cooldown: int | float = 0,
) -> Callable[[CommandHandler], CommandHandler]:
"""
Decorator to register a command handler.
"""Register a command handler.
:param name: Primary command name (case-insensitive).
:param aliases: Optional list of alternative names.
+19 -35
View File
@@ -36,8 +36,7 @@ class Config:
config_path: str | Path = "config.yaml",
overrides: dict[str, Any] | None = None,
):
"""
Initialize the configuration manager.
"""Initialize the configuration manager.
:param config_path: Path to the YAML config file.
:param overrides: CLI overrides (keys match property names).
@@ -91,9 +90,9 @@ class Config:
default: Any = _UNSET,
type_fn: type = str,
) -> Any:
"""
Resolve a setting through the priority chain:
CLI arg > env var > config file > default.
"""Resolve a setting through the priority chain.
Priority order: CLI arg > env var > config file > default.
When *default* is a ``str``, the resolved value is coerced to ``str``
(the implicit *type_fn*). For non-string types, pass both *default*
@@ -123,8 +122,7 @@ class Config:
@property
def webhook_secret(self) -> str:
"""
Secret string for the webhook URL path.
"""Secret string for the webhook URL path.
The webhook endpoint is always /webhook/<secret>. A cryptographically
secure value is generated on first run if not explicitly configured.
@@ -139,8 +137,7 @@ class Config:
@property
def webhook_path(self) -> str:
"""
URL path where Owncast sends webhooks.
"""URL path where Owncast sends webhooks.
Always returns /webhook/<secret>. The secret is auto-generated
on first run if not configured.
@@ -207,8 +204,7 @@ class Config:
@property
def public_base_url(self) -> str:
"""
Public base URL for Owlbot's web server.
"""Public base URL for Owlbot's web server.
Used to construct URLs for module routes and the webhook endpoint.
Falls back to ``owncast.url`` if not explicitly set.
@@ -224,8 +220,7 @@ class Config:
@property
def storage_dir(self) -> Path:
"""
Directory for module database files.
"""Directory for module database files.
Each module gets its own database file named '<module_name>.db'.
Defaults to 'data/' in the working directory.
@@ -242,8 +237,7 @@ class Config:
@property
def modules_dir(self) -> Path:
"""
Directory containing user modules.
"""Directory containing user modules.
Built-in modules are loaded from the package regardless of this setting.
Defaults to 'modules/' in the working directory.
@@ -260,8 +254,7 @@ class Config:
@property
def log_dir(self) -> Path | None:
"""
Directory for the log file.
"""Directory for the log file.
When set, an ``owlbot.log`` file is written to this directory in
addition to stdout. Returns ``None`` when unset (stdout only).
@@ -405,8 +398,7 @@ class Config:
logger.info(f"Configuration saved to: {self.config_path.absolute()}")
def is_module_enabled(self, module_name: str) -> bool:
"""
Check if a module is enabled.
"""Check if a module is enabled.
Modules are enabled by default unless explicitly disabled with
``modules.<name>.enabled: false`` in the config file.
@@ -424,8 +416,7 @@ class Config:
return True
def get_module_config(self, module_name: str) -> dict[str, Any]:
"""
Get the configuration dict for a module.
"""Get the configuration dict for a module.
Returns merged defaults and config file values, with config file
values taking precedence.
@@ -443,8 +434,7 @@ class Config:
return {**defaults, **config}
def set_module_config(self, module_name: str, config: dict[str, Any]) -> None:
"""
Update the configuration for a module at runtime and persist to disk.
"""Update the configuration for a module at runtime and persist to disk.
:param module_name: The module name.
:param config: Dict of configuration values to set.
@@ -459,8 +449,7 @@ class Config:
def register_module_defaults(
self, module_name: str, defaults: dict[str, Any]
) -> None:
"""
Register default configuration values for a module.
"""Register default configuration values for a module.
Called by modules during setup to declare their expected config keys
and default values. Missing keys are backfilled into the config file
@@ -516,8 +505,7 @@ class ModuleConfig:
"""Pre-scoped configuration for a specific module."""
def __init__(self, config: Config, module_name: str):
"""
Initialize a module-scoped configuration.
"""Initialize a module-scoped configuration.
:param config: The parent Config object.
:param module_name: The name of the module this config
@@ -537,8 +525,7 @@ class ModuleConfig:
return self._config.public_base_url
def get(self, key: str, default: Any = None) -> Any:
"""
Get a config value by key.
"""Get a config value by key.
:param key: The configuration key.
:param default: Value to return if key is not found.
@@ -547,16 +534,14 @@ class ModuleConfig:
return self.as_dict().get(key, default)
def as_dict(self) -> dict[str, Any]:
"""
Get the full config dict for this module.
"""Get the full config dict for this module.
:return: Dict of all configuration values.
"""
return self._config.get_module_config(self._module_name)
def set(self, key: str, value: Any) -> None:
"""
Set a config value at runtime and persist to disk.
"""Set a config value at runtime and persist to disk.
:param key: The configuration key.
:param value: The value to set.
@@ -566,8 +551,7 @@ class ModuleConfig:
self._config.set_module_config(self._module_name, current)
def register_defaults(self, defaults: dict[str, Any]) -> None:
"""
Register default values for this module's config.
"""Register default values for this module's config.
Called during setup() to declare expected config keys and their
default values.
+7 -12
View File
@@ -44,8 +44,7 @@ if TYPE_CHECKING:
@dataclass
class ModuleContext:
"""
Shared services available to all module handlers.
"""Shared services available to all module handlers.
Created once per module during loading and reused for all handler
invocations. This bundles the common dependencies that every handler needs.
@@ -83,6 +82,7 @@ class ModuleContext:
logger: logging.Logger = field(init=False)
def __post_init__(self) -> None:
"""Derive the module-scoped logger from the module name."""
self.logger = logging.getLogger(f"owlbot.modules.{self.module_name}")
@@ -96,8 +96,7 @@ class PropagationState:
@dataclass
class EventContext[E]:
"""
Context passed to event handlers.
"""Context passed to event handlers.
Each handler invocation receives its own EventContext instance with the event
data and access to shared services via the module context.
@@ -165,16 +164,14 @@ class EventContext[E]:
@property
def propagation_stopped(self) -> bool:
"""
Check if event propagation has been stopped by a handler.
"""Check if event propagation has been stopped by a handler.
:return: True if stop_propagation() was called by any handler.
"""
return self._propagation.stopped
def stop_propagation(self, reason: str | None = None) -> None:
"""
Stop event from being dispatched to remaining handlers and commands.
"""Stop event from being dispatched to remaining handlers and commands.
Once called, no further handlers will be invoked for this event, and
command dispatch (for CHAT events) will be skipped.
@@ -189,8 +186,7 @@ class EventContext[E]:
@dataclass
class CommandContext:
"""
Context passed to command handlers.
"""Context passed to command handlers.
Provides access to the parsed command data, original chat event context,
and shared services. Like EventContext and RouteContext, all ModuleContext
@@ -289,8 +285,7 @@ class CommandContext:
@dataclass
class RouteContext:
"""
Context passed to HTTP route handlers.
"""Context passed to HTTP route handlers.
Similar to EventContext but includes the aiohttp request object
for accessing HTTP-specific data (body, headers, query params).
+14 -28
View File
@@ -43,8 +43,7 @@ class EventType(StrEnum):
def _parse_timestamp(ts: str | None) -> datetime | None:
"""
Parse an ISO 8601 timestamp string into a datetime.
"""Parse an ISO 8601 timestamp string into a datetime.
Handles timezone suffixes and the Go zero-value timestamp that Owncast
sends for missing/unset timestamps.
@@ -91,8 +90,7 @@ class User:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> User:
"""
Create a User from webhook JSON data.
"""Create a User from webhook JSON data.
:param data: The user data from the webhook payload.
:return: A populated User instance.
@@ -111,8 +109,7 @@ class User:
@property
def is_moderator(self) -> bool:
"""
Check if the user has moderator privileges.
"""Check if the user has moderator privileges.
:return: True if the user has the MODERATOR scope.
"""
@@ -133,8 +130,7 @@ class ChatEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ChatEvent:
"""
Create a ChatEvent from webhook JSON data.
"""Create a ChatEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated ChatEvent instance.
@@ -168,8 +164,7 @@ class UserJoinedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> UserJoinedEvent:
"""
Create a UserJoinedEvent from webhook JSON data.
"""Create a UserJoinedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated UserJoinedEvent instance.
@@ -193,8 +188,7 @@ class UserPartedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> UserPartedEvent:
"""
Create a UserPartedEvent from webhook JSON data.
"""Create a UserPartedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated UserPartedEvent instance.
@@ -219,8 +213,7 @@ class NameChangedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> NameChangedEvent:
"""
Create a NameChangedEvent from webhook JSON data.
"""Create a NameChangedEvent from webhook JSON data.
Note: The user object contains the OLD display name in user.display_name.
@@ -248,8 +241,7 @@ class StreamStartedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStartedEvent:
"""
Create a StreamStartedEvent from webhook JSON data.
"""Create a StreamStartedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated StreamStartedEvent instance.
@@ -275,8 +267,7 @@ class StreamStoppedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStoppedEvent:
"""
Create a StreamStoppedEvent from webhook JSON data.
"""Create a StreamStoppedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated StreamStoppedEvent instance.
@@ -305,8 +296,7 @@ class StreamStatus:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamStatus:
"""
Create a StreamStatus from webhook JSON data.
"""Create a StreamStatus from webhook JSON data.
:param data: The status data from the webhook payload.
:return: A populated StreamStatus instance.
@@ -336,8 +326,7 @@ class StreamTitleUpdatedEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> StreamTitleUpdatedEvent:
"""
Create a StreamTitleUpdatedEvent from webhook JSON data.
"""Create a StreamTitleUpdatedEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated StreamTitleUpdatedEvent instance.
@@ -364,8 +353,7 @@ class VisibilityUpdateEvent:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> VisibilityUpdateEvent:
"""
Create a VisibilityUpdateEvent from webhook JSON data.
"""Create a VisibilityUpdateEvent from webhook JSON data.
:param data: The event data from the webhook payload.
:return: A populated VisibilityUpdateEvent instance.
@@ -407,8 +395,7 @@ _EVENT_TYPE_MAP: dict[EventType, type[Event]] = {
def parse_event(data: dict[str, Any]) -> _ParsedEvent | None:
"""
Parse a raw webhook payload into a typed event.
"""Parse a raw webhook payload into a typed event.
:param data: The JSON payload from Owncast's webhook.
:return: Tuple of (event_type, event_instance), or None if the event type
@@ -431,8 +418,7 @@ def parse_event(data: dict[str, Any]) -> _ParsedEvent | None:
def log_event(event_type: EventType, event: Event) -> None:
"""
Log event details for debugging and monitoring.
"""Log event details for debugging and monitoring.
:param event_type: The type of event being logged.
:param event: The parsed event instance.
+2 -4
View File
@@ -43,8 +43,7 @@ type EventHandler = "Callable[[EventContext[Any]], Awaitable[None]]"
class Priority(IntEnum):
"""
Standard priority levels for event handlers.
"""Standard priority levels for event handlers.
Higher values run first. Handlers at the same priority level run in
registration order. Custom numeric values can be used for fine-grained control.
@@ -61,8 +60,7 @@ def on_event(
*event_types: EventType,
priority: int = Priority.NORMAL,
) -> Callable[[EventHandler], EventHandler]:
"""
Decorator to register a function as a handler for one or more event types.
"""Register a function as a handler for one or more event types.
The decorated function will be called whenever an event of the specified
type(s) is received. Handlers are executed sequentially in priority order
+2 -4
View File
@@ -31,8 +31,7 @@ type LifecycleHandler = "Callable[[ModuleContext], Awaitable[None]]"
def on_setup(func: LifecycleHandler) -> LifecycleHandler:
"""
Decorator to mark a function as a module setup hook.
"""Mark a function as a module setup hook.
The decorated function will be called during module loading with a
``ModuleContext``. Setup hooks run inside a storage transaction that
@@ -52,8 +51,7 @@ def on_setup(func: LifecycleHandler) -> LifecycleHandler:
def on_teardown(func: LifecycleHandler) -> LifecycleHandler:
"""
Decorator to mark a function as a module teardown hook.
"""Mark a function as a module teardown hook.
The decorated function will be called during module unload or bot
shutdown with the same ``ModuleContext`` from setup.
+77 -154
View File
@@ -186,8 +186,7 @@ class OwncastAdminClient(OwncastClient):
def __init__(
self, base_url: str, username: str, password: str, http_client: HttpClient
):
"""
Initialize the admin client.
"""Initialize the admin client.
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
:param username: Admin username.
@@ -203,24 +202,21 @@ class OwncastAdminClient(OwncastClient):
)
async def get_status(self) -> dict[str, Any]:
"""
Get the current server status including stream info and viewer count.
"""Get the current server status including stream info and viewer count.
:return: Server status dict.
"""
return dict(await self._get("/api/admin/status"))
async def get_active_viewers(self) -> list[dict[str, Any]]:
"""
Get a list of currently active viewers.
"""Get a list of currently active viewers.
:return: Viewer list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/viewers"))
async def get_viewers_over_time(self, window_start: int) -> list[dict[str, Any]]:
"""
Get viewer count data over time for charting.
"""Get viewer count data over time for charting.
:param window_start: Unix timestamp (seconds since epoch)
for the start of the window.
@@ -233,24 +229,21 @@ class OwncastAdminClient(OwncastClient):
)
async def get_hardware_stats(self) -> dict[str, Any]:
"""
Get server hardware statistics (CPU, memory, disk).
"""Get server hardware statistics (CPU, memory, disk).
:return: Hardware stats dict.
"""
return dict(await self._get("/api/admin/hardwarestats"))
async def get_server_config(self) -> dict[str, Any]:
"""
Get the full server configuration.
"""Get the full server configuration.
:return: Server configuration dict.
"""
return dict(await self._get("/api/admin/serverconfig"))
async def disconnect_stream(self) -> dict[str, Any]:
"""
Disconnect the current inbound stream.
"""Disconnect the current inbound stream.
:return: API response confirming the disconnect.
"""
@@ -258,16 +251,14 @@ class OwncastAdminClient(OwncastClient):
return dict(await self._get("/api/admin/disconnect"))
async def get_chat_messages(self) -> list[dict[str, Any]]:
"""
Get chat messages from the admin perspective.
"""Get chat messages from the admin perspective.
:return: Chat message list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/chat/messages"))
async def get_connected_chat_clients(self) -> list[dict[str, Any]]:
"""
Get currently connected chat clients.
"""Get currently connected chat clients.
:return: Client list as returned by the Owncast API.
"""
@@ -276,8 +267,7 @@ class OwncastAdminClient(OwncastClient):
async def set_message_visibility(
self, message_ids: list[str], visible: bool
) -> str:
"""
Hide or show chat messages.
"""Hide or show chat messages.
:param message_ids: List of message IDs to modify.
:param visible: True to show messages, False to hide them.
@@ -292,8 +282,7 @@ class OwncastAdminClient(OwncastClient):
)
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 enabled: True to enable, False to disable the user.
@@ -307,16 +296,14 @@ class OwncastAdminClient(OwncastClient):
)
async def get_disabled_users(self) -> list[dict[str, Any]]:
"""
Get a list of disabled chat users.
"""Get a list of disabled chat users.
:return: User list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/chat/users/disabled"))
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 is_mod: True to grant moderator, False to revoke.
@@ -330,16 +317,14 @@ class OwncastAdminClient(OwncastClient):
)
async def get_moderators(self) -> list[dict[str, Any]]:
"""
Get a list of moderator users.
"""Get a list of moderator users.
:return: User list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/chat/users/moderators"))
async def ban_ip_address(self, ip: str) -> str:
"""
Ban an IP address from chat.
"""Ban an IP address from chat.
:param ip: The IP address to ban.
:return: Success message from the server.
@@ -348,8 +333,7 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/chat/users/ipbans/create", {"value": ip})
async def unban_ip_address(self, ip: str) -> str:
"""
Remove an IP address ban.
"""Remove an IP address ban.
:param ip: The IP address to unban.
:return: Success message from the server.
@@ -358,16 +342,14 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/chat/users/ipbans/remove", {"value": ip})
async def get_ip_address_bans(self) -> list[dict[str, Any]]:
"""
Get a list of banned IP addresses.
"""Get a list of banned IP addresses.
:return: IP ban list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/chat/users/ipbans"))
async def set_stream_title(self, title: str) -> str:
"""
Set the stream title.
"""Set the stream title.
:param title: The new stream title.
:return: Success message from the server.
@@ -377,8 +359,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_server_name(self, name: str) -> str:
"""
Set the server name.
"""Set the server name.
:param name: The new server name.
:return: Success message from the server.
@@ -388,8 +369,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_server_summary(self, summary: str) -> str:
"""
Set the server summary.
"""Set the server summary.
:param summary: The new server summary.
:return: Success message from the server.
@@ -399,8 +379,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_welcome_message(self, message: str) -> str:
"""
Set the welcome message shown to new viewers.
"""Set the welcome message shown to new viewers.
:param message: The new welcome message.
:return: Success message from the server.
@@ -410,8 +389,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_offline_message(self, message: str) -> str:
"""
Set the message shown when the stream is offline.
"""Set the message shown when the stream is offline.
:param message: The new offline message.
:return: Success message from the server.
@@ -421,8 +399,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_page_content(self, content: str) -> str:
"""
Set the custom page content (HTML/markdown below the player).
"""Set the custom page content (HTML/markdown below the player).
:param content: The page content.
:return: Success message from the server.
@@ -432,8 +409,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_admin_password(self, password: str) -> str:
"""
Change the admin password.
"""Change the admin password.
:param password: The new admin password.
:return: Success message from the server.
@@ -443,8 +419,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_server_url(self, url: str) -> str:
"""
Set the public server URL.
"""Set the public server URL.
:param url: The new server URL.
:return: Success message from the server.
@@ -454,8 +429,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_custom_styles(self, css: str) -> str:
"""
Set custom CSS styles for the web interface.
"""Set custom CSS styles for the web interface.
:param css: The CSS string.
:return: Success message from the server.
@@ -465,8 +439,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_custom_javascript(self, js: str) -> str:
"""
Set custom JavaScript for the web interface.
"""Set custom JavaScript for the web interface.
:param js: The JavaScript string.
:return: Success message from the server.
@@ -476,8 +449,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_socket_host_override(self, host: str) -> str:
"""
Set the WebSocket host override.
"""Set the WebSocket host override.
:param host: The WebSocket host override value.
:return: Success message from the server.
@@ -487,8 +459,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_ffmpeg_path(self, path: str) -> str:
"""
Set the path to the ffmpeg binary.
"""Set the path to the ffmpeg binary.
:param path: The ffmpeg binary path.
:return: Success message from the server.
@@ -498,8 +469,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_video_serving_endpoint(self, endpoint: str) -> str:
"""
Set the video serving endpoint (e.g., for CDN).
"""Set the video serving endpoint (e.g., for CDN).
:param endpoint: The video serving endpoint URL.
:return: Success message from the server.
@@ -509,8 +479,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_video_codec(self, codec: str) -> str:
"""
Set the video codec.
"""Set the video codec.
:param codec: The video codec name.
:return: Success message from the server.
@@ -520,8 +489,7 @@ class OwncastAdminClient(OwncastClient):
)
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.
:return: Success message from the server.
@@ -531,8 +499,7 @@ class OwncastAdminClient(OwncastClient):
)
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.
:return: Success message from the server.
@@ -542,8 +509,7 @@ class OwncastAdminClient(OwncastClient):
)
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.
:return: Success message from the server.
@@ -555,8 +521,7 @@ class OwncastAdminClient(OwncastClient):
)
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.
:return: Success message from the server.
@@ -568,8 +533,7 @@ class OwncastAdminClient(OwncastClient):
)
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.
:return: Success message from the server.
@@ -579,8 +543,7 @@ class OwncastAdminClient(OwncastClient):
)
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.
:return: Success message from the server.
@@ -588,8 +551,7 @@ class OwncastAdminClient(OwncastClient):
return await self._set_config_value("/api/admin/config/nsfw", nsfw, "NSFW flag")
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.
:return: Success message from the server.
@@ -599,8 +561,7 @@ class OwncastAdminClient(OwncastClient):
)
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.
:return: Success message from the server.
@@ -610,8 +571,7 @@ class OwncastAdminClient(OwncastClient):
)
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.
:return: Success message from the server.
@@ -623,8 +583,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_forbidden_usernames(self, names: list[str]) -> str:
"""
Set the list of forbidden usernames.
"""Set the list of forbidden usernames.
:param names: List of forbidden username strings.
:return: Success message from the server.
@@ -634,8 +593,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_suggested_usernames(self, names: list[str]) -> str:
"""
Set the list of suggested usernames for new viewers.
"""Set the list of suggested usernames for new viewers.
:param names: List of suggested username strings.
:return: Success message from the server.
@@ -645,8 +603,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_tags(self, tags: list[str]) -> str:
"""
Set the server tags.
"""Set the server tags.
:param tags: List of tag strings.
:return: Success message from the server.
@@ -654,8 +611,7 @@ class OwncastAdminClient(OwncastClient):
return await self._set_config_value("/api/admin/config/tags", tags, "tags")
async def set_federation_blocked_domains(self, domains: list[str]) -> str:
"""
Set the list of blocked federation domains.
"""Set the list of blocked federation domains.
:param domains: List of domain strings to block.
:return: Success message from the server.
@@ -667,8 +623,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_stream_keys(self, keys: list[StreamKey]) -> str:
"""
Set the stream keys.
"""Set the stream keys.
:param keys: List of :class:`StreamKey` objects.
:return: Success message from the server.
@@ -680,8 +635,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_video_variants(self, variants: list[VideoVariant]) -> str:
"""
Set the video output variants (quality levels).
"""Set the video output variants (quality levels).
:param variants: List of :class:`VideoVariant` objects.
:return: Success message from the server.
@@ -693,8 +647,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_social_handles(self, handles: list[SocialHandle]) -> str:
"""
Set the social media handles displayed on the page.
"""Set the social media handles displayed on the page.
:param handles: List of :class:`SocialHandle` objects.
:return: Success message from the server.
@@ -706,8 +659,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_external_actions(self, actions: list[ExternalAction]) -> str:
"""
Set the external actions (buttons/links in the player).
"""Set the external actions (buttons/links in the player).
:param actions: List of :class:`ExternalAction` objects.
:return: Success message from the server.
@@ -727,8 +679,7 @@ class OwncastAdminClient(OwncastClient):
bucket: str,
region: str,
) -> str:
"""
Set the S3 storage configuration.
"""Set the S3 storage configuration.
:param enabled: Whether S3 storage is enabled.
:param endpoint: The S3 endpoint URL.
@@ -757,8 +708,7 @@ class OwncastAdminClient(OwncastClient):
webhook: str,
go_live_message: str,
) -> str:
"""
Set the Discord notification configuration.
"""Set the Discord notification configuration.
:param enabled: Whether Discord notifications are enabled.
:param webhook: The Discord webhook URL.
@@ -780,8 +730,7 @@ class OwncastAdminClient(OwncastClient):
enabled: bool,
go_live_message: str,
) -> str:
"""
Set the browser notification configuration.
"""Set the browser notification configuration.
:param enabled: Whether browser notifications are enabled.
:param go_live_message: The message shown when going live.
@@ -797,8 +746,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_color_variables(self, variables: dict[str, Any]) -> str:
"""
Set the custom color variables for the web interface.
"""Set the custom color variables for the web interface.
:param variables: Color variables dict.
:return: Success message from the server.
@@ -808,8 +756,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_stream_latency(self, level: int) -> str:
"""
Set the stream latency level.
"""Set the stream latency level.
:param level: Latency level value.
:return: Success message from the server.
@@ -819,8 +766,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_rtmp_port(self, port: int) -> str:
"""
Set the RTMP server port.
"""Set the RTMP server port.
:param port: The RTMP port number.
:return: Success message from the server.
@@ -830,8 +776,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_web_server_port(self, port: int) -> str:
"""
Set the web server port.
"""Set the web server port.
:param port: The web server port number.
:return: Success message from the server.
@@ -841,8 +786,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_web_server_ip(self, ip: str) -> str:
"""
Set the web server bind IP address.
"""Set the web server bind IP address.
:param ip: The IP address to bind to.
:return: Success message from the server.
@@ -852,8 +796,7 @@ class OwncastAdminClient(OwncastClient):
)
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.
:return: Success message from the server.
@@ -863,8 +806,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_federation_username(self, name: str) -> str:
"""
Set the federation (ActivityPub) username.
"""Set the federation (ActivityPub) username.
:param name: The federation username.
:return: Success message from the server.
@@ -874,8 +816,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_federation_go_live_message(self, message: str) -> str:
"""
Set the message sent to followers when going live.
"""Set the message sent to followers when going live.
:param message: The go-live notification message.
:return: Success message from the server.
@@ -887,8 +828,7 @@ class OwncastAdminClient(OwncastClient):
)
async def set_logo(self, base64_data_url: str) -> str:
"""
Set the server logo from a base64 data URL.
"""Set the server logo from a base64 data URL.
:param base64_data_url: Data URL string
(e.g., ``data:image/png;base64,iVBOR...``).
@@ -899,8 +839,7 @@ class OwncastAdminClient(OwncastClient):
)
async def upload_emoji(self, name: str, data_base64: str) -> str:
"""
Upload a custom emoji.
"""Upload a custom emoji.
:param name: The emoji name.
:param data_base64: Base64-encoded image data for the emoji.
@@ -912,8 +851,7 @@ class OwncastAdminClient(OwncastClient):
)
async def delete_emoji(self, name: str) -> str:
"""
Delete a custom emoji.
"""Delete a custom emoji.
:param name: The emoji name to delete.
:return: Success message from the server.
@@ -922,16 +860,14 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/emoji/delete", {"name": name})
async def get_webhooks(self) -> list[dict[str, Any]]:
"""
Get all registered webhooks.
"""Get all registered webhooks.
:return: Webhook list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/webhooks"))
async def create_webhook(self, url: str, events: list[str]) -> str:
"""
Create a new webhook.
"""Create a new webhook.
:param url: The URL to send webhook events to.
:param events: List of event type strings to subscribe to.
@@ -943,8 +879,7 @@ class OwncastAdminClient(OwncastClient):
)
async def delete_webhook(self, webhook_id: int) -> str:
"""
Delete a webhook.
"""Delete a webhook.
:param webhook_id: The ID of the webhook to delete.
:return: Success message from the server.
@@ -953,16 +888,14 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/webhooks/delete", {"id": webhook_id})
async def get_access_tokens(self) -> list[dict[str, Any]]:
"""
Get all access tokens.
"""Get all access tokens.
:return: Token list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/accesstokens"))
async def create_access_token(self, name: str, scopes: list[str]) -> str:
"""
Create a new access token.
"""Create a new access token.
:param name: Display name for the token.
:param scopes: List of permission scope strings.
@@ -974,8 +907,7 @@ class OwncastAdminClient(OwncastClient):
)
async def delete_access_token(self, token: str) -> str:
"""
Delete an access token.
"""Delete an access token.
:param token: The token string to delete.
:return: Success message from the server.
@@ -985,8 +917,7 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/accesstokens/delete", {"token": token})
async def get_followers(self, offset: int = 0, limit: int = 25) -> dict[str, Any]:
"""
Get a paginated list of followers.
"""Get a paginated list of followers.
:param offset: Number of followers to skip.
:param limit: Maximum number of followers to return.
@@ -999,24 +930,21 @@ class OwncastAdminClient(OwncastClient):
)
async def get_pending_follow_requests(self) -> list[dict[str, Any]]:
"""
Get pending follow requests.
"""Get pending follow requests.
:return: Follower request list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/followers/pending"))
async def get_blocked_followers(self) -> list[dict[str, Any]]:
"""
Get blocked and rejected followers.
"""Get blocked and rejected followers.
:return: Follower list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/followers/blocked"))
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 approved: True to approve, False to reject.
@@ -1030,8 +958,7 @@ class OwncastAdminClient(OwncastClient):
)
async def send_federated_message(self, message: str) -> str:
"""
Send a message to all followers via federation.
"""Send a message to all followers via federation.
:param message: The message text to send.
:return: Success message from the server.
@@ -1040,24 +967,21 @@ class OwncastAdminClient(OwncastClient):
return await self._post("/api/admin/federation/send", {"value": message})
async def get_logs(self) -> list[dict[str, Any]]:
"""
Get server logs.
"""Get server logs.
:return: Log entry list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/logs"))
async def get_warnings(self) -> list[dict[str, Any]]:
"""
Get server warning and error logs.
"""Get server warning and error logs.
:return: Log entry list as returned by the Owncast API.
"""
return list(await self._get("/api/admin/logs/warnings"))
async def get_playback_metrics(self) -> dict[str, Any]:
"""
Get playback quality metrics.
"""Get playback quality metrics.
:return: Playback metrics as returned by the Owncast API.
"""
@@ -1066,8 +990,7 @@ class OwncastAdminClient(OwncastClient):
async def _set_config_value(
self, endpoint: str, value: Any, description: str
) -> str:
"""
Set a configuration value via POST with a ``{"value": ...}`` body.
"""Set a configuration value via POST with a ``{"value": ...}`` body.
:param endpoint: The config API endpoint path.
:param value: The value to set.
+13 -26
View File
@@ -52,8 +52,7 @@ class OwncastError(Exception):
"""Raised when an Owncast API request fails."""
def __init__(self, status: int, message: str):
"""
Initialize the error.
"""Initialize the error.
:param status: HTTP status code from the failed request, or 0 if the
request failed due to a connection error before receiving a response.
@@ -72,8 +71,7 @@ class OwncastClient:
"""
def __init__(self, base_url: str, access_token: str, http_client: HttpClient):
"""
Initialize the Owncast client.
"""Initialize the Owncast client.
:param base_url: The Owncast server URL (e.g., "https://stream.logal.dev").
:param access_token: API access token from Owncast admin settings.
@@ -94,8 +92,7 @@ class OwncastClient:
return self._base_url
async def get_status(self) -> dict[str, Any]:
"""
Get the public server status.
"""Get the public server status.
This is a public endpoint that does not require authentication.
Returns server info including version, online status, and viewer count.
@@ -106,8 +103,7 @@ class OwncastClient:
return dict(await self._get("/api/status"))
async def send_message(self, body: str) -> str:
"""
Send a chat message visible to all viewers.
"""Send a chat message visible to all viewers.
:param body: The message text (supports markdown).
:return: Success message from the server.
@@ -116,8 +112,7 @@ class OwncastClient:
return await self._post("/api/integrations/chat/send", {"body": body})
async def send_system_message(self, body: str) -> str:
"""
Send a system message visible to all viewers.
"""Send a system message visible to all viewers.
System messages are styled differently from regular chat (typically
italicized or dimmed) and are used for announcements or notifications.
@@ -129,8 +124,7 @@ class OwncastClient:
return await self._post("/api/integrations/chat/system", {"body": body})
async def send_action(self, body: str) -> str:
"""
Send an action message (like IRC /me).
"""Send an action message (like IRC /me).
Action messages display as "*BotName does something*" and are used
for describing actions rather than speech.
@@ -142,8 +136,7 @@ class OwncastClient:
return await self._post("/api/integrations/chat/action", {"body": body})
async def send_system_message_to_client(self, client_id: int, body: str) -> str:
"""
Send a private system message to a specific viewer.
"""Send a private system message to a specific viewer.
The message is only visible to the targeted client, useful for
welcome messages or private notifications.
@@ -160,8 +153,7 @@ class OwncastClient:
async def set_message_visibility(
self, message_ids: list[str], visible: bool
) -> str:
"""
Hide or show chat messages (moderation).
"""Hide or show chat messages (moderation).
Hidden messages are removed from the chat display for all viewers.
This is typically used for moderation purposes.
@@ -179,8 +171,7 @@ class OwncastClient:
)
async def get_chat_history(self) -> list[dict[str, Any]]:
"""
Fetch recent chat messages.
"""Fetch recent chat messages.
:return: List of recent chat message objects with user info and content.
"""
@@ -188,8 +179,7 @@ class OwncastClient:
return list(await self._get("/api/integrations/chat"))
async def get_connected_clients(self) -> list[dict[str, Any]]:
"""
Get list of currently connected viewers.
"""Get list of currently connected viewers.
:return: List of connected client objects with user info and connection details.
"""
@@ -197,8 +187,7 @@ class OwncastClient:
return list(await self._get("/api/integrations/clients"))
async def set_stream_title(self, title: str) -> str:
"""
Update the stream title.
"""Update the stream title.
:param title: The new stream title.
:return: Success message from the server.
@@ -207,8 +196,7 @@ class OwncastClient:
return await self._post("/api/integrations/streamtitle", {"value": title})
async def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> str:
"""
Send a POST request to the Owncast API.
"""Send a POST request to the Owncast API.
Owncast POST endpoints return ``{"success": true, "message": "..."}``.
This method validates the response and returns just the message string.
@@ -270,8 +258,7 @@ class OwncastClient:
raise OwncastError(0, str(e)) from e
async def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
"""
Send a GET request to the Owncast API.
"""Send a GET request to the Owncast API.
:param endpoint: The API endpoint path.
:param params: Optional query parameters.
+1 -2
View File
@@ -62,8 +62,7 @@ def on_route(
*,
methods: list[str] | None = None,
) -> Callable[[RouteHandler], RouteHandler]:
"""
Decorator to register an HTTP route handler.
"""Register an HTTP route handler.
Routes are namespaced under /owlbot/<module_name>/<path>.
+13 -24
View File
@@ -34,8 +34,7 @@ class StorageError(Exception):
class ModuleStorage:
"""
Module-scoped async SQLite storage backed by a lazy connection pool.
"""Module-scoped async SQLite storage backed by a lazy connection pool.
Each module gets its own isolated database file. Connections are created
lazily and pooled up to ``pool_size``. WAL mode is enabled so that
@@ -54,8 +53,7 @@ class ModuleStorage:
"""
def __init__(self, storage_dir: Path, module_name: str, pool_size: int = 4):
"""
Initialize the storage API.
"""Initialize the storage API.
:param storage_dir: Directory where module databases are stored.
:param module_name: Name of the module this storage belongs to.
@@ -92,8 +90,7 @@ class ModuleStorage:
@asynccontextmanager
async def transaction(self) -> AsyncIterator[ModuleStorage]:
"""
Context manager for explicit transaction control within a handler.
"""Context manager for explicit transaction control within a handler.
Use this when you need multiple operations to succeed or fail together
within a single handler. Commits on success, rolls back on exception.
@@ -105,6 +102,7 @@ class ModuleStorage:
# Both committed together, or both rolled back on error
:return: This ModuleStorage instance.
"""
self._logger.debug("Explicit transaction started.")
try:
@@ -119,8 +117,7 @@ class ModuleStorage:
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> aiosqlite.Cursor:
"""
Execute a SQL statement.
"""Execute a SQL statement.
:param sql: SQL statement (use ? or :name for parameters).
:param parameters: Query parameters (tuple for ?, dict for :name).
@@ -140,8 +137,7 @@ class ModuleStorage:
sql: str,
parameters: list[tuple[Any, ...]] | list[dict[str, Any]],
) -> aiosqlite.Cursor:
"""
Execute a SQL statement with multiple parameter sets.
"""Execute a SQL statement with multiple parameter sets.
Useful for batch inserts/updates.
@@ -166,8 +162,7 @@ class ModuleStorage:
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> aiosqlite.Row | None:
"""
Execute a query and fetch one row.
"""Execute a query and fetch one row.
:param sql: SELECT statement.
:param parameters: Query parameters.
@@ -189,8 +184,7 @@ class ModuleStorage:
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> list[aiosqlite.Row]:
"""
Execute a query and fetch all rows.
"""Execute a query and fetch all rows.
:param sql: SELECT statement.
:param parameters: Query parameters.
@@ -212,8 +206,7 @@ class ModuleStorage:
sql: str,
parameters: tuple[Any, ...] | dict[str, Any] = (),
) -> Any | None:
"""
Execute a query and fetch a single value.
"""Execute a query and fetch a single value.
:param sql: SELECT statement returning one column.
:param parameters: Query parameters.
@@ -260,8 +253,7 @@ class ModuleStorage:
@asynccontextmanager
async def _connection(self) -> AsyncIterator[aiosqlite.Connection]:
"""
Async context manager that provides a connection.
"""Async context manager that provides a connection.
If already inside a ``_checkout``, yields the checked-out connection
without releasing it. Otherwise acquires a standalone connection from
@@ -285,8 +277,7 @@ class ModuleStorage:
@asynccontextmanager
async def _checkout(self) -> AsyncIterator[None]:
"""
Check out a connection from the pool for the duration of a handler.
"""Check out a connection from the pool for the duration of a handler.
Sets a ContextVar so that all storage operations within the handler
reuse the same connection.
@@ -300,8 +291,7 @@ class ModuleStorage:
self._release(conn)
async def _commit(self) -> None:
"""
Commit the current transaction (internal use by bot).
"""Commit the current transaction (internal use by bot).
Called automatically after each handler completes successfully.
"""
@@ -311,8 +301,7 @@ class ModuleStorage:
self._logger.debug("Transaction committed.")
async def _rollback(self) -> None:
"""
Rollback the current transaction (internal use by bot).
"""Rollback the current transaction (internal use by bot).
Called automatically if a handler throws an exception.
"""
+2 -4
View File
@@ -47,8 +47,7 @@ class Owlbot:
overrides: dict[str, Any] | None = None,
skip_api_check: bool = False,
):
"""
Initialize Owlbot.
"""Initialize Owlbot.
:param config_path: Path to the YAML config file.
:param overrides: CLI overrides passed to the config manager.
@@ -161,8 +160,7 @@ class Owlbot:
logger.info("Owlbot shutdown complete.")
async def _check_api_accessibility(self) -> None:
"""
Verify that the Owncast APIs are reachable before proceeding with startup.
"""Verify that the Owncast APIs are reachable before proceeding with startup.
Calls a non-transformative endpoint on each configured client to confirm
the server is accessible and credentials are valid. Raises
@@ -56,8 +56,7 @@ __all__ = [
@on_setup
async def setup(ctx: ModuleContext) -> None:
"""
Initialize the custom_commands module.
"""Initialize the custom_commands module.
Creates the database schema and loads existing commands from the database.
@@ -30,8 +30,7 @@ from .placeholders import DEFAULT_MAX_DEPTH, process_placeholders
async def get_aliases_for_command(
storage: ModuleStorage, command_name: str
) -> list[str]:
"""
Fetch all aliases for a command from the database.
"""Fetch all aliases for a command from the database.
:param storage: The module storage instance.
:param command_name: The canonical command name.
@@ -47,8 +46,7 @@ async def get_aliases_for_command(
async def resolve_command_name(
storage: ModuleStorage, name: str
) -> aiosqlite.Row | None:
"""
Resolve a command name or alias to the full command row.
"""Resolve a command name or alias to the full command row.
Checks the commands table first, then falls back to the aliases table.
Returns the command's name, requires_moderator, and cooldown columns.
@@ -70,8 +68,7 @@ async def resolve_command_name(
async def custom_command_handler(ctx: CommandContext) -> None:
"""
Shared handler for all custom commands.
"""Shared handler for all custom commands.
Looks up the command in the database, increments use count,
processes placeholders, and sends the response.
@@ -112,8 +109,7 @@ def reregister_command(
requires_moderator: bool,
cooldown: int,
) -> None:
"""
Unregister and re-register a custom command with updated settings.
"""Unregister and re-register a custom command with updated settings.
Both registry operations are synchronous, so no other coroutine can observe
the intermediate unregistered state.
@@ -67,8 +67,7 @@ async def _resolve_or_error(ctx: CommandContext, raw_name: str) -> aiosqlite.Row
@on_command("addcommand", aliases=["addcmd"], requires_moderator=True)
async def addcommand(ctx: CommandContext) -> None:
"""
Create a new custom command.
"""Create a new custom command.
Usage: !addcommand !name response text
@@ -134,8 +133,7 @@ async def addcommand(ctx: CommandContext) -> None:
@on_command("editcommand", aliases=["editcmd"], requires_moderator=True)
async def editcommand(ctx: CommandContext) -> None:
"""
Edit an existing custom command's response.
"""Edit an existing custom command's response.
Usage: !editcommand !name new response
@@ -175,8 +173,7 @@ async def editcommand(ctx: CommandContext) -> None:
@on_command("deletecommand", aliases=["delcmd"], requires_moderator=True)
async def deletecommand(ctx: CommandContext) -> None:
"""
Delete a custom command.
"""Delete a custom command.
Usage: !deletecommand !name
@@ -215,8 +212,7 @@ async def deletecommand(ctx: CommandContext) -> None:
@on_command("commandmodonly", aliases=["cmdmodonly"], requires_moderator=True)
async def commandmodonly(ctx: CommandContext) -> None:
"""
Toggle moderator-only access for a custom command.
"""Toggle moderator-only access for a custom command.
Usage: !commandmodonly !name <on|off>
@@ -255,8 +251,7 @@ async def commandmodonly(ctx: CommandContext) -> None:
@on_command("resetcommand", aliases=["resetcmd"], requires_moderator=True)
async def resetcommand(ctx: CommandContext) -> None:
"""
Reset a custom command's use counter to 0.
"""Reset a custom command's use counter to 0.
Usage: !resetcommand !name
@@ -288,8 +283,7 @@ async def resetcommand(ctx: CommandContext) -> None:
@on_command("editcounter", aliases=["editcount"], requires_moderator=True)
async def editcounter(ctx: CommandContext) -> None:
"""
Set, increment, or decrement a named counter.
"""Set, increment, or decrement a named counter.
Usage: !editcounter <name> <value>
@@ -351,8 +345,7 @@ async def editcounter(ctx: CommandContext) -> None:
@on_command("commandcooldown", aliases=["cmdcooldown"], requires_moderator=True)
async def commandcooldown(ctx: CommandContext) -> None:
"""
Set or disable a custom command's cooldown.
"""Set or disable a custom command's cooldown.
Usage: !commandcooldown !name <seconds>
@@ -404,8 +397,7 @@ async def commandcooldown(ctx: CommandContext) -> None:
@on_command("addalias", requires_moderator=True)
async def addalias(ctx: CommandContext) -> None:
"""
Add an alias to an existing custom command.
"""Add an alias to an existing custom command.
Usage: !addalias !command !alias
@@ -485,8 +477,7 @@ async def addalias(ctx: CommandContext) -> None:
@on_command("removealias", requires_moderator=True)
async def removealias(ctx: CommandContext) -> None:
"""
Remove an alias from a custom command.
"""Remove an alias from a custom command.
Usage: !removealias !alias
@@ -535,8 +526,7 @@ async def removealias(ctx: CommandContext) -> None:
@on_command("listcommands", aliases=["listcmds"], cooldown=15)
async def listcommands(ctx: CommandContext) -> None:
"""
List all custom commands.
"""List all custom commands.
Sends a URL to the command list web page.
Has a 15-second cooldown to prevent spam.
@@ -105,10 +105,10 @@ class PlaceholderContext:
def _find_matching_close(template: str, pos: int) -> int:
"""Find the position of the ``)`` that closes an escaped ``\\$(...)`` group.
r"""Find the position of the ``)`` that closes an escaped ``\$(...)`` group.
Tracks nested ``$(`` / ``)`` pairs so that escaped groups containing inner
placeholders (e.g. ``\\$(rand $(1) $(2))``) are consumed in their entirety.
placeholders (e.g. ``\$(rand $(1) $(2))``) are consumed in their entirety.
:param template: The full template string.
:param pos: The position immediately after the opening ``$(`` of the
@@ -30,8 +30,7 @@ _jinja_env = jinja2.Environment(
@on_route("/list", methods=["GET"])
async def command_list_page(ctx: RouteContext) -> web.Response:
"""
Serve an HTML page listing all custom commands in a table.
"""Serve an HTML page listing all custom commands in a table.
Columns: Command, Aliases, Response, Cooldown, Permissions.
Accessible at /owlbot/custom_commands/list.
+6 -12
View File
@@ -41,8 +41,7 @@ _jinja_env = jinja2.Environment(
@on_setup
async def setup(ctx: ModuleContext) -> None:
"""
Initialize the quotes module.
"""Initialize the quotes module.
Creates the database schema.
@@ -63,8 +62,7 @@ async def setup(ctx: ModuleContext) -> None:
@on_route("/list", methods=["GET"])
async def quotes_list_page(ctx: RouteContext) -> web.Response:
"""
Serve an HTML page listing all quotes in a table.
"""Serve an HTML page listing all quotes in a table.
Columns: #, Quote, Added By, Date Added.
Accessible at /owlbot/quotes/list.
@@ -94,8 +92,7 @@ async def quotes_list_page(ctx: RouteContext) -> web.Response:
@on_command("quote", aliases=["q"])
async def quote_command(ctx: CommandContext) -> None:
"""
Display a quote. Random if no argument, specific if an ID is given.
"""Display a quote. Random if no argument, specific if an ID is given.
:param ctx: The command context.
"""
@@ -137,8 +134,7 @@ async def quote_command(ctx: CommandContext) -> None:
@on_command("addquote", requires_moderator=True)
async def addquote_command(ctx: CommandContext) -> None:
"""
Add a new quote to the database. Moderator only.
"""Add a new quote to the database. Moderator only.
:param ctx: The command context.
"""
@@ -159,8 +155,7 @@ async def addquote_command(ctx: CommandContext) -> None:
@on_command("deletequote", aliases=["delquote"], requires_moderator=True)
async def deletequote_command(ctx: CommandContext) -> None:
"""
Delete a quote by ID. Moderator only.
"""Delete a quote by ID. Moderator only.
:param ctx: The command context.
"""
@@ -190,8 +185,7 @@ async def deletequote_command(ctx: CommandContext) -> None:
@on_command("listquotes", cooldown=15)
async def listquotes_command(ctx: CommandContext) -> None:
"""
Send the URL to the quotes list web page.
"""Send the URL to the quotes list web page.
:param ctx: The command context.
"""
+2 -4
View File
@@ -56,8 +56,7 @@ __all__ = [
@on_setup
async def setup(ctx: ModuleContext) -> None:
"""
Initialize the timers module.
"""Initialize the timers module.
Creates the database schema, initializes chat counters for enabled timers,
and starts the background scheduler.
@@ -97,8 +96,7 @@ async def setup(ctx: ModuleContext) -> None:
@on_teardown
async def teardown(ctx: ModuleContext) -> None:
"""
Clean up the timers module.
"""Clean up the timers module.
Stops the background scheduler task.
@@ -32,8 +32,7 @@ from .scheduler import get_scheduler
@on_event(EventType.CHAT, priority=Priority.LOWEST)
async def count_chat_message(ctx: EventContext[ChatEvent]) -> None:
"""
Count a chat message for all tracked timers.
"""Count a chat message for all tracked timers.
Runs at lowest priority so all other CHAT handlers (moderation, etc.)
execute first. Skips bot messages and hidden messages.
@@ -60,8 +59,7 @@ async def count_chat_message(ctx: EventContext[ChatEvent]) -> None:
@on_event(EventType.VISIBILITY_UPDATE, priority=Priority.LOWEST)
async def handle_visibility_update(ctx: EventContext[VisibilityUpdateEvent]) -> None:
"""
Remove hidden messages from chat counts.
"""Remove hidden messages from chat counts.
Only handles the hide case. Un-hiding does not re-add messages because
we cannot distinguish previously counted user messages from bot messages
+10 -20
View File
@@ -33,8 +33,7 @@ _NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,31}$")
async def _resolve_timer(ctx: CommandContext, identifier: str) -> aiosqlite.Row | None:
"""
Resolve a timer by numeric ID or name.
"""Resolve a timer by numeric ID or name.
Tries parsing as an integer first, then falls back to a name lookup.
@@ -66,8 +65,7 @@ async def _resolve_timer(ctx: CommandContext, identifier: str) -> aiosqlite.Row
def _timer_display(row: aiosqlite.Row) -> str:
"""
Format a timer's display identifier.
"""Format a timer's display identifier.
:param row: Timer database row.
:return: Display string like "timer_name (#3)" or "timer #3".
@@ -79,8 +77,7 @@ def _timer_display(row: aiosqlite.Row) -> str:
@on_command("addtimer", requires_moderator=True)
async def addtimer(ctx: CommandContext) -> None:
"""
Create a new empty timer with an optional name.
"""Create a new empty timer with an optional name.
Usage: !addtimer [name]
@@ -123,8 +120,7 @@ async def addtimer(ctx: CommandContext) -> None:
@on_command("settimermessage", requires_moderator=True)
async def settimermessage(ctx: CommandContext) -> None:
"""
Set the message text for a timer.
"""Set the message text for a timer.
Usage: !settimermessage <id|name> <message>
@@ -164,8 +160,7 @@ async def settimermessage(ctx: CommandContext) -> None:
@on_command("settimerinterval", requires_moderator=True)
async def settimerinterval(ctx: CommandContext) -> None:
"""
Set the interval for a timer.
"""Set the interval for a timer.
Accepts simple durations (15m, 1h30m) or cron expressions (*/15 * * * *).
@@ -212,8 +207,7 @@ async def settimerinterval(ctx: CommandContext) -> None:
@on_command("settimerlines", requires_moderator=True)
async def settimerlines(ctx: CommandContext) -> None:
"""
Set the minimum chat lines between timer firings.
"""Set the minimum chat lines between timer firings.
Usage: !settimerlines <id|name> <count>
@@ -260,8 +254,7 @@ async def settimerlines(ctx: CommandContext) -> None:
@on_command("enabletimer", requires_moderator=True)
async def enabletimer(ctx: CommandContext) -> None:
"""
Enable a timer.
"""Enable a timer.
Won't enable a timer that has no message set.
@@ -310,8 +303,7 @@ async def enabletimer(ctx: CommandContext) -> None:
@on_command("disabletimer", requires_moderator=True)
async def disabletimer(ctx: CommandContext) -> None:
"""
Disable a timer.
"""Disable a timer.
Usage: !disabletimer <id|name>
@@ -351,8 +343,7 @@ async def disabletimer(ctx: CommandContext) -> None:
@on_command("deletetimer", requires_moderator=True)
async def deletetimer(ctx: CommandContext) -> None:
"""
Permanently delete a timer.
"""Permanently delete a timer.
Usage: !deletetimer <id|name>
@@ -382,8 +373,7 @@ async def deletetimer(ctx: CommandContext) -> None:
@on_command("listtimers", requires_moderator=True, cooldown=15)
async def listtimers(ctx: CommandContext) -> None:
"""
Send the URL to the timer list web page.
"""Send the URL to the timer list web page.
Usage: !listtimers
+1 -2
View File
@@ -31,8 +31,7 @@ _jinja_env = jinja2.Environment(
@on_route("/list", methods=["GET"])
async def timer_list_page(ctx: RouteContext) -> web.Response:
"""
Serve an HTML page listing all timers in a table.
"""Serve an HTML page listing all timers in a table.
Columns: #, Name, Message, Interval, Min Lines, Status, Last Fired.
Accessible at /owlbot/timers/list.
+16 -30
View File
@@ -51,8 +51,7 @@ _MIN_CRON_MINUTES = 1
def parse_interval(value: str) -> tuple[IntervalType, str]:
"""
Parse and validate an interval string.
"""Parse and validate an interval string.
Simple durations (no spaces) are parsed with a regex. Cron expressions
(contain spaces) are validated with cronsim. Returns the detected type
@@ -72,8 +71,7 @@ def parse_interval(value: str) -> tuple[IntervalType, str]:
def _parse_simple(value: str) -> tuple[IntervalType, str]:
"""
Parse and validate a simple duration string.
"""Parse and validate a simple duration string.
:param value: Duration string like "30s", "5m", "1h30m".
:return: Tuple of (IntervalType.SIMPLE, value).
@@ -99,8 +97,7 @@ def _parse_simple(value: str) -> tuple[IntervalType, str]:
def _parse_cron(value: str) -> tuple[IntervalType, str]:
"""
Validate a cron expression.
"""Validate a cron expression.
:param value: Cron expression string (5 fields).
:return: Tuple of (IntervalType.CRON, value).
@@ -125,8 +122,7 @@ def _parse_cron(value: str) -> tuple[IntervalType, str]:
def _duration_to_seconds(value: str) -> int:
"""
Convert a validated simple duration string to total seconds.
"""Convert a validated simple duration string to total seconds.
:param value: A previously validated duration string.
:return: Total seconds.
@@ -141,8 +137,7 @@ def _duration_to_seconds(value: str) -> int:
def _next_fire_time(row: aiosqlite.Row, now: datetime) -> datetime | None:
"""
Compute when a timer will next be time-due.
"""Compute when a timer will next be time-due.
:param row: Database row with interval_type, interval_value, last_fired_at.
:param now: Current UTC time.
@@ -167,8 +162,7 @@ def _next_fire_time(row: aiosqlite.Row, now: datetime) -> datetime | None:
def _is_timer_due(row: aiosqlite.Row, now: datetime) -> bool:
"""
Check whether a timer should fire based on its interval and last fire time.
"""Check whether a timer should fire based on its interval and last fire time.
:param row: Database row with interval_type, interval_value, last_fired_at.
:param now: Current UTC time.
@@ -187,6 +181,7 @@ class TimerScheduler:
"""
def __init__(self) -> None:
"""Initialize an empty scheduler with no running task."""
self._task: asyncio.Task[None] | None = None
self._counted_ids: dict[int, set[str]] = {}
self._wake_event: asyncio.Event | None = None
@@ -197,8 +192,7 @@ class TimerScheduler:
return self._counted_ids
def init_counted_ids(self, timer_ids: list[int]) -> None:
"""
Initialize empty counter sets for the given timer IDs.
"""Initialize empty counter sets for the given timer IDs.
Called during module setup to prepare tracking for enabled timers.
@@ -207,8 +201,7 @@ class TimerScheduler:
self._counted_ids = {tid: set() for tid in timer_ids}
def start(self, ctx: ModuleContext) -> None:
"""
Start the background scheduler task.
"""Start the background scheduler task.
:param ctx: The module context.
"""
@@ -219,8 +212,7 @@ class TimerScheduler:
ctx.logger.debug("Timer scheduler started.")
async def stop(self, ctx: ModuleContext) -> None:
"""
Cancel the background scheduler task and wait for it to exit.
"""Cancel the background scheduler task and wait for it to exit.
:param ctx: The module context.
"""
@@ -233,8 +225,7 @@ class TimerScheduler:
ctx.logger.info("Timer scheduler stopped.")
def reschedule(self) -> None:
"""
Wake the scheduler so it recalculates the next fire time.
"""Wake the scheduler so it recalculates the next fire time.
Called by timer management commands when timer state changes.
"""
@@ -242,8 +233,7 @@ class TimerScheduler:
self._wake_event.set()
async def _scheduler_loop(self, ctx: ModuleContext, wake: asyncio.Event) -> None:
"""
Background loop that sleeps until the next timer is due and fires it.
"""Background loop that sleeps until the next timer is due and fires it.
Uses an asyncio.Event to allow early wake-ups when timer state changes.
@@ -283,8 +273,7 @@ class TimerScheduler:
ctx.logger.exception("Scheduler tick failed.")
async def _tick(self, ctx: ModuleContext) -> None:
"""
Single scheduler tick: query enabled timers and fire any that are due.
"""Single scheduler tick: query enabled timers and fire any that are due.
:param ctx: The module context.
"""
@@ -342,8 +331,7 @@ class TimerScheduler:
async def _compute_next_delay(ctx: ModuleContext) -> tuple[float | None, str | None]:
"""
Query enabled timers and return seconds until the soonest one is time-due.
"""Query enabled timers and return seconds until the soonest one is time-due.
:param ctx: The module context.
:return: Tuple of (seconds until next timer, display name of that timer),
@@ -394,8 +382,7 @@ _scheduler: TimerScheduler | None = None
def get_scheduler() -> TimerScheduler:
"""
Return the active scheduler instance.
"""Return the active scheduler instance.
:return: The active TimerScheduler.
:raises RuntimeError: If the scheduler has not been initialized.
@@ -406,8 +393,7 @@ def get_scheduler() -> TimerScheduler:
def set_scheduler(scheduler: TimerScheduler) -> None:
"""
Set the active scheduler instance.
"""Set the active scheduler instance.
:param scheduler: The TimerScheduler to install.
"""
+3 -6
View File
@@ -48,8 +48,7 @@ type WebhookCallback = Callable[[EventType, Event], Coroutine[Any, Any, None]]
class HttpServer:
"""
HTTP server for Owlbot.
"""HTTP server for Owlbot.
Manages the aiohttp Application, built-in routes (webhook),
and module-registered routes via the RouteDispatcher.
@@ -61,8 +60,7 @@ class HttpServer:
event_dispatch: WebhookCallback,
route_dispatcher: RouteDispatcher,
):
"""
Initialize the web server.
"""Initialize the web server.
:param config: Bot configuration.
:param event_dispatch: Async callback to dispatch webhook events.
@@ -94,8 +92,7 @@ class HttpServer:
logger.debug("HttpServer initialized.")
async def start(self, host: str, port: int) -> None:
"""
Start the HTTP server.
"""Start the HTTP server.
:param host: Address to bind to.
:param port: Port to bind to.
+14 -28
View File
@@ -54,8 +54,7 @@ class ModuleLoader:
config: Config,
http_client: HttpClient,
):
"""
Initialize the module loader.
"""Initialize the module loader.
:param modules_dir: Path to the user modules directory.
:param config: Configuration object for checking module enable/disable state.
@@ -105,8 +104,7 @@ class ModuleLoader:
)
def get_module_context(self, module_name: str) -> ModuleContext | None:
"""
Look up a module's context by name.
"""Look up a module's context by name.
:param module_name: The module name.
:return: The ModuleContext if the module is loaded, None otherwise.
@@ -128,8 +126,7 @@ class ModuleLoader:
return ctx
def discover_module_names(self) -> list[str]:
"""
Discover all loadable modules from both built-in and user directories.
"""Discover all loadable modules from both built-in and user directories.
Built-in modules ship with the package. User modules are discovered
from the configured modules directory. If a user module has the same
@@ -160,8 +157,7 @@ class ModuleLoader:
return modules
def _discover_user_module_names(self) -> set[str]:
"""
Scan the user modules directory for loadable modules.
"""Scan the user modules directory for loadable modules.
Supports both single-file modules (``name.py``) and package modules
(``name/__init__.py``). Files and directories starting with underscore
@@ -197,8 +193,7 @@ class ModuleLoader:
return found
async def load_all_modules(self) -> list[str]:
"""
Discover and load all enabled modules using two-phase loading.
"""Discover and load all enabled modules using two-phase loading.
**Phase 1:** Import every module, create contexts, and register all
decorated handlers (``@on_event``, ``@on_command``, ``@on_route``).
@@ -255,8 +250,7 @@ class ModuleLoader:
return loaded
async def load_module(self, module_name: str, *, _run_setup: bool = True) -> None:
"""
Load a single module by name.
"""Load a single module by name.
If both a package and single-file form exist for the same name,
the package form is used. Checks if the module is enabled in
@@ -354,8 +348,7 @@ class ModuleLoader:
raise ModuleLoadError(f"Failed to load module '{module_name}': {e}") from e
async def unload_module(self, module_name: str) -> bool:
"""
Unload a module, calling its teardown and cleaning up all state.
"""Unload a module, calling its teardown and cleaning up all state.
:param module_name: The module to unload.
:return: True if module was unloaded, False if not found.
@@ -397,8 +390,7 @@ class ModuleLoader:
return True
async def unload_all_modules(self) -> None:
"""
Unload all modules, calling teardown and cleaning up all state.
"""Unload all modules, calling teardown and cleaning up all state.
Called during bot shutdown to allow modules to clean up resources.
"""
@@ -411,8 +403,7 @@ class ModuleLoader:
logger.info("All module unload complete.")
async def _run_module_setup(self, module_name: str) -> None:
"""
Run a module's ``@on_setup`` hooks if any are defined.
"""Run a module's ``@on_setup`` hooks if any are defined.
All setup handlers run inside a single storage transaction that is
committed on success. If any handler fails, the transaction is
@@ -449,8 +440,7 @@ class ModuleLoader:
) from e
def _resolve_module_path(self, module_name: str) -> Path:
"""
Resolve the filesystem path for a module by name.
"""Resolve the filesystem path for a module by name.
User modules are checked first (package form, then single-file).
If no user module is found and the name is a built-in, the built-in
@@ -478,8 +468,7 @@ class ModuleLoader:
@staticmethod
def _resolve_builtin_module_path(module_name: str) -> Path:
"""
Locate a built-in module's ``__init__.py`` inside the package.
"""Locate a built-in module's ``__init__.py`` inside the package.
:param module_name: Name of the built-in module.
:return: Path to the module's ``__init__.py``.
@@ -493,8 +482,7 @@ class ModuleLoader:
)
def _register_module_handlers(self, module: ModuleType, module_name: str) -> None:
"""
Scan a module for decorated handlers and register them.
"""Scan a module for decorated handlers and register them.
Delegates to each registry's ``register_from_module()`` method,
which knows how to find its own decorator markers.
@@ -510,8 +498,7 @@ class ModuleLoader:
def _collect_lifecycle_handlers(
module: ModuleType, marker: str
) -> list[LifecycleHandler]:
"""
Collect callables from a module that have a given marker attribute.
"""Collect callables from a module that have a given marker attribute.
Scans ``vars(module)`` for callable objects where ``getattr(obj, marker)``
is truthy. Used to find ``@on_setup`` (marker ``"_owlbot_setup"``) and
@@ -528,8 +515,7 @@ class ModuleLoader:
]
def _cleanup_module(self, module_name: str) -> None:
"""
Remove all state associated with a module.
"""Remove all state associated with a module.
Removes the top-level module entry and any submodule entries
(for package-style modules) from ``sys.modules``.
+27 -54
View File
@@ -41,16 +41,14 @@ logger = logging.getLogger("owlbot.commands")
class CommandRegistry:
"""
Holds all registered commands for a bot instance.
"""Holds all registered commands for a bot instance.
Instance-scoped to enable proper dependency injection and allow multiple
bot instances to coexist without sharing state.
"""
def __init__(self, prefix: str = "!") -> None:
"""
Initialize the command registry.
"""Initialize the command registry.
:param prefix: Command prefix character (e.g., "!" for "!ping").
"""
@@ -76,8 +74,7 @@ class CommandRegistry:
cooldown: int | float = 0,
module_name: str,
) -> None:
"""
Register a command handler.
"""Register a command handler.
:param name: Primary command name (case-insensitive).
:param handler: Async function to handle the command.
@@ -123,8 +120,7 @@ class CommandRegistry:
)
def unregister(self, name: str) -> bool:
"""
Unregister a command and all its aliases.
"""Unregister a command and all its aliases.
:param name: The primary command name or any alias.
:return: True if command was found and removed, False otherwise.
@@ -149,8 +145,7 @@ class CommandRegistry:
return True
def get(self, trigger: str) -> CommandInfo | None:
"""
Look up a command by name or alias.
"""Look up a command by name or alias.
:param trigger: Command name or alias (case-insensitive).
:return: CommandInfo if found, None otherwise.
@@ -162,8 +157,7 @@ class CommandRegistry:
return self._commands.get(primary)
def exists(self, trigger: str) -> bool:
"""
Check if a command is registered.
"""Check if a command is registered.
:param trigger: Command name or alias (case-insensitive).
:return: True if the command exists, False otherwise.
@@ -171,16 +165,14 @@ class CommandRegistry:
return trigger.lower() in self._aliases
def get_all(self) -> dict[str, CommandInfo]:
"""
Get all registered commands.
"""Get all registered commands.
:return: Dict mapping primary command names to CommandInfo.
"""
return self._commands.copy()
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all commands registered by a specific module.
"""Remove all commands registered by a specific module.
:param module_name: The module whose commands should be removed.
:return: Number of commands removed.
@@ -197,8 +189,7 @@ class CommandRegistry:
return len(to_remove)
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for @on_command-decorated functions and register them.
"""Scan a Python module for @on_command-decorated functions and register them.
Looks for functions with the ``_owlbot_command`` attribute set by
the ``@on_command`` decorator and registers each one.
@@ -222,8 +213,7 @@ class CommandRegistry:
)
def parse(self, message: str) -> tuple[str, str] | None:
"""
Parse a message to extract command and arguments.
"""Parse a message to extract command and arguments.
:param message: The chat message body.
:return: Tuple of (command_name, args_string), or None if not a command.
@@ -245,8 +235,7 @@ class CommandRegistry:
class CommandDispatcher:
"""
Dispatches chat events to registered command handlers.
"""Dispatches chat events to registered command handlers.
Parses messages, checks authentication/moderator requirements,
and calls the appropriate command handler.
@@ -260,8 +249,7 @@ class CommandDispatcher:
loaded_modules: set[str],
command_prefix: str = "!",
) -> None:
"""
Initialize the command dispatcher.
"""Initialize the command dispatcher.
Creates and owns a :class:`CommandRegistry` internally.
@@ -296,8 +284,7 @@ class CommandDispatcher:
*,
aliases: list[str] | tuple[str, ...] | None = None,
) -> None:
"""
Register a built-in command handler.
"""Register a built-in command handler.
Built-in commands use a simpler handler signature (event, owncast_client)
and don't require module infrastructure.
@@ -328,8 +315,7 @@ class CommandDispatcher:
cooldown: int | float = 0,
module_name: str,
) -> None:
"""
Register a command handler.
"""Register a command handler.
Delegates to the internal CommandRegistry.
@@ -355,8 +341,7 @@ class CommandDispatcher:
self._cooldown_tracker.pop(name.lower(), None)
def unregister(self, name: str) -> bool:
"""
Unregister a command and all its aliases.
"""Unregister a command and all its aliases.
Delegates to the internal CommandRegistry.
@@ -371,8 +356,7 @@ class CommandDispatcher:
return result
def get(self, trigger: str) -> CommandInfo | None:
"""
Look up a command by name or alias.
"""Look up a command by name or alias.
Delegates to the internal CommandRegistry.
@@ -382,8 +366,7 @@ class CommandDispatcher:
return self._command_registry.get(trigger)
def exists(self, trigger: str) -> bool:
"""
Check if a command is registered.
"""Check if a command is registered.
Delegates to the internal CommandRegistry.
@@ -393,8 +376,7 @@ class CommandDispatcher:
return self._command_registry.exists(trigger)
def get_by_module(self, module_name: str) -> dict[str, CommandInfo]:
"""
Get all commands registered by a specific module.
"""Get all commands registered by a specific module.
:param module_name: The module whose commands to return.
:return: Dict mapping primary command names to CommandInfo for that module.
@@ -406,8 +388,7 @@ class CommandDispatcher:
}
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for @on_command-decorated functions and register them.
"""Scan a Python module for @on_command-decorated functions and register them.
Delegates to the internal CommandRegistry.
@@ -417,8 +398,7 @@ class CommandDispatcher:
self._command_registry.register_from_module(module, module_name)
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all commands registered by a specific module.
"""Remove all commands registered by a specific module.
Delegates to the internal CommandRegistry.
@@ -432,8 +412,7 @@ class CommandDispatcher:
return self._command_registry.unregister_by_module(module_name)
async def dispatch(self, event: ChatEvent) -> None:
"""
Dispatch a chat event to the appropriate command handler if it's a command.
"""Dispatch a chat event to the appropriate command handler if it's a command.
:param event: The chat event to check for commands.
"""
@@ -605,8 +584,7 @@ class CommandDispatcher:
class ModuleCommands:
"""
Module-scoped wrapper around CommandDispatcher.
"""Module-scoped wrapper around CommandDispatcher.
This wrapper auto-supplies the module name for registration operations,
so modules don't need to pass their own name back into the API.
@@ -614,8 +592,7 @@ class ModuleCommands:
"""
def __init__(self, dispatcher: CommandDispatcher, module_name: str) -> None:
"""
Initialize the module-scoped command wrapper.
"""Initialize the module-scoped command wrapper.
:param dispatcher: The CommandDispatcher that owns the command registry.
:param module_name: The name of the module using this wrapper.
@@ -643,8 +620,7 @@ class ModuleCommands:
requires_moderator: bool = False,
cooldown: int | float = 0,
) -> None:
"""
Register a command handler for this module.
"""Register a command handler for this module.
The module name is automatically supplied.
@@ -667,8 +643,7 @@ class ModuleCommands:
)
def unregister(self, name: str) -> bool:
"""
Unregister a command and all its aliases.
"""Unregister a command and all its aliases.
Only commands registered by this module can be unregistered.
@@ -682,8 +657,7 @@ class ModuleCommands:
return self._dispatcher.unregister(name)
def get(self, trigger: str) -> CommandInfo | None:
"""
Look up a command by name or alias within this module's registrations.
"""Look up a command by name or alias within this module's registrations.
:param trigger: Command name or alias (case-insensitive).
:return: CommandInfo if found and owned by this module, None otherwise.
@@ -694,8 +668,7 @@ class ModuleCommands:
return info
def exists(self, trigger: str) -> bool:
"""
Check if a command is registered across all modules.
"""Check if a command is registered across all modules.
:param trigger: Command name or alias (case-insensitive).
:return: True if the command exists, False otherwise.
+23 -46
View File
@@ -49,8 +49,7 @@ logger = logging.getLogger("owlbot.events")
class EventRegistry:
"""
Holds all registered event handlers for a bot instance.
"""Holds all registered event handlers for a bot instance.
Instance-scoped to enable proper dependency injection and allow multiple
bot instances to coexist without sharing state.
@@ -69,8 +68,7 @@ class EventRegistry:
module_name: str,
priority: int = Priority.NORMAL,
) -> None:
"""
Register a handler for the given event types.
"""Register a handler for the given event types.
Called by the module loader after scanning for decorated functions.
@@ -97,8 +95,7 @@ class EventRegistry:
)
def unregister(self, handler: EventHandler) -> bool:
"""
Unregister a handler from all event types it is registered for.
"""Unregister a handler from all event types it is registered for.
:param handler: The handler function to unregister.
:return: True if handler was found and removed, False otherwise.
@@ -126,8 +123,7 @@ class EventRegistry:
return False
def get(self, event_type: EventType) -> list[HandlerEntry]:
"""
Get all handlers registered for a specific event type.
"""Get all handlers registered for a specific event type.
:param event_type: The event type to look up.
:return: List of (handler, module_name, priority) tuples.
@@ -135,8 +131,7 @@ class EventRegistry:
return self._handlers.get(event_type.value, [])
def get_all(self) -> EventHandlerMap:
"""
Get a copy of the entire handler registry.
"""Get a copy of the entire handler registry.
:return: Dict mapping event type values to handler lists.
"""
@@ -145,8 +140,7 @@ class EventRegistry:
}
def get_handler_module(self, handler: EventHandler) -> str | None:
"""
Look up which module registered a given handler.
"""Look up which module registered a given handler.
:param handler: The handler function to look up.
:return: The module name if found, None otherwise.
@@ -158,8 +152,7 @@ class EventRegistry:
return None
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all handlers registered by a specific module.
"""Remove all handlers registered by a specific module.
:param module_name: The module whose handlers should be removed.
:return: Number of handlers removed.
@@ -181,8 +174,7 @@ class EventRegistry:
return len(seen)
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for @on_event-decorated functions and register them.
"""Scan a Python module for @on_event-decorated functions and register them.
Looks for functions with the ``_owlbot_event`` attribute set by
the ``@on_event`` decorator and registers each one.
@@ -201,8 +193,7 @@ class EventRegistry:
class EventDispatcher:
"""
Dispatches events to registered handlers sequentially by priority.
"""Dispatches events to registered handlers sequentially by priority.
After all event handlers complete, command dispatch is triggered for
CHAT events (via the injected command_dispatch callback).
@@ -214,8 +205,7 @@ class EventDispatcher:
get_module_context: Callable[[str], ModuleContext],
handler_timeout: float,
) -> None:
"""
Initialize the event dispatcher.
"""Initialize the event dispatcher.
Creates and owns a :class:`EventRegistry` internally.
@@ -237,8 +227,7 @@ class EventDispatcher:
module_name: str,
priority: int = Priority.NORMAL,
) -> None:
"""
Register a handler for the given event types.
"""Register a handler for the given event types.
A single handler can respond to multiple event types; the registry
stores a separate entry per event type. This is the shared entry
@@ -255,8 +244,7 @@ class EventDispatcher:
self._handler_registry.register(handler, event_types, module_name, priority)
def unregister(self, handler: EventHandler) -> bool:
"""
Unregister a handler from all event types it is registered for.
"""Unregister a handler from all event types it is registered for.
Unlike commands (looked up by name string), event handlers are
identified by object identity. A handler registered for multiple
@@ -268,8 +256,7 @@ class EventDispatcher:
return self._handler_registry.unregister(handler)
def get_by_module(self, module_name: str) -> EventHandlerMap:
"""
Get all handlers registered by a specific module, grouped by event type.
"""Get all handlers registered by a specific module, grouped by event type.
The registry stores handlers grouped by event type, not by module,
so this filters across all event types to collect a single module's
@@ -288,8 +275,7 @@ class EventDispatcher:
return result
def get_handler_module(self, handler: EventHandler) -> str | None:
"""
Reverse-lookup which module registered a given handler.
"""Reverse-lookup which module registered a given handler.
Scans all event types since handlers are stored by event type,
not by module.
@@ -300,8 +286,7 @@ class EventDispatcher:
return self._handler_registry.get_handler_module(handler)
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for ``@on_event``-decorated functions and register them.
"""Scan a Python module for ``@on_event``-decorated functions and register them.
This is the import-phase entry point: the module loader calls it once
per module. Decorator attributes are read here and passed as explicit
@@ -314,8 +299,7 @@ class EventDispatcher:
self._handler_registry.register_from_module(module, module_name)
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all handlers registered by a specific module.
"""Remove all handlers registered by a specific module.
Used during module teardown to clean up all of a module's handlers
in one call, regardless of which event types they were registered for.
@@ -326,8 +310,7 @@ class EventDispatcher:
return self._handler_registry.unregister_by_module(module_name)
async def dispatch(self, event_type: EventType, event: Event) -> None:
"""
Dispatch an event to handlers sequentially by priority, then to commands.
"""Dispatch an event to handlers sequentially by priority, then to commands.
:param event_type: The type of event to dispatch.
:param event: The parsed event instance.
@@ -391,8 +374,7 @@ class EventDispatcher:
module_name: str,
propagation: PropagationState,
) -> None:
"""
Call a single handler with timeout enforcement and transaction management.
"""Call a single handler with timeout enforcement and transaction management.
:param handler: The handler function to call.
:param event: The event to pass to the handler.
@@ -444,8 +426,7 @@ class EventDispatcher:
class ModuleEvents:
"""
Module-scoped wrapper around EventDispatcher.
"""Module-scoped wrapper around EventDispatcher.
This wrapper auto-supplies the module name for registration operations,
so modules don't need to pass their own name back into the API.
@@ -453,8 +434,7 @@ class ModuleEvents:
"""
def __init__(self, dispatcher: EventDispatcher, module_name: str) -> None:
"""
Initialize the module-scoped handler wrapper.
"""Initialize the module-scoped handler wrapper.
:param dispatcher: The EventDispatcher that owns the handler registry.
:param module_name: The name of the module using this wrapper.
@@ -473,8 +453,7 @@ class ModuleEvents:
event_types: tuple[EventType, ...],
priority: int = Priority.NORMAL,
) -> None:
"""
Register an event handler for this module.
"""Register an event handler for this module.
The module name is automatically supplied.
@@ -491,8 +470,7 @@ class ModuleEvents:
)
def unregister(self, handler: EventHandler) -> bool:
"""
Unregister a handler from all event types it is registered for.
"""Unregister a handler from all event types it is registered for.
Only handlers registered by this module can be unregistered.
@@ -505,8 +483,7 @@ class ModuleEvents:
return self._dispatcher.unregister(handler)
def get(self, event_type: EventType) -> list[HandlerEntry]:
"""
Get handlers registered by this module for a specific event type.
"""Get handlers registered by this module for a specific event type.
:param event_type: The event type to look up.
:return: List of (handler, module_name, priority) tuples for this module only.
+27 -54
View File
@@ -39,8 +39,7 @@ logger = logging.getLogger("owlbot.web")
class RouteRegistry:
"""
Holds all registered routes for a bot instance.
"""Holds all registered routes for a bot instance.
Routes are namespaced by module to prevent conflicts. Supports path
patterns using aiohttp's ``{name}`` and ``{name:regex}`` syntax via
@@ -64,8 +63,7 @@ class RouteRegistry:
methods: list[str] | None = None,
module_name: str,
) -> RouteInfo:
"""
Register a route handler.
"""Register a route handler.
:param path: URL path relative to module namespace. Supports
``{name}`` and ``{name:regex}`` patterns.
@@ -108,8 +106,7 @@ class RouteRegistry:
return info
def unregister(self, full_path: str) -> bool:
"""
Unregister a route by its full path.
"""Unregister a route by its full path.
:param full_path: The full route path including namespace.
:return: True if route was found and removed, False otherwise.
@@ -134,8 +131,7 @@ class RouteRegistry:
return False
def get(self, full_path: str) -> RouteInfo | None:
"""
Look up a route by its full path (exact match on registered pattern).
"""Look up a route by its full path (exact match on registered pattern).
:param full_path: The full route path including namespace.
:return: RouteInfo if found, None otherwise.
@@ -146,8 +142,7 @@ class RouteRegistry:
return None
def match(self, full_path: str) -> tuple[RouteInfo, dict[str, str]] | None:
"""
Match a request path against registered routes.
"""Match a request path against registered routes.
Scans routes in registration order (first match wins). Uses
``DynamicResource._match()`` for both plain and parameterized paths.
@@ -162,16 +157,14 @@ class RouteRegistry:
return None
def get_all(self) -> dict[str, RouteInfo]:
"""
Get all registered routes.
"""Get all registered routes.
:return: Dict mapping full paths to RouteInfo.
"""
return {info.full_path: info for _, info in self._routes}
def get_by_module(self, module_name: str) -> list[RouteInfo]:
"""
Get all routes registered by a specific module.
"""Get all routes registered by a specific module.
:param module_name: The module name.
:return: List of RouteInfo for that module.
@@ -180,8 +173,7 @@ class RouteRegistry:
return [info for _, info in self._routes if info.full_path in paths]
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all routes registered by a specific module.
"""Remove all routes registered by a specific module.
:param module_name: The module whose routes should be removed.
:return: Number of routes removed.
@@ -202,8 +194,7 @@ class RouteRegistry:
return count
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for @on_route-decorated functions and register them.
"""Scan a Python module for @on_route-decorated functions and register them.
Looks for functions with the ``_owlbot_route`` attribute set by
the ``@on_route`` decorator and registers each one.
@@ -225,8 +216,7 @@ class RouteRegistry:
class RouteDispatcher:
"""
Dispatches HTTP requests to registered module route handlers.
"""Dispatches HTTP requests to registered module route handlers.
Looks up routes in the RouteRegistry, validates methods, creates
RouteContext, and calls the handler with timeout and transaction management.
@@ -237,8 +227,7 @@ class RouteDispatcher:
get_module_context: Callable[[str], ModuleContext],
handler_timeout: float,
) -> None:
"""
Initialize the route dispatcher.
"""Initialize the route dispatcher.
Creates and owns a :class:`RouteRegistry` internally.
@@ -258,8 +247,7 @@ class RouteDispatcher:
methods: list[str] | None = None,
module_name: str,
) -> RouteInfo:
"""
Register a route handler.
"""Register a route handler.
Delegates to the internal RouteRegistry.
@@ -278,8 +266,7 @@ class RouteDispatcher:
)
def unregister(self, full_path: str) -> bool:
"""
Unregister a route by its full path.
"""Unregister a route by its full path.
Delegates to the internal RouteRegistry.
@@ -289,8 +276,7 @@ class RouteDispatcher:
return self._route_registry.unregister(full_path)
def get(self, full_path: str) -> RouteInfo | None:
"""
Look up a route by its full path.
"""Look up a route by its full path.
Delegates to the internal RouteRegistry.
@@ -300,8 +286,7 @@ class RouteDispatcher:
return self._route_registry.get(full_path)
def get_by_module(self, module_name: str) -> list[RouteInfo]:
"""
Get all routes registered by a specific module.
"""Get all routes registered by a specific module.
Delegates to the internal RouteRegistry.
@@ -311,8 +296,7 @@ class RouteDispatcher:
return self._route_registry.get_by_module(module_name)
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""
Scan a Python module for @on_route-decorated functions and register them.
"""Scan a Python module for @on_route-decorated functions and register them.
Delegates to the internal RouteRegistry.
@@ -322,8 +306,7 @@ class RouteDispatcher:
self._route_registry.register_from_module(module, module_name)
def unregister_by_module(self, module_name: str) -> int:
"""
Remove all routes registered by a specific module.
"""Remove all routes registered by a specific module.
Delegates to the internal RouteRegistry.
@@ -333,8 +316,7 @@ class RouteDispatcher:
return self._route_registry.unregister_by_module(module_name)
async def dispatch(self, request: web.Request) -> web.StreamResponse:
"""
Dispatch an HTTP request to the appropriate module route handler.
"""Dispatch an HTTP request to the appropriate module route handler.
Extracts module_name and path from the URL, matches it against
registered routes (supporting path patterns), validates the HTTP
@@ -374,8 +356,7 @@ class RouteDispatcher:
route_info: RouteInfo,
match_info: dict[str, str] | None = None,
) -> web.StreamResponse:
"""
Handle an HTTP request to a module-registered route.
"""Handle an HTTP request to a module-registered route.
:param request: The aiohttp request object.
:param route_info: Information about the registered route.
@@ -447,8 +428,7 @@ class RouteDispatcher:
class ModuleRoutes:
"""
Module-scoped wrapper around RouteDispatcher.
"""Module-scoped wrapper around RouteDispatcher.
This wrapper auto-supplies the module name for route operations,
so modules don't need to know the internal routing namespace.
@@ -458,8 +438,7 @@ class ModuleRoutes:
def __init__(
self, dispatcher: RouteDispatcher, module_name: str, public_base_url: str
) -> None:
"""
Initialize the module-scoped routes wrapper.
"""Initialize the module-scoped routes wrapper.
:param dispatcher: The RouteDispatcher that owns the route registry.
:param module_name: The name of the module using this wrapper.
@@ -475,8 +454,7 @@ class ModuleRoutes:
return self._dispatcher.get_by_module(self._module_name)
def url_for(self, path: str) -> str:
"""
Build a public URL for a route registered by this module.
"""Build a public URL for a route registered by this module.
:param path: The route path (e.g., "/list").
:return: Full public URL (e.g., "http://host/owlbot/quotes/list").
@@ -490,8 +468,7 @@ class ModuleRoutes:
*,
methods: list[str] | None = None,
) -> RouteInfo:
"""
Register a route handler for this module.
"""Register a route handler for this module.
The module name is automatically supplied.
@@ -509,8 +486,7 @@ class ModuleRoutes:
)
def unregister(self, path: str) -> bool:
"""
Unregister a route by its relative path.
"""Unregister a route by its relative path.
:param path: Relative route path (e.g., "/stats").
:return: True if route was found and removed, False otherwise.
@@ -518,8 +494,7 @@ class ModuleRoutes:
return self._dispatcher.unregister(self._full_path(path))
def get(self, path: str) -> RouteInfo | None:
"""
Look up a route by its relative path.
"""Look up a route by its relative path.
:param path: Relative route path (e.g., "/stats").
:return: RouteInfo if found, None otherwise.
@@ -527,8 +502,7 @@ class ModuleRoutes:
return self._dispatcher.get(self._full_path(path))
def exists(self, path: str) -> bool:
"""
Check if a route is registered at the given relative path.
"""Check if a route is registered at the given relative path.
:param path: Relative route path (e.g., "/stats").
:return: True if the route exists, False otherwise.
@@ -536,8 +510,7 @@ class ModuleRoutes:
return self.get(path) is not None
def _full_path(self, path: str) -> str:
"""
Normalize a relative path into the full namespaced path.
"""Normalize a relative path into the full namespaced path.
:param path: Relative route path (e.g., "/list" or "list").
:return: Full path (e.g., "/owlbot/quotes/list").
+5
View File
@@ -77,6 +77,11 @@ select = [
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"RUF", # Ruff-specific rules
"D", # pydocstyle
]
ignore = [
"D203", # incompatible with D211 (no blank line before class docstring)
"D213", # incompatible with D212 (summary on first line)
]
[tool.ruff.lint.per-file-ignores]
+3
View File
@@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared test fixtures for the Owlbot test suite."""
from __future__ import annotations
from typing import TYPE_CHECKING
@@ -27,5 +29,6 @@ if TYPE_CHECKING:
@pytest.fixture
async def storage(tmp_path: Path) -> AsyncIterator[ModuleStorage]:
"""Yield an open ModuleStorage backed by a temporary directory."""
async with ModuleStorage(tmp_path, "test_module") as s:
yield s