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
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:
@@ -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)
|
||||
Reference in New Issue
Block a user