# 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)