CI / Formatting (push) Successful in 9s
CI / Linting (push) Successful in 8s
CI / Tests (Python 3.12) (push) Failing after 25s
CI / Tests (Python 3.13) (push) Failing after 14s
CI / Tests (Python 3.14) (push) Failing after 13s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 9s
389 lines
14 KiB
Python
389 lines
14 KiB
Python
# Copyright 2026 Logan Fick
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""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,
|
|
InactiveCommandError,
|
|
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
|
|
|
|
async def _resolve_active(self, name: str) -> Command:
|
|
"""Resolve a name/alias and refuse if the command is inactive.
|
|
|
|
A command is inactive when its canonical name is not currently owned
|
|
by this module in the registry, typically because another module
|
|
holds the same name. If the conflict has cleared since startup,
|
|
attempts to re-register with the current snapshot before giving up.
|
|
|
|
:param name: Command name or alias.
|
|
:return: The resolved Command snapshot.
|
|
:raises InactiveCommandError: If the command is inactive and cannot
|
|
be reactivated.
|
|
"""
|
|
command = await self._resolve(name)
|
|
if self._commands.get(command.name) is not None:
|
|
return command
|
|
try:
|
|
self._commands.register(
|
|
name=command.name,
|
|
handler=custom_command_handler,
|
|
aliases=list(command.aliases),
|
|
requires_moderator=command.requires_moderator,
|
|
cooldown=command.cooldown,
|
|
)
|
|
except ValueError:
|
|
raise InactiveCommandError(command.name) from None
|
|
self._ctx.logger.info(
|
|
"Custom command '%s' reactivated after conflict cleared.",
|
|
command.name,
|
|
)
|
|
return command
|
|
|
|
def _reregister(self, command: Command) -> None:
|
|
"""Unregister and re-register a command when owned by this module.
|
|
|
|
No-op when another module currently holds the name, so mutation
|
|
paths (for example ``remove_alias``) can still keep the database
|
|
in sync without disturbing the active registration.
|
|
"""
|
|
if self._commands.get(command.name) is None:
|
|
return
|
|
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)
|
|
command = await self._repo.create(name, response, default_cooldown)
|
|
self._commands.register(
|
|
name=name,
|
|
handler=custom_command_handler,
|
|
cooldown=default_cooldown,
|
|
)
|
|
|
|
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_active(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_active(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_active(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_active(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_active(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.") # noqa: TRY004 # state error, not a type error
|
|
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)
|