Refactored custom commands module into layered architecture with typed domain objects and comprehensive tests.
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 15s
CI / Tests (Python 3.13) (push) Successful in 14s
CI / Tests (Python 3.14) (push) Successful in 11s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s

This commit is contained in:
2026-04-12 14:21:33 -04:00
parent 0b28ec39d0
commit 3413b1dfe4
13 changed files with 2394 additions and 844 deletions
@@ -0,0 +1,355 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Business logic coordinator for the custom_commands module.
Manages the lifecycle of custom commands: CRUD operations, alias management,
command execution with placeholder processing, and counter management. All
persistence is delegated to CommandRepository. Command registry interactions
go through the ModuleCommands wrapper.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from .placeholders import DEFAULT_MAX_DEPTH, process_placeholders
from .types import (
NAME_RE,
AliasIsCanonicalNameError,
CannotDeleteByAliasError,
CommandAlreadyExistsError,
CommandNotFoundError,
InvalidNameError,
NotCustomCommandError,
)
if TYPE_CHECKING:
from owlbot.api import CommandContext, ModuleContext
from .repository import CommandRepository
from .types import Command
class CommandManager:
"""Coordinates between repository, command registry, and placeholders."""
def __init__(
self,
ctx: ModuleContext,
repo: CommandRepository,
) -> None:
"""Initialize the manager.
:param ctx: The module context.
:param repo: The command repository for persistence.
"""
self._ctx = ctx
self._repo = repo
self._commands = ctx.commands
async def _resolve(self, name: str) -> Command:
"""Resolve a name/alias to a Command, or raise a domain error.
If the name is not in the repository but exists in the global command
registry, raises NotCustomCommandError. Otherwise re-raises
CommandNotFoundError.
"""
try:
return await self._repo.get(name)
except CommandNotFoundError as err:
if self._commands.exists(name):
raise NotCustomCommandError(name) from err
raise
def _reregister(self, command: Command) -> None:
"""Unregister and re-register a command with current snapshot settings.
Both operations are synchronous, preventing interleaved state.
"""
self._commands.unregister(command.name)
self._commands.register(
name=command.name,
handler=custom_command_handler,
aliases=list(command.aliases),
requires_moderator=command.requires_moderator,
cooldown=command.cooldown,
)
async def load_all(self) -> None:
"""Load all commands from the database into the command registry."""
commands = await self._repo.list_all()
loaded = 0
skipped = 0
for cmd in commands:
try:
self._commands.register(
name=cmd.name,
handler=custom_command_handler,
aliases=list(cmd.aliases),
requires_moderator=cmd.requires_moderator,
cooldown=cmd.cooldown,
)
loaded += 1
except ValueError:
self._ctx.logger.warning(
"Skipping custom command '%s': conflicts with an existing command.",
cmd.name,
)
skipped += 1
self._ctx.logger.info("Loaded %d custom command(s) from database.", loaded)
if skipped:
self._ctx.logger.info("Skipped %d conflicting custom command(s).", skipped)
async def create_command(self, name: str, response: str) -> Command:
"""Create a new custom command.
:param name: Command name (must match NAME_RE).
:param response: Response template.
:return: Snapshot of the created command.
:raises InvalidNameError: If name is invalid.
:raises CommandAlreadyExistsError: If name conflicts with any command.
"""
if not NAME_RE.match(name):
raise InvalidNameError(name)
if self._commands.exists(name):
raise CommandAlreadyExistsError(name)
default_cooldown: int = self._ctx.config.get("default_cooldown", 5)
self._commands.register(
name=name,
handler=custom_command_handler,
cooldown=default_cooldown,
)
try:
command = await self._repo.create(name, response, default_cooldown)
except Exception:
self._commands.unregister(name)
raise
self._ctx.logger.info("Custom command '%s' created.", name)
return command
async def edit_command(self, name: str, response: str) -> Command:
"""Edit an existing custom command's response.
:param name: Command name or alias.
:param response: New response template.
:return: Updated command snapshot.
"""
command = await self._resolve(name)
updated = await self._repo.update_response(command.name, response)
self._ctx.logger.info("Custom command '%s' updated.", command.name)
return updated
async def delete_command(self, input_name: str) -> Command:
"""Delete a custom command.
:param input_name: Command name (must be canonical, not alias).
:return: Snapshot of the deleted command.
:raises CannotDeleteByAliasError: If input_name is an alias.
"""
command = await self._resolve(input_name)
if input_name != command.name:
raise CannotDeleteByAliasError(input_name, command.name)
deleted = await self._repo.delete(command.name)
self._commands.unregister(command.name)
self._ctx.logger.info("Custom command '%s' deleted.", command.name)
return deleted
async def set_mod_only(self, name: str, *, enabled: bool) -> Command:
"""Toggle moderator-only access for a command.
:param name: Command name or alias.
:param enabled: True for moderator-only, False for public.
:return: Updated command snapshot.
"""
command = await self._resolve(name)
updated = await self._repo.update_moderator_flag(command.name, enabled=enabled)
self._reregister(updated)
self._ctx.logger.info(
"Custom command '%s' set to %s.",
command.name,
"moderator-only" if enabled else "public",
)
return updated
async def set_cooldown(self, name: str, seconds: int) -> Command:
"""Set a command's cooldown.
:param name: Command name or alias.
:param seconds: Cooldown in seconds (0 to disable).
:return: Updated command snapshot.
"""
command = await self._resolve(name)
updated = await self._repo.update_cooldown(command.name, seconds=seconds)
self._reregister(updated)
self._ctx.logger.info(
"Custom command '%s' cooldown set to %ds.",
command.name,
seconds,
)
return updated
async def reset_use_count(self, name: str) -> Command:
"""Reset a command's use counter to zero.
:param name: Command name or alias.
:return: Updated command snapshot.
"""
command = await self._resolve(name)
reset = await self._repo.reset_use_count(command.name)
self._ctx.logger.info("Custom command '%s' counter reset.", command.name)
return reset
async def add_alias(self, command_name: str, alias: str) -> Command:
"""Add an alias to a command.
:param command_name: Command name or alias (resolved to canonical).
:param alias: New alias to add.
:return: Updated command snapshot.
"""
if not NAME_RE.match(alias):
raise InvalidNameError(alias)
command = await self._resolve(command_name)
if alias == command.name:
raise AliasIsCanonicalNameError(alias)
if self._commands.exists(alias):
raise CommandAlreadyExistsError(alias)
updated = await self._repo.add_alias(command.name, alias)
self._reregister(updated)
self._ctx.logger.info(
"Alias '%s' added to custom command '%s'.",
alias,
command.name,
)
return updated
async def remove_alias(self, alias: str) -> Command:
"""Remove an alias from a command.
:param alias: Alias to remove.
:return: Updated command snapshot (owner with alias removed).
"""
_, updated = await self._repo.remove_alias(alias)
self._reregister(updated)
self._ctx.logger.info(
"Alias '%s' removed from custom command '%s'.",
alias,
updated.name,
)
return updated
async def execute_command(
self,
name: str,
args_list: list[str],
user_display_name: str,
) -> str:
"""Execute a custom command: increment count and process placeholders.
:param name: Canonical command name.
:param args_list: Arguments passed to the command.
:param user_display_name: Display name of the invoking user.
:return: The processed response string.
"""
command = await self._repo.increment_use_count(name)
max_depth: int = self._ctx.config.get("max_nesting_depth", DEFAULT_MAX_DEPTH)
return await process_placeholders(
command.response,
args_list,
user_display_name,
command,
self,
max_depth=max_depth,
)
async def get_counter(self, name: str) -> int:
"""Get a counter's value (CounterAccessor protocol).
:param name: Counter name.
:return: Current value (0 if not found).
"""
return await self._repo.get_counter(name)
async def set_counter(self, name: str, value: int) -> int:
"""Set a counter to an absolute value (CounterAccessor protocol).
:param name: Counter name.
:param value: Absolute value to set.
:return: The new value.
"""
return await self._repo.set_counter(name, value)
async def adjust_counter(self, name: str, delta: int) -> int:
"""Adjust a counter by a relative delta (CounterAccessor protocol).
:param name: Counter name.
:param delta: Amount to add (can be negative).
:return: The new value.
"""
return await self._repo.update_counter(name, delta)
async def edit_counter(self, name: str, *, value: int, relative: bool) -> int:
"""Set or adjust a named counter.
:param name: Counter name (must match NAME_RE).
:param value: The integer value (absolute or delta).
:param relative: True for relative adjustment, False for absolute set.
:return: The new counter value.
:raises InvalidNameError: If name is invalid.
"""
if not NAME_RE.match(name):
raise InvalidNameError(name)
if relative:
new_value = await self._repo.update_counter(name, value)
else:
new_value = await self._repo.set_counter(name, value)
self._ctx.logger.info("Counter '%s' set to %d.", name, new_value)
return new_value
async def list_commands(self) -> list[Command]:
"""Return all custom commands ordered by name.
:return: List of Command snapshots.
"""
return await self._repo.list_all()
def get_manager(ctx: ModuleContext) -> CommandManager:
"""Return the CommandManager stored in the module context's state.
:param ctx: The module context.
:return: The active CommandManager.
:raises RuntimeError: If the manager has not been initialized.
"""
manager = ctx.state.get("manager")
if not isinstance(manager, CommandManager):
raise RuntimeError("CommandManager is not initialized.")
return manager
async def custom_command_handler(ctx: CommandContext) -> None:
"""Shared handler for all custom commands.
Delegates execution to the manager, which increments the use count,
processes placeholders, and returns the response string.
:param ctx: The command context.
"""
manager = get_manager(ctx.module)
response = await manager.execute_command(
ctx.command, ctx.args_list, ctx.user.display_name
)
await ctx.owncast_client.send_message(response)