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:
@@ -14,17 +14,14 @@
|
||||
|
||||
"""Custom commands module for Owlbot.
|
||||
|
||||
Allows moderators to create, edit, delete, and list custom chat commands at runtime.
|
||||
Custom commands are stored in SQLite and dynamically registered
|
||||
Allows moderators to create, edit, delete, and list custom chat commands
|
||||
at runtime. Custom commands are stored in SQLite and dynamically registered
|
||||
with the CommandRegistry.
|
||||
"""
|
||||
|
||||
from owlbot.api import ModuleContext, on_setup
|
||||
from owlbot.api import ModuleContext, on_setup, on_teardown
|
||||
|
||||
from .handler import custom_command_handler
|
||||
|
||||
# Re-export decorated handlers so the module loader discovers them.
|
||||
from .management_commands import (
|
||||
from .commands import (
|
||||
addalias,
|
||||
addcommand,
|
||||
commandcooldown,
|
||||
@@ -36,6 +33,8 @@ from .management_commands import (
|
||||
removealias,
|
||||
resetcommand,
|
||||
)
|
||||
from .manager import CommandManager
|
||||
from .repository import CommandRepository
|
||||
from .routes import command_list_page
|
||||
|
||||
__all__ = [
|
||||
@@ -51,6 +50,7 @@ __all__ = [
|
||||
"removealias",
|
||||
"resetcommand",
|
||||
"setup",
|
||||
"teardown",
|
||||
]
|
||||
|
||||
|
||||
@@ -58,64 +58,21 @@ __all__ = [
|
||||
async def setup(ctx: ModuleContext) -> None:
|
||||
"""Initialize the custom_commands module.
|
||||
|
||||
Creates the database schema and loads existing commands from the database.
|
||||
|
||||
:param ctx: Module context with config, storage, and other services.
|
||||
"""
|
||||
ctx.config.register_defaults({"max_nesting_depth": 4, "default_cooldown": 5})
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS commands (
|
||||
name TEXT PRIMARY KEY NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
use_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
requires_moderator INTEGER DEFAULT 0,
|
||||
cooldown INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
repo = CommandRepository(ctx.storage)
|
||||
await repo.setup()
|
||||
manager = CommandManager(ctx, repo)
|
||||
ctx.state["manager"] = manager
|
||||
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS command_aliases (
|
||||
alias TEXT PRIMARY KEY NOT NULL,
|
||||
command_name TEXT NOT NULL,
|
||||
FOREIGN KEY (command_name) REFERENCES commands(name) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
await manager.load_all()
|
||||
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
name TEXT PRIMARY KEY NOT NULL,
|
||||
value INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT c.name, c.requires_moderator, c.cooldown, "
|
||||
"GROUP_CONCAT(ca.alias) AS aliases "
|
||||
"FROM commands c "
|
||||
"LEFT JOIN command_aliases ca ON c.name = ca.command_name "
|
||||
"GROUP BY c.name"
|
||||
)
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
aliases = row["aliases"].split(",") if row["aliases"] else []
|
||||
try:
|
||||
ctx.commands.register(
|
||||
name=row["name"],
|
||||
handler=custom_command_handler,
|
||||
aliases=aliases,
|
||||
requires_moderator=bool(row["requires_moderator"]),
|
||||
cooldown=row["cooldown"],
|
||||
)
|
||||
except ValueError:
|
||||
ctx.logger.warning(
|
||||
f"Skipping custom command '{row['name']}': "
|
||||
"conflicts with an existing command."
|
||||
)
|
||||
skipped += 1
|
||||
@on_teardown
|
||||
async def teardown(ctx: ModuleContext) -> None:
|
||||
"""Clean up the custom_commands module.
|
||||
|
||||
loaded_count = len(rows) - skipped
|
||||
ctx.logger.info(f"Loaded {loaded_count} custom command(s) from database.")
|
||||
if skipped:
|
||||
ctx.logger.info(f"Skipped {skipped} conflicting custom command(s).")
|
||||
:param ctx: Module context.
|
||||
"""
|
||||
ctx.state["manager"] = None
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
# 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.
|
||||
|
||||
"""Chat command handlers for custom commands (add, edit, delete, etc.)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from owlbot.api import CommandContext, on_command
|
||||
|
||||
from .manager import get_manager
|
||||
from .types import (
|
||||
AliasAlreadyExistsError,
|
||||
AliasIsCanonicalNameError,
|
||||
CannotDeleteByAliasError,
|
||||
CommandAlreadyExistsError,
|
||||
CommandNotFoundError,
|
||||
InvalidNameError,
|
||||
NotCustomCommandError,
|
||||
)
|
||||
|
||||
|
||||
def _clean_name(raw: str, prefix: str) -> str:
|
||||
"""Strip the command prefix (if present) and lowercase a raw name."""
|
||||
return raw.removeprefix(prefix).lower()
|
||||
|
||||
|
||||
@on_command("addcommand", aliases=["addcmd"], requires_moderator=True)
|
||||
async def addcommand(ctx: CommandContext) -> None:
|
||||
"""Create a new custom command.
|
||||
|
||||
Usage: !addcommand !<name> <response text>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}addcommand {prefix}<name> <response text>"
|
||||
)
|
||||
return
|
||||
|
||||
name = _clean_name(args[0], prefix)
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}addcommand {prefix}<name> <response text>"
|
||||
)
|
||||
return
|
||||
response = parts[1]
|
||||
|
||||
try:
|
||||
cmd = await get_manager(ctx.module).create_command(name, response)
|
||||
except InvalidNameError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid command name. Only letters, numbers, and underscores are allowed."
|
||||
)
|
||||
return
|
||||
except CommandAlreadyExistsError:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Command {prefix}{name} already exists. "
|
||||
f"Use '{prefix}editcommand' to modify it."
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{cmd.name} created.")
|
||||
|
||||
|
||||
@on_command("editcommand", aliases=["editcmd"], requires_moderator=True)
|
||||
async def editcommand(ctx: CommandContext) -> None:
|
||||
"""Edit an existing custom command's response.
|
||||
|
||||
Usage: !editcommand !<name> <new response>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}editcommand {prefix}<name> <new response>"
|
||||
)
|
||||
return
|
||||
|
||||
name = _clean_name(args[0], prefix)
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}editcommand {prefix}<name> <new response>"
|
||||
)
|
||||
return
|
||||
response = parts[1]
|
||||
|
||||
try:
|
||||
cmd = await get_manager(ctx.module).edit_command(name, response)
|
||||
except CommandNotFoundError:
|
||||
await ctx.owncast_client.send_message("That command does not exist.")
|
||||
return
|
||||
except NotCustomCommandError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"That command is not a custom command and cannot be modified."
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{cmd.name} updated.")
|
||||
|
||||
|
||||
@on_command("deletecommand", aliases=["delcmd"], requires_moderator=True)
|
||||
async def deletecommand(ctx: CommandContext) -> None:
|
||||
"""Delete a custom command.
|
||||
|
||||
Usage: !deletecommand !<name>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}deletecommand {prefix}<name>"
|
||||
)
|
||||
return
|
||||
|
||||
name = _clean_name(args[0], prefix)
|
||||
|
||||
try:
|
||||
cmd = await get_manager(ctx.module).delete_command(name)
|
||||
except CommandNotFoundError:
|
||||
await ctx.owncast_client.send_message("That command does not exist.")
|
||||
return
|
||||
except NotCustomCommandError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"That command is not a custom command and cannot be modified."
|
||||
)
|
||||
return
|
||||
except CannotDeleteByAliasError as e:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"{prefix}{e.alias} is an alias of {prefix}{e.canonical}. "
|
||||
f"Use {prefix}removealias {prefix}{e.alias} to remove the alias, "
|
||||
f"or {prefix}deletecommand {prefix}{e.canonical} "
|
||||
"to delete the command."
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{cmd.name} deleted.")
|
||||
|
||||
|
||||
@on_command("commandmodonly", aliases=["cmdmodonly"], requires_moderator=True)
|
||||
async def commandmodonly(ctx: CommandContext) -> None:
|
||||
"""Toggle moderator-only access for a custom command.
|
||||
|
||||
Usage: !commandmodonly !<name> <on|off>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}commandmodonly {prefix}<name> <on|off>"
|
||||
)
|
||||
return
|
||||
|
||||
setting = args[1].lower()
|
||||
if setting not in ("on", "off"):
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid setting. Expected 'on' or 'off'."
|
||||
)
|
||||
return
|
||||
|
||||
name = _clean_name(args[0], prefix)
|
||||
enabled = setting == "on"
|
||||
|
||||
try:
|
||||
cmd = await get_manager(ctx.module).set_mod_only(name, enabled=enabled)
|
||||
except CommandNotFoundError:
|
||||
await ctx.owncast_client.send_message("That command does not exist.")
|
||||
return
|
||||
except NotCustomCommandError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"That command is not a custom command and cannot be modified."
|
||||
)
|
||||
return
|
||||
|
||||
status = "moderator-only" if cmd.requires_moderator else "public"
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Command {prefix}{cmd.name} is now {status}."
|
||||
)
|
||||
|
||||
|
||||
@on_command("resetcommand", aliases=["resetcmd"], requires_moderator=True)
|
||||
async def resetcommand(ctx: CommandContext) -> None:
|
||||
"""Reset a custom command's use counter to 0.
|
||||
|
||||
Usage: !resetcommand !<name>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}resetcommand {prefix}<name>"
|
||||
)
|
||||
return
|
||||
|
||||
name = _clean_name(args[0], prefix)
|
||||
|
||||
try:
|
||||
cmd = await get_manager(ctx.module).reset_use_count(name)
|
||||
except CommandNotFoundError:
|
||||
await ctx.owncast_client.send_message("That command does not exist.")
|
||||
return
|
||||
except NotCustomCommandError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"That command is not a custom command and cannot be modified."
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{cmd.name} counter reset.")
|
||||
|
||||
|
||||
@on_command("editcounter", aliases=["editcount"], requires_moderator=True)
|
||||
async def editcounter(ctx: CommandContext) -> None:
|
||||
"""Set, increment, or decrement a named counter.
|
||||
|
||||
Usage: !editcounter <name> <value>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
if len(args) != 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}editcounter <name> <value>"
|
||||
)
|
||||
return
|
||||
|
||||
counter_name = args[0].lower()
|
||||
value_str = args[1]
|
||||
|
||||
try:
|
||||
value = int(value_str)
|
||||
except ValueError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid value. Must be an integer (e.g. 15, +1, -3)."
|
||||
)
|
||||
return
|
||||
|
||||
relative = value_str[0] in ("+", "-")
|
||||
|
||||
try:
|
||||
new_value = await get_manager(ctx.module).edit_counter(
|
||||
counter_name, value=value, relative=relative
|
||||
)
|
||||
except InvalidNameError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid counter name. Only letters, numbers, and underscores are allowed."
|
||||
)
|
||||
return
|
||||
|
||||
if relative:
|
||||
sign = "+" if value >= 0 else ""
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Adjusted the {counter_name} counter by {sign}{value} to {new_value}."
|
||||
)
|
||||
else:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Set the {counter_name} counter to {new_value}."
|
||||
)
|
||||
|
||||
|
||||
@on_command("commandcooldown", aliases=["cmdcooldown"], requires_moderator=True)
|
||||
async def commandcooldown(ctx: CommandContext) -> None:
|
||||
"""Set or disable a custom command's cooldown.
|
||||
|
||||
Usage: !commandcooldown !<name> <seconds>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}commandcooldown {prefix}<name> <seconds>"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
seconds = int(args[1])
|
||||
except ValueError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid cooldown value. Must be a non-negative integer."
|
||||
)
|
||||
return
|
||||
|
||||
if seconds < 0:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Cooldown must be a non-negative integer (0 to disable)."
|
||||
)
|
||||
return
|
||||
|
||||
name = _clean_name(args[0], prefix)
|
||||
|
||||
try:
|
||||
cmd = await get_manager(ctx.module).set_cooldown(name, seconds)
|
||||
except CommandNotFoundError:
|
||||
await ctx.owncast_client.send_message("That command does not exist.")
|
||||
return
|
||||
except NotCustomCommandError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"That command is not a custom command and cannot be modified."
|
||||
)
|
||||
return
|
||||
|
||||
if cmd.cooldown == 0:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Command {prefix}{cmd.name} cooldown disabled."
|
||||
)
|
||||
else:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Command {prefix}{cmd.name} cooldown set to {cmd.cooldown}s."
|
||||
)
|
||||
|
||||
|
||||
@on_command("addalias", requires_moderator=True)
|
||||
async def addalias(ctx: CommandContext) -> None:
|
||||
"""Add an alias to an existing custom command.
|
||||
|
||||
Usage: !addalias !<command> !<alias>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}addalias {prefix}<command> {prefix}<alias>"
|
||||
)
|
||||
return
|
||||
|
||||
command_name = _clean_name(args[0], prefix)
|
||||
alias = _clean_name(args[1], prefix)
|
||||
|
||||
try:
|
||||
cmd = await get_manager(ctx.module).add_alias(command_name, alias)
|
||||
except CommandNotFoundError:
|
||||
await ctx.owncast_client.send_message("That command does not exist.")
|
||||
return
|
||||
except NotCustomCommandError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"That command is not a custom command and cannot be modified."
|
||||
)
|
||||
return
|
||||
except InvalidNameError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid alias name. Only letters, numbers, and underscores are allowed."
|
||||
)
|
||||
return
|
||||
except AliasIsCanonicalNameError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"An alias cannot be the same as the command name."
|
||||
)
|
||||
return
|
||||
except AliasAlreadyExistsError as e:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{e.alias} is already assigned to {prefix}{e.owner}."
|
||||
)
|
||||
return
|
||||
except CommandAlreadyExistsError as e:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{e.name} conflicts with an existing command."
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} added to {prefix}{cmd.name}."
|
||||
)
|
||||
|
||||
|
||||
@on_command("removealias", requires_moderator=True)
|
||||
async def removealias(ctx: CommandContext) -> None:
|
||||
"""Remove an alias from a custom command.
|
||||
|
||||
Usage: !removealias !<alias>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}removealias {prefix}<alias>"
|
||||
)
|
||||
return
|
||||
|
||||
alias = _clean_name(args[0], prefix)
|
||||
|
||||
try:
|
||||
cmd = await get_manager(ctx.module).remove_alias(alias)
|
||||
except CommandNotFoundError:
|
||||
await ctx.owncast_client.send_message(f"Alias {prefix}{alias} does not exist.")
|
||||
return
|
||||
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} removed from {prefix}{cmd.name}."
|
||||
)
|
||||
|
||||
|
||||
@on_command("listcommands", aliases=["listcmds"], cooldown=15)
|
||||
async def listcommands(ctx: CommandContext) -> None:
|
||||
"""List all custom commands.
|
||||
|
||||
Sends a URL to the command list web page.
|
||||
|
||||
Usage: !listcommands
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
url = ctx.routes.url_for("/list")
|
||||
await ctx.owncast_client.send_message(f"Custom commands: {url}")
|
||||
@@ -1,173 +0,0 @@
|
||||
# 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.
|
||||
|
||||
"""Shared custom command handler and database/registry helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
from owlbot.api import CommandContext, ModuleCommands, ModuleStorage
|
||||
|
||||
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.
|
||||
|
||||
:param storage: The module storage instance.
|
||||
:param command_name: The canonical command name.
|
||||
:return: List of alias names (may be empty).
|
||||
"""
|
||||
rows = await storage.fetch_all(
|
||||
"SELECT alias FROM command_aliases WHERE command_name = ? ORDER BY alias",
|
||||
(command_name,),
|
||||
)
|
||||
return [row["alias"] for row in rows]
|
||||
|
||||
|
||||
async def resolve_command_name(
|
||||
storage: ModuleStorage, name: str
|
||||
) -> aiosqlite.Row | None:
|
||||
"""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.
|
||||
|
||||
:param storage: The module storage instance.
|
||||
:param name: A command name or alias (already lowercased).
|
||||
:return: The command row, or None if not found.
|
||||
"""
|
||||
return await storage.fetch_one(
|
||||
"SELECT c.name, c.requires_moderator, c.cooldown FROM commands c "
|
||||
"WHERE c.name = ? "
|
||||
"UNION ALL "
|
||||
"SELECT c.name, c.requires_moderator, c.cooldown "
|
||||
"FROM command_aliases a JOIN commands c ON c.name = a.command_name "
|
||||
"WHERE a.alias = ? "
|
||||
"LIMIT 1",
|
||||
(name, name),
|
||||
)
|
||||
|
||||
|
||||
async def custom_command_handler(ctx: CommandContext) -> None:
|
||||
"""Shared handler for all custom commands.
|
||||
|
||||
Looks up the command in the database, increments use count,
|
||||
processes placeholders, and sends the response.
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
cmd_name = ctx.command
|
||||
|
||||
row = await ctx.storage.fetch_one(
|
||||
"UPDATE commands SET use_count = use_count + 1 WHERE name = ? "
|
||||
"RETURNING response, use_count",
|
||||
(cmd_name,),
|
||||
)
|
||||
|
||||
# Shouldn't happen unless the command was deleted but not unregistered.
|
||||
if not row:
|
||||
ctx.logger.warning(f"Custom command '{cmd_name}' not found in database.")
|
||||
return
|
||||
|
||||
max_depth = ctx.config.get("max_nesting_depth", DEFAULT_MAX_DEPTH)
|
||||
response = await process_placeholders(
|
||||
row["response"],
|
||||
ctx.args_list,
|
||||
ctx.user.display_name,
|
||||
row["use_count"],
|
||||
ctx.storage,
|
||||
max_depth=max_depth,
|
||||
)
|
||||
|
||||
await ctx.owncast_client.send_message(response)
|
||||
|
||||
|
||||
def reregister_command(
|
||||
commands: ModuleCommands,
|
||||
name: str,
|
||||
*,
|
||||
aliases: list[str],
|
||||
requires_moderator: bool,
|
||||
cooldown: int,
|
||||
) -> None:
|
||||
"""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.
|
||||
|
||||
:param commands: The module-scoped command API (ModuleCommands).
|
||||
:param name: The canonical command name.
|
||||
:param aliases: List of alias names.
|
||||
:param requires_moderator: Whether the command requires moderator.
|
||||
:param cooldown: Cooldown in seconds.
|
||||
"""
|
||||
commands.unregister(name)
|
||||
commands.register(
|
||||
name=name,
|
||||
handler=custom_command_handler,
|
||||
aliases=aliases,
|
||||
requires_moderator=requires_moderator,
|
||||
cooldown=cooldown,
|
||||
)
|
||||
|
||||
|
||||
async def update_and_reregister(
|
||||
ctx: CommandContext,
|
||||
command_row: aiosqlite.Row,
|
||||
*,
|
||||
requires_moderator: int | None = None,
|
||||
cooldown: int | None = None,
|
||||
) -> None:
|
||||
"""Update a command's settings in the database and re-register it.
|
||||
|
||||
Uses the provided *command_row* for current settings, applies any
|
||||
overrides, writes them back, and re-registers the command so the
|
||||
in-memory registry matches the database.
|
||||
|
||||
:param ctx: The command context.
|
||||
:param command_row: The command row (from ``resolve_command_name``).
|
||||
:param requires_moderator: New value, or None to keep the current one.
|
||||
:param cooldown: New value, or None to keep the current one.
|
||||
"""
|
||||
name: str = command_row["name"]
|
||||
final_mod = (
|
||||
requires_moderator
|
||||
if requires_moderator is not None
|
||||
else command_row["requires_moderator"]
|
||||
)
|
||||
final_cooldown = cooldown if cooldown is not None else command_row["cooldown"]
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE commands SET requires_moderator = ?, cooldown = ?, updated_at = ? "
|
||||
"WHERE name = ?",
|
||||
(final_mod, final_cooldown, now, name),
|
||||
)
|
||||
|
||||
aliases = await get_aliases_for_command(ctx.storage, name)
|
||||
reregister_command(
|
||||
ctx.commands,
|
||||
name,
|
||||
aliases=aliases,
|
||||
requires_moderator=bool(final_mod),
|
||||
cooldown=final_cooldown,
|
||||
)
|
||||
@@ -1,539 +0,0 @@
|
||||
# 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.
|
||||
|
||||
"""Management commands for custom commands (add, edit, delete, etc.)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from owlbot.api import CommandContext, on_command
|
||||
|
||||
from .handler import (
|
||||
custom_command_handler,
|
||||
get_aliases_for_command,
|
||||
reregister_command,
|
||||
resolve_command_name,
|
||||
update_and_reregister,
|
||||
)
|
||||
from .placeholder_handlers import NAME_RE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
|
||||
def _clean_raw_name(raw_name: str, prefix: str) -> str:
|
||||
"""Strip the command prefix (if present) and lowercase a raw name."""
|
||||
return raw_name.removeprefix(prefix).lower()
|
||||
|
||||
|
||||
async def _resolve_or_error(ctx: CommandContext, raw_name: str) -> aiosqlite.Row | None:
|
||||
"""Resolve a raw argument to a custom command row, or send an error message.
|
||||
|
||||
Cleans the raw name, resolves it via the database, and -- if not found --
|
||||
sends the appropriate error to chat (distinguishing "not a custom command"
|
||||
from "does not exist").
|
||||
|
||||
:param ctx: The command context.
|
||||
:param raw_name: The raw argument from the user (may include prefix).
|
||||
:return: The command row (name, requires_moderator, cooldown), or None
|
||||
if resolution failed.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
input_name = _clean_raw_name(raw_name, prefix)
|
||||
row = await resolve_command_name(ctx.storage, input_name)
|
||||
if row is not None:
|
||||
return row
|
||||
if ctx.commands.exists(input_name):
|
||||
await ctx.owncast_client.send_message(
|
||||
"That command is not a custom command and cannot be modified."
|
||||
)
|
||||
else:
|
||||
await ctx.owncast_client.send_message("That command does not exist.")
|
||||
return None
|
||||
|
||||
|
||||
@on_command("addcommand", aliases=["addcmd"], requires_moderator=True)
|
||||
async def addcommand(ctx: CommandContext) -> None:
|
||||
"""Create a new custom command.
|
||||
|
||||
Usage: !addcommand !name response text
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}addcommand {prefix}name response text"
|
||||
)
|
||||
return
|
||||
|
||||
name = _clean_raw_name(args[0], prefix)
|
||||
|
||||
if not NAME_RE.match(name):
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid command name. Only letters, numbers, and underscores are allowed."
|
||||
)
|
||||
return
|
||||
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}addcommand {prefix}name response text"
|
||||
)
|
||||
return
|
||||
response = parts[1]
|
||||
|
||||
if ctx.commands.exists(name):
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Command {prefix}{name} already exists. "
|
||||
f"Use '{prefix}editcommand' to modify it."
|
||||
)
|
||||
return
|
||||
|
||||
# Register immediately after the exists() check -- both are synchronous,
|
||||
# so no other coroutine can interleave.
|
||||
default_cooldown = ctx.config.get("default_cooldown", 5)
|
||||
ctx.commands.register(
|
||||
name=name,
|
||||
handler=custom_command_handler,
|
||||
cooldown=default_cooldown,
|
||||
)
|
||||
|
||||
# Persist to database. If this fails, roll back the in-memory registration.
|
||||
now = datetime.now(UTC).isoformat()
|
||||
try:
|
||||
await ctx.storage.execute(
|
||||
"INSERT INTO commands (name, response, use_count,"
|
||||
" created_at, updated_at, requires_moderator,"
|
||||
" cooldown) VALUES (?, ?, 0, ?, ?, 0, ?)",
|
||||
(name, response, now, now, default_cooldown),
|
||||
)
|
||||
except Exception:
|
||||
ctx.commands.unregister(name)
|
||||
raise
|
||||
|
||||
ctx.logger.info(f"Custom command '{name}' created.")
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{name} created.")
|
||||
|
||||
|
||||
@on_command("editcommand", aliases=["editcmd"], requires_moderator=True)
|
||||
async def editcommand(ctx: CommandContext) -> None:
|
||||
"""Edit an existing custom command's response.
|
||||
|
||||
Usage: !editcommand !name new response
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}editcommand {prefix}name new response"
|
||||
)
|
||||
return
|
||||
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}editcommand {prefix}name new response"
|
||||
)
|
||||
return
|
||||
response = parts[1]
|
||||
|
||||
row = await _resolve_or_error(ctx, args[0])
|
||||
if row is None:
|
||||
return
|
||||
name: str = row["name"]
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE commands SET response = ?, updated_at = ? WHERE name = ?",
|
||||
(response, now, name),
|
||||
)
|
||||
|
||||
ctx.logger.info(f"Custom command '{name}' updated.")
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{name} updated.")
|
||||
|
||||
|
||||
@on_command("deletecommand", aliases=["delcmd"], requires_moderator=True)
|
||||
async def deletecommand(ctx: CommandContext) -> None:
|
||||
"""Delete a custom command.
|
||||
|
||||
Usage: !deletecommand !name
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}deletecommand {prefix}name"
|
||||
)
|
||||
return
|
||||
|
||||
input_name = _clean_raw_name(args[0], prefix)
|
||||
row = await _resolve_or_error(ctx, args[0])
|
||||
if row is None:
|
||||
return
|
||||
name: str = row["name"]
|
||||
|
||||
if name != input_name:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"{prefix}{input_name} is an alias of {prefix}{name}. "
|
||||
f"Use {prefix}removealias {prefix}{input_name} to remove the alias, "
|
||||
f"or {prefix}deletecommand {prefix}{name} to delete the command."
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.storage.execute("DELETE FROM commands WHERE name = ?", (name,))
|
||||
|
||||
ctx.commands.unregister(name)
|
||||
|
||||
ctx.logger.info(f"Custom command '{name}' deleted.")
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{name} deleted.")
|
||||
|
||||
|
||||
@on_command("commandmodonly", aliases=["cmdmodonly"], requires_moderator=True)
|
||||
async def commandmodonly(ctx: CommandContext) -> None:
|
||||
"""Toggle moderator-only access for a custom command.
|
||||
|
||||
Usage: !commandmodonly !name <on|off>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}commandmodonly {prefix}name <on|off>"
|
||||
)
|
||||
return
|
||||
|
||||
setting = args[1].lower()
|
||||
|
||||
if setting not in ("on", "off"):
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid setting. Expected 'on' or 'off'."
|
||||
)
|
||||
return
|
||||
|
||||
requires_moderator = 1 if setting == "on" else 0
|
||||
|
||||
row = await _resolve_or_error(ctx, args[0])
|
||||
if row is None:
|
||||
return
|
||||
name: str = row["name"]
|
||||
|
||||
await update_and_reregister(ctx, row, requires_moderator=requires_moderator)
|
||||
|
||||
status = "moderator-only" if requires_moderator else "public"
|
||||
ctx.logger.info(f"Custom command '{name}' set to {status}.")
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{name} is now {status}.")
|
||||
|
||||
|
||||
@on_command("resetcommand", aliases=["resetcmd"], requires_moderator=True)
|
||||
async def resetcommand(ctx: CommandContext) -> None:
|
||||
"""Reset a custom command's use counter to 0.
|
||||
|
||||
Usage: !resetcommand !name
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}resetcommand {prefix}name"
|
||||
)
|
||||
return
|
||||
|
||||
row = await _resolve_or_error(ctx, args[0])
|
||||
if row is None:
|
||||
return
|
||||
name: str = row["name"]
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE commands SET use_count = 0, updated_at = ? WHERE name = ?",
|
||||
(now, name),
|
||||
)
|
||||
|
||||
ctx.logger.info(f"Custom command '{name}' counter reset to 0.")
|
||||
await ctx.owncast_client.send_message(f"Command {prefix}{name} counter reset.")
|
||||
|
||||
|
||||
@on_command("editcounter", aliases=["editcount"], requires_moderator=True)
|
||||
async def editcounter(ctx: CommandContext) -> None:
|
||||
"""Set, increment, or decrement a named counter.
|
||||
|
||||
Usage: !editcounter <name> <value>
|
||||
|
||||
The value can be an absolute number (e.g. ``15``), or a relative
|
||||
modifier prefixed with ``+`` or ``-`` (e.g. ``+1``, ``-3``).
|
||||
If the counter does not exist it is created on the fly.
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if len(args) != 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}editcounter name <value>"
|
||||
)
|
||||
return
|
||||
|
||||
counter_name = args[0].lower()
|
||||
|
||||
if not NAME_RE.match(counter_name):
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid counter name. Only letters, numbers, and underscores are allowed."
|
||||
)
|
||||
return
|
||||
|
||||
value_str = args[1]
|
||||
|
||||
try:
|
||||
value = int(value_str)
|
||||
except ValueError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid value. Must be an integer (e.g. 15, +1, -3)."
|
||||
)
|
||||
return
|
||||
|
||||
# Fetch old value (default 0 if counter doesn't exist yet).
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT value FROM counters WHERE name = ?", (counter_name,)
|
||||
)
|
||||
old_value = row["value"] if row else 0
|
||||
|
||||
# Leading +/- means relative delta; otherwise absolute.
|
||||
new_value = old_value + value if value_str[0] in ("+", "-") else value
|
||||
|
||||
await ctx.storage.execute(
|
||||
"INSERT INTO counters (name, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(name) DO UPDATE SET value = ?",
|
||||
(counter_name, new_value, new_value),
|
||||
)
|
||||
|
||||
ctx.logger.info(
|
||||
f"Counter '{counter_name}' changed from {old_value} to {new_value}."
|
||||
)
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Changed the {counter_name} counter from {old_value} to {new_value}."
|
||||
)
|
||||
|
||||
|
||||
@on_command("commandcooldown", aliases=["cmdcooldown"], requires_moderator=True)
|
||||
async def commandcooldown(ctx: CommandContext) -> None:
|
||||
"""Set or disable a custom command's cooldown.
|
||||
|
||||
Usage: !commandcooldown !name <seconds>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}commandcooldown {prefix}name <seconds>"
|
||||
)
|
||||
return
|
||||
|
||||
raw_seconds = args[1]
|
||||
|
||||
try:
|
||||
seconds = int(raw_seconds)
|
||||
except ValueError:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid cooldown value. Must be a non-negative integer."
|
||||
)
|
||||
return
|
||||
|
||||
if seconds < 0:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Cooldown must be a non-negative integer (0 to disable)."
|
||||
)
|
||||
return
|
||||
|
||||
row = await _resolve_or_error(ctx, args[0])
|
||||
if row is None:
|
||||
return
|
||||
name: str = row["name"]
|
||||
|
||||
await update_and_reregister(ctx, row, cooldown=seconds)
|
||||
|
||||
if seconds == 0:
|
||||
ctx.logger.info(f"Custom command '{name}' cooldown disabled.")
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Command {prefix}{name} cooldown disabled."
|
||||
)
|
||||
else:
|
||||
ctx.logger.info(f"Custom command '{name}' cooldown set to {seconds}s.")
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Command {prefix}{name} cooldown set to {seconds}s."
|
||||
)
|
||||
|
||||
|
||||
@on_command("addalias", requires_moderator=True)
|
||||
async def addalias(ctx: CommandContext) -> None:
|
||||
"""Add an alias to an existing custom command.
|
||||
|
||||
Usage: !addalias !command !alias
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}addalias {prefix}command {prefix}alias"
|
||||
)
|
||||
return
|
||||
|
||||
alias = _clean_raw_name(args[1], prefix)
|
||||
|
||||
if not NAME_RE.match(alias):
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid alias name. Only letters, numbers, and underscores are allowed."
|
||||
)
|
||||
return
|
||||
|
||||
command_row = await _resolve_or_error(ctx, args[0])
|
||||
if command_row is None:
|
||||
return
|
||||
name: str = command_row["name"]
|
||||
|
||||
if alias == name:
|
||||
await ctx.owncast_client.send_message(
|
||||
"An alias cannot be the same as the command name."
|
||||
)
|
||||
return
|
||||
|
||||
existing_alias = await ctx.storage.fetch_one(
|
||||
"SELECT alias, command_name FROM command_aliases WHERE alias = ?",
|
||||
(alias,),
|
||||
)
|
||||
if existing_alias:
|
||||
if existing_alias["command_name"] == name:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} is already assigned to {prefix}{name}."
|
||||
)
|
||||
else:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} is already assigned to "
|
||||
f"{prefix}{existing_alias['command_name']}."
|
||||
)
|
||||
return
|
||||
|
||||
if ctx.commands.exists(alias):
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} conflicts with an existing command."
|
||||
)
|
||||
return
|
||||
|
||||
current_aliases = await get_aliases_for_command(ctx.storage, name)
|
||||
|
||||
await ctx.storage.execute(
|
||||
"INSERT INTO command_aliases (alias, command_name) VALUES (?, ?)",
|
||||
(alias, name),
|
||||
)
|
||||
|
||||
updated_aliases = [*current_aliases, alias]
|
||||
reregister_command(
|
||||
ctx.commands,
|
||||
name,
|
||||
aliases=updated_aliases,
|
||||
requires_moderator=bool(command_row["requires_moderator"]),
|
||||
cooldown=command_row["cooldown"],
|
||||
)
|
||||
|
||||
ctx.logger.info(f"Alias '{alias}' added to custom command '{name}'.")
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} added to {prefix}{name}."
|
||||
)
|
||||
|
||||
|
||||
@on_command("removealias", requires_moderator=True)
|
||||
async def removealias(ctx: CommandContext) -> None:
|
||||
"""Remove an alias from a custom command.
|
||||
|
||||
Usage: !removealias !alias
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
prefix = ctx.commands.prefix
|
||||
args = ctx.args_list
|
||||
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Usage: {prefix}removealias {prefix}alias"
|
||||
)
|
||||
return
|
||||
|
||||
alias = _clean_raw_name(args[0], prefix)
|
||||
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT a.command_name, c.requires_moderator, c.cooldown "
|
||||
"FROM command_aliases a "
|
||||
"JOIN commands c ON c.name = a.command_name "
|
||||
"WHERE a.alias = ?",
|
||||
(alias,),
|
||||
)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Alias {prefix}{alias} does not exist.")
|
||||
return
|
||||
|
||||
command_name: str = row["command_name"]
|
||||
|
||||
await ctx.storage.execute("DELETE FROM command_aliases WHERE alias = ?", (alias,))
|
||||
|
||||
remaining_aliases = await get_aliases_for_command(ctx.storage, command_name)
|
||||
reregister_command(
|
||||
ctx.commands,
|
||||
command_name,
|
||||
aliases=remaining_aliases,
|
||||
requires_moderator=bool(row["requires_moderator"]),
|
||||
cooldown=row["cooldown"],
|
||||
)
|
||||
|
||||
ctx.logger.info(f"Alias '{alias}' removed from custom command '{command_name}'.")
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Alias {prefix}{alias} removed from {prefix}{command_name}."
|
||||
)
|
||||
|
||||
|
||||
@on_command("listcommands", aliases=["listcmds"], cooldown=15)
|
||||
async def listcommands(ctx: CommandContext) -> None:
|
||||
"""List all custom commands.
|
||||
|
||||
Sends a URL to the command list web page.
|
||||
Has a 15-second cooldown to prevent spam.
|
||||
|
||||
Usage: !listcommands
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
url = ctx.routes.url_for("/list")
|
||||
await ctx.owncast_client.send_message(f"Custom commands: {url}")
|
||||
@@ -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)
|
||||
@@ -24,11 +24,12 @@ the entire response, discarding any other template content.
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .types import NAME_RE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .placeholders import PlaceholderContext
|
||||
|
||||
@@ -250,9 +251,6 @@ TIMEZONE_OFFSETS: dict[str, float] = {
|
||||
"YEKT": 5,
|
||||
}
|
||||
|
||||
# Pattern for validating identifiers (command names, counter names, aliases).
|
||||
NAME_RE = re.compile(r"^[a-z0-9_]+$")
|
||||
|
||||
|
||||
def _parse_placeholder_date(date_str: str) -> datetime | None:
|
||||
"""Parse a placeholder date string.
|
||||
@@ -341,7 +339,7 @@ async def _evaluate_count(
|
||||
"""``$(count)`` / ``$(count name [mod])`` -- use count or named counter."""
|
||||
if not args:
|
||||
# No arguments: return the command's use_count (backward compatible).
|
||||
return str(ctx.use_count)
|
||||
return str(ctx.command.use_count)
|
||||
|
||||
# Named counter.
|
||||
counter_name = args[0].lower()
|
||||
@@ -357,30 +355,16 @@ async def _evaluate_count(
|
||||
"Invalid $(count): too many arguments, expected $(count name [modifier])"
|
||||
)
|
||||
|
||||
if modifier_str[0] in ("+", "-"):
|
||||
try:
|
||||
delta = int(modifier_str)
|
||||
except ValueError as e:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
||||
) from e
|
||||
result = await ctx.storage.fetch_value(
|
||||
"INSERT INTO counters (name, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(name) DO UPDATE SET value = value + ? "
|
||||
"RETURNING value",
|
||||
(counter_name, delta, delta),
|
||||
)
|
||||
return str(result)
|
||||
try:
|
||||
value = int(modifier_str)
|
||||
except ValueError as e:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
||||
) from e
|
||||
result = await ctx.storage.fetch_value(
|
||||
"INSERT OR REPLACE INTO counters (name, value) VALUES (?, ?) RETURNING value",
|
||||
(counter_name, value),
|
||||
)
|
||||
if modifier_str[0] in ("+", "-"):
|
||||
result = await ctx.counters.adjust_counter(counter_name, value)
|
||||
else:
|
||||
result = await ctx.counters.set_counter(counter_name, value)
|
||||
return str(result)
|
||||
|
||||
|
||||
@@ -402,10 +386,8 @@ async def _evaluate_getcount(
|
||||
"Invalid $(getcount): counter name may only contain "
|
||||
"letters, numbers, and underscores"
|
||||
)
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT value FROM counters WHERE name = ?", (counter_name,)
|
||||
)
|
||||
return str(row["value"]) if row else "0"
|
||||
result = await ctx.counters.get_counter(counter_name)
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _evaluate_rand(
|
||||
|
||||
@@ -46,7 +46,7 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
from .types import Command, CounterAccessor
|
||||
|
||||
from .placeholder_handlers import HANDLERS, PlaceholderError
|
||||
|
||||
@@ -88,14 +88,14 @@ class PlaceholderContext:
|
||||
|
||||
:param args_list: Command arguments (``$(1)``-``$(9)`` values).
|
||||
:param user_display_name: Display name of the invoking user.
|
||||
:param use_count: The command's current use count.
|
||||
:param storage: Module storage for database access.
|
||||
:param command: Snapshot of the command being executed.
|
||||
:param counters: Counter accessor for named counter operations.
|
||||
"""
|
||||
|
||||
args_list: list[str]
|
||||
user_display_name: str
|
||||
use_count: int
|
||||
storage: ModuleStorage
|
||||
command: Command
|
||||
counters: CounterAccessor
|
||||
|
||||
|
||||
# The parser uses recursive descent to convert a template string into a list
|
||||
@@ -360,8 +360,8 @@ async def process_placeholders(
|
||||
template: str,
|
||||
args_list: list[str],
|
||||
user_display_name: str,
|
||||
use_count: int,
|
||||
storage: ModuleStorage,
|
||||
command: Command,
|
||||
counters: CounterAccessor,
|
||||
max_depth: int = DEFAULT_MAX_DEPTH,
|
||||
) -> str:
|
||||
"""Replace placeholders in a response template.
|
||||
@@ -375,8 +375,8 @@ async def process_placeholders(
|
||||
:param template: The response template with placeholders.
|
||||
:param args_list: List of arguments passed to the command.
|
||||
:param user_display_name: The executing user's display name.
|
||||
:param use_count: The command's current use count.
|
||||
:param storage: Module storage for database access.
|
||||
:param command: Snapshot of the command being executed.
|
||||
:param counters: Counter accessor for named counter operations.
|
||||
:param max_depth: Maximum nesting depth for placeholders.
|
||||
:return: The processed response string, or the error message on failure.
|
||||
"""
|
||||
@@ -384,8 +384,8 @@ async def process_placeholders(
|
||||
ctx = PlaceholderContext(
|
||||
args_list=args_list,
|
||||
user_display_name=user_display_name,
|
||||
use_count=use_count,
|
||||
storage=storage,
|
||||
command=command,
|
||||
counters=counters,
|
||||
)
|
||||
try:
|
||||
return await _evaluate(nodes, ctx)
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
# 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.
|
||||
|
||||
"""Persistence layer for custom command, alias, and counter records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .types import (
|
||||
AliasAlreadyExistsError,
|
||||
Command,
|
||||
CommandNotFoundError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
|
||||
|
||||
def _command_from_row(row: aiosqlite.Row) -> Command:
|
||||
"""Build a Command snapshot from a row with alias_list."""
|
||||
alias_str: str | None = row["alias_list"]
|
||||
aliases = frozenset(alias_str.split(",")) if alias_str else frozenset()
|
||||
return Command.from_row(row, aliases)
|
||||
|
||||
|
||||
class CommandRepository:
|
||||
"""Handles all database operations for custom commands."""
|
||||
|
||||
def __init__(self, storage: ModuleStorage) -> None:
|
||||
"""Initialize with a module storage instance.
|
||||
|
||||
:param storage: The module's storage backend.
|
||||
"""
|
||||
self._storage = storage
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Create tables if they do not exist."""
|
||||
await self._storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS commands (
|
||||
name TEXT PRIMARY KEY NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
use_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
requires_moderator INTEGER DEFAULT 0,
|
||||
cooldown INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
await self._storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS command_aliases (
|
||||
alias TEXT PRIMARY KEY NOT NULL,
|
||||
command_name TEXT NOT NULL,
|
||||
FOREIGN KEY (command_name)
|
||||
REFERENCES commands(name) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
await self._storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
name TEXT PRIMARY KEY NOT NULL,
|
||||
value INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
async def create(self, name: str, response: str, cooldown: int) -> Command:
|
||||
"""Insert a new command and return its snapshot.
|
||||
|
||||
:param name: Command name.
|
||||
:param response: Response template.
|
||||
:param cooldown: Cooldown in seconds.
|
||||
:return: Snapshot of the newly created command.
|
||||
"""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
row = await self._storage.fetch_one(
|
||||
"INSERT INTO commands "
|
||||
"(name, response, use_count, created_at, updated_at, "
|
||||
"requires_moderator, cooldown) "
|
||||
"VALUES (?, ?, 0, ?, ?, 0, ?) RETURNING *",
|
||||
(name, response, now, now, cooldown),
|
||||
)
|
||||
if row is None:
|
||||
raise RuntimeError("INSERT RETURNING did not produce a row")
|
||||
return Command.from_row(row)
|
||||
|
||||
async def get(self, name: str) -> Command:
|
||||
"""Fetch a command by name or alias.
|
||||
|
||||
Resolves name-or-alias and fetches all columns plus aggregated
|
||||
aliases in a single query.
|
||||
|
||||
:param name: Command name or alias.
|
||||
:return: The matching Command (resolved to canonical name).
|
||||
:raises CommandNotFoundError: If no command matches.
|
||||
"""
|
||||
row = await self._storage.fetch_one(
|
||||
"SELECT c.*, GROUP_CONCAT(ca.alias) AS alias_list "
|
||||
"FROM commands c "
|
||||
"LEFT JOIN command_aliases ca ON c.name = ca.command_name "
|
||||
"WHERE c.name = ? OR c.name = ("
|
||||
" SELECT command_name FROM command_aliases WHERE alias = ? LIMIT 1"
|
||||
") "
|
||||
"GROUP BY c.name LIMIT 1",
|
||||
(name, name),
|
||||
)
|
||||
if row is None:
|
||||
raise CommandNotFoundError(name)
|
||||
alias_str: str | None = row["alias_list"]
|
||||
aliases = frozenset(alias_str.split(",")) if alias_str else frozenset()
|
||||
return Command.from_row(row, aliases)
|
||||
|
||||
async def delete(self, name: str) -> Command:
|
||||
"""Delete a command and return its snapshot.
|
||||
|
||||
:param name: Canonical command name.
|
||||
:return: Snapshot of the deleted command (with empty aliases).
|
||||
:raises CommandNotFoundError: If no command matches.
|
||||
"""
|
||||
row = await self._storage.fetch_one(
|
||||
"DELETE FROM commands WHERE name = ? RETURNING *", (name,)
|
||||
)
|
||||
if row is None:
|
||||
raise CommandNotFoundError(name)
|
||||
return Command.from_row(row)
|
||||
|
||||
async def update_response(self, name: str, response: str) -> Command:
|
||||
"""Update a command's response template.
|
||||
|
||||
:param name: Canonical command name.
|
||||
:param response: New response template.
|
||||
:return: Updated command snapshot.
|
||||
:raises CommandNotFoundError: If no command matches.
|
||||
"""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
row = await self._storage.fetch_one(
|
||||
"UPDATE commands SET response = ?, updated_at = ? "
|
||||
"WHERE name = ? "
|
||||
"RETURNING *, ("
|
||||
" SELECT GROUP_CONCAT(alias) FROM command_aliases"
|
||||
" WHERE command_name = commands.name"
|
||||
") AS alias_list",
|
||||
(response, now, name),
|
||||
)
|
||||
if row is None:
|
||||
raise CommandNotFoundError(name)
|
||||
return _command_from_row(row)
|
||||
|
||||
async def update_moderator_flag(self, name: str, *, enabled: bool) -> Command:
|
||||
"""Update a command's moderator-only access flag.
|
||||
|
||||
:param name: Canonical command name.
|
||||
:param enabled: True for moderator-only, False for public.
|
||||
:return: Updated command snapshot.
|
||||
:raises CommandNotFoundError: If no command matches.
|
||||
"""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
row = await self._storage.fetch_one(
|
||||
"UPDATE commands SET requires_moderator = ?, updated_at = ? "
|
||||
"WHERE name = ? "
|
||||
"RETURNING *, ("
|
||||
" SELECT GROUP_CONCAT(alias) FROM command_aliases"
|
||||
" WHERE command_name = commands.name"
|
||||
") AS alias_list",
|
||||
(int(enabled), now, name),
|
||||
)
|
||||
if row is None:
|
||||
raise CommandNotFoundError(name)
|
||||
return _command_from_row(row)
|
||||
|
||||
async def update_cooldown(self, name: str, seconds: int) -> Command:
|
||||
"""Update a command's cooldown duration.
|
||||
|
||||
:param name: Canonical command name.
|
||||
:param seconds: Cooldown in seconds.
|
||||
:return: Updated command snapshot.
|
||||
:raises CommandNotFoundError: If no command matches.
|
||||
"""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
row = await self._storage.fetch_one(
|
||||
"UPDATE commands SET cooldown = ?, updated_at = ? "
|
||||
"WHERE name = ? "
|
||||
"RETURNING *, ("
|
||||
" SELECT GROUP_CONCAT(alias) FROM command_aliases"
|
||||
" WHERE command_name = commands.name"
|
||||
") AS alias_list",
|
||||
(seconds, now, name),
|
||||
)
|
||||
if row is None:
|
||||
raise CommandNotFoundError(name)
|
||||
return _command_from_row(row)
|
||||
|
||||
async def reset_use_count(self, name: str) -> Command:
|
||||
"""Reset a command's use count to zero.
|
||||
|
||||
:param name: Canonical command name.
|
||||
:return: Updated command snapshot.
|
||||
:raises CommandNotFoundError: If no command matches.
|
||||
"""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
row = await self._storage.fetch_one(
|
||||
"UPDATE commands SET use_count = 0, updated_at = ? "
|
||||
"WHERE name = ? "
|
||||
"RETURNING *, ("
|
||||
" SELECT GROUP_CONCAT(alias) FROM command_aliases"
|
||||
" WHERE command_name = commands.name"
|
||||
") AS alias_list",
|
||||
(now, name),
|
||||
)
|
||||
if row is None:
|
||||
raise CommandNotFoundError(name)
|
||||
return _command_from_row(row)
|
||||
|
||||
async def increment_use_count(self, name: str) -> Command:
|
||||
"""Increment a command's use count by one.
|
||||
|
||||
:param name: Canonical command name.
|
||||
:return: Updated command snapshot with new use_count.
|
||||
:raises CommandNotFoundError: If no command matches.
|
||||
"""
|
||||
row = await self._storage.fetch_one(
|
||||
"UPDATE commands SET use_count = use_count + 1 "
|
||||
"WHERE name = ? "
|
||||
"RETURNING *, ("
|
||||
" SELECT GROUP_CONCAT(alias) FROM command_aliases"
|
||||
" WHERE command_name = commands.name"
|
||||
") AS alias_list",
|
||||
(name,),
|
||||
)
|
||||
if row is None:
|
||||
raise CommandNotFoundError(name)
|
||||
return _command_from_row(row)
|
||||
|
||||
async def list_all(self) -> list[Command]:
|
||||
"""Return all commands ordered by name, with aliases populated.
|
||||
|
||||
:return: List of Command snapshots.
|
||||
"""
|
||||
rows = await self._storage.fetch_all(
|
||||
"SELECT c.*, GROUP_CONCAT(ca.alias) AS alias_list "
|
||||
"FROM commands c "
|
||||
"LEFT JOIN command_aliases ca ON c.name = ca.command_name "
|
||||
"GROUP BY c.name ORDER BY c.name"
|
||||
)
|
||||
result: list[Command] = []
|
||||
for row in rows:
|
||||
alias_str: str | None = row["alias_list"]
|
||||
aliases = frozenset(alias_str.split(",")) if alias_str else frozenset()
|
||||
result.append(Command.from_row(row, aliases))
|
||||
return result
|
||||
|
||||
async def add_alias(self, command_name: str, alias: str) -> Command:
|
||||
"""Add an alias to a command.
|
||||
|
||||
:param command_name: Canonical command name.
|
||||
:param alias: Alias to add.
|
||||
:return: Refreshed command snapshot with updated aliases.
|
||||
:raises AliasAlreadyExistsError: If alias is already taken.
|
||||
"""
|
||||
existing = await self._storage.fetch_one(
|
||||
"SELECT alias, command_name FROM command_aliases WHERE alias = ?",
|
||||
(alias,),
|
||||
)
|
||||
if existing:
|
||||
raise AliasAlreadyExistsError(alias, existing["command_name"])
|
||||
await self._storage.execute(
|
||||
"INSERT INTO command_aliases (alias, command_name) VALUES (?, ?)",
|
||||
(alias, command_name),
|
||||
)
|
||||
return await self.get(command_name)
|
||||
|
||||
async def remove_alias(self, alias: str) -> tuple[str, Command]:
|
||||
"""Remove an alias and return its owner.
|
||||
|
||||
:param alias: Alias to remove.
|
||||
:return: Tuple of (removed alias, refreshed owner command).
|
||||
:raises CommandNotFoundError: If alias does not exist.
|
||||
"""
|
||||
row = await self._storage.fetch_one(
|
||||
"DELETE FROM command_aliases WHERE alias = ? RETURNING command_name",
|
||||
(alias,),
|
||||
)
|
||||
if row is None:
|
||||
raise CommandNotFoundError(alias)
|
||||
cmd = await self.get(row["command_name"])
|
||||
return alias, cmd
|
||||
|
||||
async def get_counter(self, name: str) -> int:
|
||||
"""Get a counter's value, defaulting to 0 if it does not exist.
|
||||
|
||||
:param name: Counter name.
|
||||
:return: Current value.
|
||||
"""
|
||||
row = await self._storage.fetch_one(
|
||||
"SELECT value FROM counters WHERE name = ?", (name,)
|
||||
)
|
||||
return row["value"] if row else 0
|
||||
|
||||
async def set_counter(self, name: str, value: int) -> int:
|
||||
"""Set a counter to an absolute value (upsert).
|
||||
|
||||
:param name: Counter name.
|
||||
:param value: Absolute value to set.
|
||||
:return: The new value.
|
||||
"""
|
||||
result = await self._storage.fetch_value(
|
||||
"INSERT INTO counters (name, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(name) DO UPDATE SET value = ? RETURNING value",
|
||||
(name, value, value),
|
||||
)
|
||||
if result is None:
|
||||
raise RuntimeError("UPSERT RETURNING did not produce a value")
|
||||
return int(result)
|
||||
|
||||
async def update_counter(self, name: str, delta: int) -> int:
|
||||
"""Adjust a counter by a relative delta (upsert).
|
||||
|
||||
:param name: Counter name.
|
||||
:param delta: Amount to add (can be negative).
|
||||
:return: The new value.
|
||||
"""
|
||||
result = await self._storage.fetch_value(
|
||||
"INSERT INTO counters (name, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(name) DO UPDATE SET value = value + ? "
|
||||
"RETURNING value",
|
||||
(name, delta, delta),
|
||||
)
|
||||
if result is None:
|
||||
raise RuntimeError("UPSERT RETURNING did not produce a value")
|
||||
return int(result)
|
||||
@@ -18,41 +18,18 @@ from aiohttp import web
|
||||
|
||||
from owlbot.api import RouteContext, on_route
|
||||
|
||||
from .manager import get_manager
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Columns: Command, Aliases, Response, Cooldown, Permissions.
|
||||
Accessible at /owlbot/custom_commands/list.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: HTML response with the command list table.
|
||||
"""
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT c.name, c.response, c.requires_moderator, c.cooldown, "
|
||||
"GROUP_CONCAT(ca.alias, ', ') AS aliases "
|
||||
"FROM commands c "
|
||||
"LEFT JOIN command_aliases ca ON c.name = ca.command_name "
|
||||
"GROUP BY c.name "
|
||||
"ORDER BY c.name"
|
||||
commands = await get_manager(ctx.module).list_commands()
|
||||
page = ctx.templates.render(
|
||||
"list.html", commands=commands, prefix=ctx.commands.prefix
|
||||
)
|
||||
|
||||
prefix = ctx.commands.prefix
|
||||
commands = [
|
||||
{
|
||||
"name": row["name"],
|
||||
"aliases": (
|
||||
", ".join(f"{prefix}{a}" for a in row["aliases"].split(", "))
|
||||
if row["aliases"]
|
||||
else "None"
|
||||
),
|
||||
"response": row["response"],
|
||||
"permissions": "Moderator" if row["requires_moderator"] else "Everyone",
|
||||
"cooldown": "None" if row["cooldown"] == 0 else f"{row['cooldown']}s",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
page = ctx.templates.render("list.html", commands=commands, prefix=prefix)
|
||||
return web.Response(text=page, content_type="text/html")
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
{% for cmd in commands %}
|
||||
<tr>
|
||||
<td>{{ prefix }}{{ cmd.name }}</td>
|
||||
<td>{{ cmd.aliases }}</td>
|
||||
<td>{% if cmd.aliases %}{% for a in cmd.aliases | sort %}{{ prefix }}{{ a }}{% if not loop.last %}, {% endif %}{% endfor %}{% else %}None{% endif %}</td>
|
||||
<td>{{ cmd.response }}</td>
|
||||
<td class="text-center">{{ cmd.cooldown }}</td>
|
||||
<td class="text-center">{{ cmd.permissions }}</td>
|
||||
<td class="text-center">{% if cmd.cooldown == 0 %}None{% else %}{{ cmd.cooldown }}s{% endif %}</td>
|
||||
<td class="text-center">{% if cmd.requires_moderator %}Moderator{% else %}Everyone{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# 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.
|
||||
|
||||
"""Data containers and domain errors for the custom_commands module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
NAME_RE = re.compile(r"^[a-z0-9_]+$")
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class Command:
|
||||
"""Immutable snapshot of a custom command record."""
|
||||
|
||||
name: str
|
||||
response: str
|
||||
use_count: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
requires_moderator: bool
|
||||
cooldown: int
|
||||
aliases: frozenset[str] = dataclasses.field(default_factory=frozenset)
|
||||
|
||||
@classmethod
|
||||
def from_row(
|
||||
cls,
|
||||
row: aiosqlite.Row,
|
||||
aliases: frozenset[str] = frozenset(),
|
||||
) -> Command:
|
||||
"""Build a Command from a database row.
|
||||
|
||||
:param row: A row from the commands table.
|
||||
:param aliases: Aliases for this command (populated via JOIN).
|
||||
:return: A Command snapshot.
|
||||
"""
|
||||
return cls(
|
||||
name=row["name"],
|
||||
response=row["response"],
|
||||
use_count=row["use_count"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
requires_moderator=bool(row["requires_moderator"]),
|
||||
cooldown=row["cooldown"],
|
||||
aliases=aliases,
|
||||
)
|
||||
|
||||
|
||||
class CounterAccessor(Protocol):
|
||||
"""Interface for counter read/write operations.
|
||||
|
||||
Used by the placeholder system to access counters without coupling
|
||||
to the repository directly.
|
||||
"""
|
||||
|
||||
async def get_counter(self, name: str) -> int:
|
||||
"""Get a counter's value (0 if not found)."""
|
||||
...
|
||||
|
||||
async def set_counter(self, name: str, value: int) -> int:
|
||||
"""Set a counter to an absolute value.
|
||||
|
||||
:param name: Counter name.
|
||||
:param value: Absolute value to set.
|
||||
"""
|
||||
...
|
||||
|
||||
async def adjust_counter(self, name: str, delta: int) -> int:
|
||||
"""Adjust a counter by a relative delta.
|
||||
|
||||
:param name: Counter name.
|
||||
:param delta: Amount to add (can be negative).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class CommandError(Exception):
|
||||
"""Base class for custom command domain errors."""
|
||||
|
||||
|
||||
class CommandNotFoundError(CommandError):
|
||||
"""No command matches the given name or alias."""
|
||||
|
||||
def __init__(self, identifier: str) -> None:
|
||||
"""Initialize with the identifier that was not found.
|
||||
|
||||
:param identifier: The name or alias that was not found.
|
||||
"""
|
||||
self.identifier = identifier
|
||||
super().__init__(f"command not found: {identifier}")
|
||||
|
||||
|
||||
class InvalidNameError(CommandError):
|
||||
"""Name does not match the allowed pattern."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialize with the invalid name.
|
||||
|
||||
:param name: The name that failed validation.
|
||||
"""
|
||||
self.name = name
|
||||
super().__init__(f"invalid name: {name}")
|
||||
|
||||
|
||||
class CommandAlreadyExistsError(CommandError):
|
||||
"""A command with this name or trigger already exists."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialize with the conflicting name.
|
||||
|
||||
:param name: The name that already exists.
|
||||
"""
|
||||
self.name = name
|
||||
super().__init__(f"command already exists: {name}")
|
||||
|
||||
|
||||
class NotCustomCommandError(CommandError):
|
||||
"""The command exists but is not a custom command."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialize with the non-custom command name.
|
||||
|
||||
:param name: The name of the built-in command.
|
||||
"""
|
||||
self.name = name
|
||||
super().__init__(f"not a custom command: {name}")
|
||||
|
||||
|
||||
class AliasAlreadyExistsError(CommandError):
|
||||
"""The alias is already assigned to a command."""
|
||||
|
||||
def __init__(self, alias: str, owner: str) -> None:
|
||||
"""Initialize with the conflicting alias and its owner.
|
||||
|
||||
:param alias: The alias that already exists.
|
||||
:param owner: The command that owns the alias.
|
||||
"""
|
||||
self.alias = alias
|
||||
self.owner = owner
|
||||
super().__init__(f"alias already exists: {alias} (owned by {owner})")
|
||||
|
||||
|
||||
class AliasIsCanonicalNameError(CommandError):
|
||||
"""Cannot alias a command to its own canonical name."""
|
||||
|
||||
def __init__(self, alias: str) -> None:
|
||||
"""Initialize with the alias that matches the canonical name.
|
||||
|
||||
:param alias: The alias that is the same as the command name.
|
||||
"""
|
||||
self.alias = alias
|
||||
super().__init__(f"alias is the canonical name: {alias}")
|
||||
|
||||
|
||||
class CannotDeleteByAliasError(CommandError):
|
||||
"""Cannot delete a command via its alias name."""
|
||||
|
||||
def __init__(self, alias: str, canonical: str) -> None:
|
||||
"""Initialize with the alias and its canonical command name.
|
||||
|
||||
:param alias: The alias that was targeted for deletion.
|
||||
:param canonical: The canonical command name that owns the alias.
|
||||
"""
|
||||
self.alias = alias
|
||||
self.canonical = canonical
|
||||
super().__init__(f"cannot delete alias {alias}, belongs to {canonical}")
|
||||
@@ -0,0 +1,990 @@
|
||||
# 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.
|
||||
|
||||
"""Tests for the custom_commands module: types, repository, and manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from owlbot.api.context import ModuleContext
|
||||
from owlbot.api.storage import ModuleStorage, StorageError
|
||||
from owlbot.builtin_modules.custom_commands.manager import (
|
||||
CommandManager,
|
||||
get_manager,
|
||||
)
|
||||
from owlbot.builtin_modules.custom_commands.repository import CommandRepository
|
||||
from owlbot.builtin_modules.custom_commands.types import (
|
||||
NAME_RE,
|
||||
AliasAlreadyExistsError,
|
||||
AliasIsCanonicalNameError,
|
||||
CannotDeleteByAliasError,
|
||||
Command,
|
||||
CommandAlreadyExistsError,
|
||||
CommandError,
|
||||
CommandNotFoundError,
|
||||
InvalidNameError,
|
||||
NotCustomCommandError,
|
||||
)
|
||||
|
||||
|
||||
class TestCommandFromRow:
|
||||
"""Command.from_row() database row conversion."""
|
||||
|
||||
def test_builds_from_row(self) -> None:
|
||||
"""from_row constructs a Command with correct field values."""
|
||||
row = {
|
||||
"name": "greet",
|
||||
"response": "Hello $(user)!",
|
||||
"use_count": 5,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-02T00:00:00+00:00",
|
||||
"requires_moderator": 0,
|
||||
"cooldown": 10,
|
||||
}
|
||||
cmd = Command.from_row(row) # type: ignore[arg-type]
|
||||
assert cmd.name == "greet"
|
||||
assert cmd.response == "Hello $(user)!"
|
||||
assert cmd.use_count == 5
|
||||
assert cmd.requires_moderator is False
|
||||
assert cmd.cooldown == 10
|
||||
assert cmd.aliases == frozenset()
|
||||
|
||||
def test_builds_with_aliases(self) -> None:
|
||||
"""from_row populates aliases and coerces requires_moderator to bool."""
|
||||
row = {
|
||||
"name": "greet",
|
||||
"response": "Hello!",
|
||||
"use_count": 0,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
"requires_moderator": 1,
|
||||
"cooldown": 0,
|
||||
}
|
||||
aliases = frozenset({"hi", "hey"})
|
||||
cmd = Command.from_row(row, aliases) # type: ignore[arg-type]
|
||||
assert cmd.requires_moderator is True
|
||||
assert cmd.aliases == {"hi", "hey"}
|
||||
|
||||
def test_frozen(self) -> None:
|
||||
"""Command instances are immutable (frozen dataclass)."""
|
||||
row = {
|
||||
"name": "x",
|
||||
"response": "y",
|
||||
"use_count": 0,
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"requires_moderator": 0,
|
||||
"cooldown": 0,
|
||||
}
|
||||
cmd = Command.from_row(row) # type: ignore[arg-type]
|
||||
with pytest.raises(AttributeError):
|
||||
cmd.name = "z" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestCommandErrors:
|
||||
"""Domain error hierarchy and stored context."""
|
||||
|
||||
def test_all_inherit_from_command_error(self) -> None:
|
||||
"""All domain errors are subclasses of CommandError."""
|
||||
assert issubclass(CommandNotFoundError, CommandError)
|
||||
assert issubclass(InvalidNameError, CommandError)
|
||||
assert issubclass(CommandAlreadyExistsError, CommandError)
|
||||
assert issubclass(NotCustomCommandError, CommandError)
|
||||
assert issubclass(AliasAlreadyExistsError, CommandError)
|
||||
assert issubclass(AliasIsCanonicalNameError, CommandError)
|
||||
assert issubclass(CannotDeleteByAliasError, CommandError)
|
||||
|
||||
def test_not_found_stores_identifier(self) -> None:
|
||||
"""CommandNotFoundError stores the identifier and includes it in str."""
|
||||
err = CommandNotFoundError("greet")
|
||||
assert err.identifier == "greet"
|
||||
assert "greet" in str(err)
|
||||
|
||||
def test_invalid_name_stores_name(self) -> None:
|
||||
"""InvalidNameError stores the invalid name."""
|
||||
err = InvalidNameError("BAD NAME!")
|
||||
assert err.name == "BAD NAME!"
|
||||
|
||||
def test_already_exists_stores_name(self) -> None:
|
||||
"""CommandAlreadyExistsError stores the conflicting name."""
|
||||
err = CommandAlreadyExistsError("greet")
|
||||
assert err.name == "greet"
|
||||
|
||||
def test_not_custom_stores_name(self) -> None:
|
||||
"""NotCustomCommandError stores the built-in command name."""
|
||||
err = NotCustomCommandError("about")
|
||||
assert err.name == "about"
|
||||
|
||||
def test_alias_already_exists_stores_context(self) -> None:
|
||||
"""AliasAlreadyExistsError stores alias and owner."""
|
||||
err = AliasAlreadyExistsError("hi", "greet")
|
||||
assert err.alias == "hi"
|
||||
assert err.owner == "greet"
|
||||
|
||||
def test_alias_is_canonical_stores_alias(self) -> None:
|
||||
"""AliasIsCanonicalNameError stores the alias."""
|
||||
err = AliasIsCanonicalNameError("greet")
|
||||
assert err.alias == "greet"
|
||||
|
||||
def test_cannot_delete_alias_stores_context(self) -> None:
|
||||
"""CannotDeleteByAliasError stores alias and canonical name."""
|
||||
err = CannotDeleteByAliasError("hi", "greet")
|
||||
assert err.alias == "hi"
|
||||
assert err.canonical == "greet"
|
||||
|
||||
|
||||
class TestNamePattern:
|
||||
"""NAME_RE validates identifiers."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
pytest.param("greet", id="alpha"),
|
||||
pytest.param("hello_world", id="underscore"),
|
||||
pytest.param("cmd1", id="trailing_digit"),
|
||||
pytest.param("a", id="single_char"),
|
||||
pytest.param("test_123", id="mixed"),
|
||||
],
|
||||
)
|
||||
def test_valid_names(self, name: str) -> None:
|
||||
"""NAME_RE matches valid lowercase alphanumeric identifiers."""
|
||||
assert NAME_RE.match(name) is not None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
pytest.param("", id="empty"),
|
||||
pytest.param("Hello", id="uppercase"),
|
||||
pytest.param("has space", id="space"),
|
||||
pytest.param("special!", id="special_char"),
|
||||
pytest.param("with-dash", id="dash"),
|
||||
pytest.param("UPPER", id="all_upper"),
|
||||
],
|
||||
)
|
||||
def test_invalid_names(self, name: str) -> None:
|
||||
"""NAME_RE rejects names with invalid characters or patterns."""
|
||||
assert NAME_RE.match(name) is None
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
# ---- Repository fixtures ----
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def cmd_storage() -> AsyncIterator[ModuleStorage]:
|
||||
"""Yield an open in-memory ModuleStorage."""
|
||||
async with ModuleStorage(None, "custom_commands") as storage:
|
||||
yield storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def repo(cmd_storage: ModuleStorage) -> CommandRepository:
|
||||
"""CommandRepository backed by cmd_storage, with schema initialized."""
|
||||
r = CommandRepository(cmd_storage)
|
||||
await r.setup()
|
||||
return r
|
||||
|
||||
|
||||
# ---- Repository command tests ----
|
||||
|
||||
|
||||
class TestRepositoryCreate:
|
||||
"""CommandRepository.create() inserts new commands."""
|
||||
|
||||
async def test_create_returns_command(self, repo: CommandRepository) -> None:
|
||||
"""create() returns a Command snapshot with correct fields."""
|
||||
cmd = await repo.create("greet", "Hello!", 5)
|
||||
assert cmd.name == "greet"
|
||||
assert cmd.response == "Hello!"
|
||||
assert cmd.use_count == 0
|
||||
assert cmd.requires_moderator is False
|
||||
assert cmd.cooldown == 5
|
||||
assert cmd.aliases == frozenset()
|
||||
assert cmd.created_at is not None
|
||||
assert cmd.updated_at is not None
|
||||
|
||||
async def test_create_duplicate_raises(self, repo: CommandRepository) -> None:
|
||||
"""create() raises on duplicate command name."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
with pytest.raises(StorageError, match="UNIQUE constraint"):
|
||||
await repo.create("greet", "Hi!", 5)
|
||||
|
||||
|
||||
class TestRepositoryGet:
|
||||
"""CommandRepository.get() fetches by name or alias."""
|
||||
|
||||
async def test_get_by_name(self, repo: CommandRepository) -> None:
|
||||
"""get() retrieves a command by its canonical name."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
cmd = await repo.get("greet")
|
||||
assert cmd.name == "greet"
|
||||
assert cmd.response == "Hello!"
|
||||
|
||||
async def test_get_not_found(self, repo: CommandRepository) -> None:
|
||||
"""get() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError) as exc_info:
|
||||
await repo.get("nonexistent")
|
||||
assert exc_info.value.identifier == "nonexistent"
|
||||
|
||||
|
||||
class TestRepositoryDelete:
|
||||
"""CommandRepository.delete() removes commands."""
|
||||
|
||||
async def test_delete_returns_command(self, repo: CommandRepository) -> None:
|
||||
"""delete() returns the deleted command snapshot."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
deleted = await repo.delete("greet")
|
||||
assert deleted.name == "greet"
|
||||
|
||||
async def test_delete_removes_from_db(self, repo: CommandRepository) -> None:
|
||||
"""delete() makes the command unreachable via get()."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.delete("greet")
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.get("greet")
|
||||
|
||||
async def test_delete_not_found(self, repo: CommandRepository) -> None:
|
||||
"""delete() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.delete("nonexistent")
|
||||
|
||||
|
||||
class TestRepositoryUpdateResponse:
|
||||
"""CommandRepository.update_response() changes a command's response."""
|
||||
|
||||
async def test_update_response(self, repo: CommandRepository) -> None:
|
||||
"""update_response() returns updated Command with new response."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
updated = await repo.update_response("greet", "Hi there!")
|
||||
assert updated.response == "Hi there!"
|
||||
assert updated.name == "greet"
|
||||
|
||||
async def test_update_response_changes_updated_at(
|
||||
self, repo: CommandRepository
|
||||
) -> None:
|
||||
"""update_response() bumps updated_at timestamp."""
|
||||
created = await repo.create("greet", "Hello!", 5)
|
||||
updated = await repo.update_response("greet", "Hi!")
|
||||
assert updated.updated_at != created.updated_at
|
||||
|
||||
async def test_update_response_not_found(self, repo: CommandRepository) -> None:
|
||||
"""update_response() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.update_response("nonexistent", "Hi!")
|
||||
|
||||
|
||||
class TestRepositoryUpdateModeratorFlag:
|
||||
"""CommandRepository.update_moderator_flag() changes the mod-only flag."""
|
||||
|
||||
async def test_enable(self, repo: CommandRepository) -> None:
|
||||
"""update_moderator_flag() sets requires_moderator to True."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
updated = await repo.update_moderator_flag("greet", enabled=True)
|
||||
assert updated.requires_moderator is True
|
||||
assert updated.cooldown == 5
|
||||
|
||||
async def test_disable(self, repo: CommandRepository) -> None:
|
||||
"""update_moderator_flag() sets requires_moderator to False."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.update_moderator_flag("greet", enabled=True)
|
||||
updated = await repo.update_moderator_flag("greet", enabled=False)
|
||||
assert updated.requires_moderator is False
|
||||
|
||||
async def test_not_found(self, repo: CommandRepository) -> None:
|
||||
"""update_moderator_flag() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.update_moderator_flag("nonexistent", enabled=True)
|
||||
|
||||
|
||||
class TestRepositoryUpdateCooldown:
|
||||
"""CommandRepository.update_cooldown() changes the cooldown duration."""
|
||||
|
||||
async def test_update(self, repo: CommandRepository) -> None:
|
||||
"""update_cooldown() sets the cooldown value."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
updated = await repo.update_cooldown("greet", seconds=30)
|
||||
assert updated.cooldown == 30
|
||||
assert updated.requires_moderator is False
|
||||
|
||||
async def test_not_found(self, repo: CommandRepository) -> None:
|
||||
"""update_cooldown() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.update_cooldown("nonexistent", seconds=5)
|
||||
|
||||
|
||||
class TestRepositoryResetUseCount:
|
||||
"""CommandRepository.reset_use_count() zeroes the counter."""
|
||||
|
||||
async def test_reset_use_count(self, repo: CommandRepository) -> None:
|
||||
"""reset_use_count() sets use_count back to 0."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.increment_use_count("greet")
|
||||
await repo.increment_use_count("greet")
|
||||
reset = await repo.reset_use_count("greet")
|
||||
assert reset.use_count == 0
|
||||
|
||||
async def test_reset_not_found(self, repo: CommandRepository) -> None:
|
||||
"""reset_use_count() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.reset_use_count("nonexistent")
|
||||
|
||||
|
||||
class TestRepositoryIncrementUseCount:
|
||||
"""CommandRepository.increment_use_count() bumps the counter."""
|
||||
|
||||
async def test_increment(self, repo: CommandRepository) -> None:
|
||||
"""increment_use_count() increases use_count by 1 each call."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
cmd = await repo.increment_use_count("greet")
|
||||
assert cmd.use_count == 1
|
||||
cmd = await repo.increment_use_count("greet")
|
||||
assert cmd.use_count == 2
|
||||
|
||||
async def test_increment_not_found(self, repo: CommandRepository) -> None:
|
||||
"""increment_use_count() raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.increment_use_count("nonexistent")
|
||||
|
||||
|
||||
class TestRepositoryListAll:
|
||||
"""CommandRepository.list_all() returns all commands."""
|
||||
|
||||
async def test_list_all(self, repo: CommandRepository) -> None:
|
||||
"""list_all() returns commands sorted by name."""
|
||||
await repo.create("bravo", "B", 5)
|
||||
await repo.create("alpha", "A", 5)
|
||||
result = await repo.list_all()
|
||||
assert len(result) == 2
|
||||
assert result[0].name == "alpha"
|
||||
assert result[1].name == "bravo"
|
||||
|
||||
async def test_list_all_empty(self, repo: CommandRepository) -> None:
|
||||
"""list_all() returns empty list when no commands exist."""
|
||||
assert await repo.list_all() == []
|
||||
|
||||
|
||||
# ---- Repository alias tests ----
|
||||
|
||||
|
||||
class TestRepositoryGetByAlias:
|
||||
"""CommandRepository.get() resolves aliases."""
|
||||
|
||||
async def test_get_by_alias(self, repo: CommandRepository) -> None:
|
||||
"""get() resolves an alias to its canonical command."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
cmd = await repo.get("hi")
|
||||
assert cmd.name == "greet"
|
||||
assert "hi" in cmd.aliases
|
||||
|
||||
|
||||
class TestRepositoryAddAlias:
|
||||
"""CommandRepository.add_alias() adds aliases."""
|
||||
|
||||
async def test_add_alias(self, repo: CommandRepository) -> None:
|
||||
"""add_alias() adds an alias and returns refreshed command."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
cmd = await repo.add_alias("greet", "hi")
|
||||
assert "hi" in cmd.aliases
|
||||
|
||||
async def test_add_multiple_aliases(self, repo: CommandRepository) -> None:
|
||||
"""add_alias() accumulates multiple aliases."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
cmd = await repo.add_alias("greet", "hey")
|
||||
assert cmd.aliases == frozenset({"hi", "hey"})
|
||||
|
||||
async def test_add_alias_duplicate(self, repo: CommandRepository) -> None:
|
||||
"""add_alias() raises AliasAlreadyExistsError for duplicate aliases."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
with pytest.raises(AliasAlreadyExistsError) as exc_info:
|
||||
await repo.add_alias("greet", "hi")
|
||||
assert exc_info.value.alias == "hi"
|
||||
assert exc_info.value.owner == "greet"
|
||||
|
||||
|
||||
class TestRepositoryRemoveAlias:
|
||||
"""CommandRepository.remove_alias() removes aliases."""
|
||||
|
||||
async def test_remove_alias(self, repo: CommandRepository) -> None:
|
||||
"""remove_alias() removes the alias and returns refreshed command."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
alias, cmd = await repo.remove_alias("hi")
|
||||
assert alias == "hi"
|
||||
assert "hi" not in cmd.aliases
|
||||
|
||||
async def test_remove_alias_not_found(self, repo: CommandRepository) -> None:
|
||||
"""remove_alias() raises CommandNotFoundError for missing aliases."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.remove_alias("nonexistent")
|
||||
|
||||
|
||||
class TestRepositoryDeleteCascade:
|
||||
"""Deleting a command cascades to its aliases."""
|
||||
|
||||
async def test_delete_cascades_aliases(self, repo: CommandRepository) -> None:
|
||||
"""delete() removes associated aliases via CASCADE."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
await repo.delete("greet")
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await repo.get("hi")
|
||||
|
||||
|
||||
class TestRepositoryListAllWithAliases:
|
||||
"""CommandRepository.list_all() includes aliases."""
|
||||
|
||||
async def test_list_all_includes_aliases(self, repo: CommandRepository) -> None:
|
||||
"""list_all() populates alias sets for each command."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
await repo.add_alias("greet", "hey")
|
||||
result = await repo.list_all()
|
||||
assert len(result) == 1
|
||||
assert result[0].aliases == frozenset({"hi", "hey"})
|
||||
|
||||
|
||||
# ---- Repository counter tests ----
|
||||
|
||||
|
||||
class TestRepositoryGetCounter:
|
||||
"""CommandRepository.get_counter() reads counter values."""
|
||||
|
||||
async def test_get_counter_missing(self, repo: CommandRepository) -> None:
|
||||
"""get_counter() returns 0 for nonexistent counters."""
|
||||
assert await repo.get_counter("deaths") == 0
|
||||
|
||||
async def test_get_counter_existing(self, repo: CommandRepository) -> None:
|
||||
"""get_counter() returns the stored value."""
|
||||
await repo.set_counter("deaths", 10)
|
||||
assert await repo.get_counter("deaths") == 10
|
||||
|
||||
|
||||
class TestRepositorySetCounter:
|
||||
"""CommandRepository.set_counter() sets absolute values."""
|
||||
|
||||
async def test_set_counter_new(self, repo: CommandRepository) -> None:
|
||||
"""set_counter() inserts a new counter with the given value."""
|
||||
result = await repo.set_counter("deaths", 42)
|
||||
assert result == 42
|
||||
assert await repo.get_counter("deaths") == 42
|
||||
|
||||
async def test_set_counter_overwrite(self, repo: CommandRepository) -> None:
|
||||
"""set_counter() overwrites an existing counter."""
|
||||
await repo.set_counter("deaths", 10)
|
||||
result = await repo.set_counter("deaths", 99)
|
||||
assert result == 99
|
||||
|
||||
|
||||
class TestRepositoryUpdateCounter:
|
||||
"""CommandRepository.update_counter() applies relative deltas."""
|
||||
|
||||
async def test_update_counter_new(self, repo: CommandRepository) -> None:
|
||||
"""update_counter() creates a counter if it does not exist."""
|
||||
result = await repo.update_counter("deaths", 5)
|
||||
assert result == 5
|
||||
|
||||
async def test_update_counter_increment(self, repo: CommandRepository) -> None:
|
||||
"""update_counter() adds a positive delta to existing value."""
|
||||
await repo.set_counter("deaths", 10)
|
||||
result = await repo.update_counter("deaths", 3)
|
||||
assert result == 13
|
||||
|
||||
async def test_update_counter_decrement(self, repo: CommandRepository) -> None:
|
||||
"""update_counter() subtracts a negative delta from existing value."""
|
||||
await repo.set_counter("deaths", 10)
|
||||
result = await repo.update_counter("deaths", -3)
|
||||
assert result == 7
|
||||
|
||||
|
||||
# ---- Manager stubs and fixtures ----
|
||||
|
||||
|
||||
class _StubModuleCommands:
|
||||
"""Minimal ModuleCommands stand-in that tracks registrations."""
|
||||
|
||||
def __init__(self, prefix: str = "!") -> None:
|
||||
self.prefix = prefix
|
||||
self._registered: dict[str, dict[str, Any]] = {}
|
||||
self._all_triggers: set[str] = set()
|
||||
self._external_triggers: set[str] = set()
|
||||
|
||||
def seed_external(self, name: str) -> None:
|
||||
"""Pre-register a trigger as belonging to another module."""
|
||||
self._external_triggers.add(name)
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: Any,
|
||||
*,
|
||||
aliases: list[str] | tuple[str, ...] | None = None,
|
||||
requires_authenticated: bool = False,
|
||||
requires_moderator: bool = False,
|
||||
cooldown: int | float = 0,
|
||||
) -> None:
|
||||
all_new = {name} | set(aliases or [])
|
||||
for trigger in all_new:
|
||||
if trigger in self._all_triggers or trigger in self._external_triggers:
|
||||
raise ValueError(
|
||||
f"Command trigger '{trigger}' conflicts with existing command"
|
||||
)
|
||||
self._registered[name] = {
|
||||
"handler": handler,
|
||||
"aliases": set(aliases or []),
|
||||
"requires_moderator": requires_moderator,
|
||||
"cooldown": cooldown,
|
||||
}
|
||||
self._all_triggers.update(all_new)
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
if name not in self._registered:
|
||||
return False
|
||||
info = self._registered.pop(name)
|
||||
self._all_triggers.discard(name)
|
||||
for a in info["aliases"]:
|
||||
self._all_triggers.discard(a)
|
||||
return True
|
||||
|
||||
def exists(self, trigger: str) -> bool:
|
||||
return trigger in self._all_triggers or trigger in self._external_triggers
|
||||
|
||||
|
||||
class _StubConfig:
|
||||
"""Minimal ModuleConfig stand-in."""
|
||||
|
||||
def __init__(self, values: dict[str, Any] | None = None) -> None:
|
||||
self._values: dict[str, Any] = values or {}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._values.get(key, default)
|
||||
|
||||
def register_defaults(self, defaults: dict[str, Any]) -> None:
|
||||
for key, value in defaults.items():
|
||||
self._values.setdefault(key, value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_commands() -> _StubModuleCommands:
|
||||
"""Return a fresh stub command registry."""
|
||||
return _StubModuleCommands()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmd_ctx(
|
||||
cmd_storage: ModuleStorage, stub_commands: _StubModuleCommands
|
||||
) -> ModuleContext:
|
||||
"""ModuleContext with real storage and stub commands/config."""
|
||||
return ModuleContext(
|
||||
module_name="custom_commands",
|
||||
config=_StubConfig({"default_cooldown": 5, "max_nesting_depth": 4}), # type: ignore[arg-type]
|
||||
owncast_client=None, # type: ignore[arg-type]
|
||||
storage=cmd_storage,
|
||||
commands=stub_commands, # type: ignore[arg-type]
|
||||
events=None, # type: ignore[arg-type]
|
||||
routes=None, # type: ignore[arg-type]
|
||||
http=None, # type: ignore[arg-type]
|
||||
templates=None, # type: ignore[arg-type]
|
||||
admin_client=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(cmd_ctx: ModuleContext, repo: CommandRepository) -> CommandManager:
|
||||
"""CommandManager backed by real repo and stub commands."""
|
||||
return CommandManager(cmd_ctx, repo)
|
||||
|
||||
|
||||
# ---- Manager tests ----
|
||||
|
||||
|
||||
class TestManagerLoadAll:
|
||||
"""CommandManager.load_all() loads commands into the registry."""
|
||||
|
||||
async def test_load_all(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
repo: CommandRepository,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""load_all registers all commands from the database."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.create("bye", "Goodbye!", 0)
|
||||
await manager.load_all()
|
||||
assert stub_commands.exists("greet")
|
||||
assert stub_commands.exists("bye")
|
||||
|
||||
async def test_load_all_skips_conflicts(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
repo: CommandRepository,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""load_all skips commands that conflict with existing triggers."""
|
||||
stub_commands.seed_external("greet")
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.create("bye", "Goodbye!", 0)
|
||||
await manager.load_all()
|
||||
assert not stub_commands._registered.get("greet")
|
||||
assert stub_commands.exists("bye")
|
||||
|
||||
async def test_load_all_with_aliases(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
repo: CommandRepository,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""load_all registers commands together with their aliases."""
|
||||
await repo.create("greet", "Hello!", 5)
|
||||
await repo.add_alias("greet", "hi")
|
||||
await manager.load_all()
|
||||
assert stub_commands.exists("greet")
|
||||
assert stub_commands.exists("hi")
|
||||
|
||||
async def test_load_all_empty(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""load_all does nothing when the database is empty."""
|
||||
await manager.load_all()
|
||||
assert len(stub_commands._registered) == 0
|
||||
|
||||
|
||||
class TestManagerCreateCommand:
|
||||
"""CommandManager.create_command() creates and registers commands."""
|
||||
|
||||
async def test_create_command(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""create_command creates and registers a command."""
|
||||
cmd = await manager.create_command("greet", "Hello!")
|
||||
assert cmd.name == "greet"
|
||||
assert cmd.response == "Hello!"
|
||||
assert cmd.cooldown == 5 # default_cooldown from config
|
||||
assert stub_commands.exists("greet")
|
||||
|
||||
async def test_create_rejects_invalid_name(self, manager: CommandManager) -> None:
|
||||
"""create_command raises InvalidNameError for bad names."""
|
||||
with pytest.raises(InvalidNameError):
|
||||
await manager.create_command("BAD!", "Hello!")
|
||||
|
||||
async def test_create_rejects_conflict(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""create_command raises CommandAlreadyExistsError on conflict."""
|
||||
stub_commands.seed_external("about")
|
||||
with pytest.raises(CommandAlreadyExistsError) as exc_info:
|
||||
await manager.create_command("about", "Info")
|
||||
assert exc_info.value.name == "about"
|
||||
|
||||
async def test_create_rollback_on_db_failure(
|
||||
self,
|
||||
cmd_ctx: ModuleContext,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""If DB insert fails, the registry registration is rolled back."""
|
||||
# Create a manager with a repo whose storage is closed.
|
||||
closed_storage = ModuleStorage(None, "closed")
|
||||
bad_repo = CommandRepository(closed_storage)
|
||||
mgr = CommandManager(cmd_ctx, bad_repo)
|
||||
with pytest.raises((StorageError, AttributeError)):
|
||||
await mgr.create_command("greet", "Hello!")
|
||||
assert not stub_commands.exists("greet")
|
||||
|
||||
|
||||
class TestManagerEditCommand:
|
||||
"""CommandManager.edit_command() updates a command's response."""
|
||||
|
||||
async def test_edit_command(self, manager: CommandManager) -> None:
|
||||
"""edit_command updates the response text."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
updated = await manager.edit_command("greet", "Hi there!")
|
||||
assert updated.response == "Hi there!"
|
||||
|
||||
async def test_edit_resolves_alias(self, manager: CommandManager) -> None:
|
||||
"""edit_command resolves an alias to the canonical command."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
await manager.add_alias("greet", "hi")
|
||||
updated = await manager.edit_command("hi", "Hi there!")
|
||||
assert updated.name == "greet"
|
||||
assert updated.response == "Hi there!"
|
||||
|
||||
async def test_edit_not_found(self, manager: CommandManager) -> None:
|
||||
"""edit_command raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await manager.edit_command("nonexistent", "Hi!")
|
||||
|
||||
async def test_edit_not_custom(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""edit_command raises NotCustomCommandError for built-in commands."""
|
||||
stub_commands.seed_external("about")
|
||||
with pytest.raises(NotCustomCommandError):
|
||||
await manager.edit_command("about", "Hacked!")
|
||||
|
||||
|
||||
class TestManagerDeleteCommand:
|
||||
"""CommandManager.delete_command() deletes and unregisters commands."""
|
||||
|
||||
async def test_delete_command(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""delete_command removes the command and unregisters its trigger."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
deleted = await manager.delete_command("greet")
|
||||
assert deleted.name == "greet"
|
||||
assert not stub_commands.exists("greet")
|
||||
|
||||
async def test_delete_rejects_alias(self, manager: CommandManager) -> None:
|
||||
"""delete_command raises CannotDeleteByAliasError for aliases."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
await manager.add_alias("greet", "hi")
|
||||
with pytest.raises(CannotDeleteByAliasError) as exc_info:
|
||||
await manager.delete_command("hi")
|
||||
assert exc_info.value.alias == "hi"
|
||||
assert exc_info.value.canonical == "greet"
|
||||
|
||||
async def test_delete_not_found(self, manager: CommandManager) -> None:
|
||||
"""delete_command raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await manager.delete_command("nonexistent")
|
||||
|
||||
|
||||
class TestManagerSetModOnly:
|
||||
"""CommandManager.set_mod_only() updates moderator access."""
|
||||
|
||||
async def test_set_mod_only_on(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""set_mod_only enables moderator-only access."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
updated = await manager.set_mod_only("greet", enabled=True)
|
||||
assert updated.requires_moderator is True
|
||||
assert stub_commands._registered["greet"]["requires_moderator"] is True
|
||||
|
||||
async def test_set_mod_only_off(self, manager: CommandManager) -> None:
|
||||
"""set_mod_only disables moderator-only access."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
await manager.set_mod_only("greet", enabled=True)
|
||||
updated = await manager.set_mod_only("greet", enabled=False)
|
||||
assert updated.requires_moderator is False
|
||||
|
||||
|
||||
class TestManagerSetCooldown:
|
||||
"""CommandManager.set_cooldown() updates cooldown."""
|
||||
|
||||
async def test_set_cooldown(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""set_cooldown updates the cooldown and re-registers the command."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
updated = await manager.set_cooldown("greet", 30)
|
||||
assert updated.cooldown == 30
|
||||
assert stub_commands._registered["greet"]["cooldown"] == 30
|
||||
|
||||
async def test_set_cooldown_zero(self, manager: CommandManager) -> None:
|
||||
"""set_cooldown accepts zero to disable cooldown."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
updated = await manager.set_cooldown("greet", 0)
|
||||
assert updated.cooldown == 0
|
||||
|
||||
|
||||
class TestManagerResetUseCount:
|
||||
"""CommandManager.reset_use_count() zeroes the counter."""
|
||||
|
||||
async def test_reset_use_count(self, manager: CommandManager) -> None:
|
||||
"""reset_use_count sets the counter back to zero."""
|
||||
await manager.create_command("greet", "$(count)")
|
||||
await manager.execute_command("greet", [], "tester")
|
||||
reset = await manager.reset_use_count("greet")
|
||||
assert reset.use_count == 0
|
||||
|
||||
|
||||
class TestManagerAddAlias:
|
||||
"""CommandManager.add_alias() adds and re-registers aliases."""
|
||||
|
||||
async def test_add_alias(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""add_alias registers a new alias for the command."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
cmd = await manager.add_alias("greet", "hi")
|
||||
assert "hi" in cmd.aliases
|
||||
assert stub_commands.exists("hi")
|
||||
|
||||
async def test_add_alias_rejects_canonical(self, manager: CommandManager) -> None:
|
||||
"""add_alias raises AliasIsCanonicalNameError for the canonical name."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
with pytest.raises(AliasIsCanonicalNameError):
|
||||
await manager.add_alias("greet", "greet")
|
||||
|
||||
async def test_add_alias_rejects_conflict(
|
||||
self,
|
||||
manager: CommandManager,
|
||||
stub_commands: _StubModuleCommands,
|
||||
) -> None:
|
||||
"""add_alias raises CommandAlreadyExistsError on trigger conflict."""
|
||||
stub_commands.seed_external("about")
|
||||
await manager.create_command("greet", "Hello!")
|
||||
with pytest.raises(CommandAlreadyExistsError):
|
||||
await manager.add_alias("greet", "about")
|
||||
|
||||
async def test_add_alias_invalid_name(self, manager: CommandManager) -> None:
|
||||
"""add_alias raises InvalidNameError for bad alias names."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
with pytest.raises(InvalidNameError):
|
||||
await manager.add_alias("greet", "BAD!")
|
||||
|
||||
|
||||
class TestManagerRemoveAlias:
|
||||
"""CommandManager.remove_alias() removes and re-registers aliases."""
|
||||
|
||||
async def test_remove_alias(
|
||||
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||
) -> None:
|
||||
"""remove_alias removes the alias and keeps the canonical command."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
await manager.add_alias("greet", "hi")
|
||||
cmd = await manager.remove_alias("hi")
|
||||
assert "hi" not in cmd.aliases
|
||||
assert not stub_commands.exists("hi")
|
||||
assert stub_commands.exists("greet")
|
||||
|
||||
async def test_remove_alias_not_found(self, manager: CommandManager) -> None:
|
||||
"""remove_alias raises CommandNotFoundError for missing aliases."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await manager.remove_alias("nonexistent")
|
||||
|
||||
|
||||
class TestManagerExecuteCommand:
|
||||
"""CommandManager.execute_command() increments count and processes response."""
|
||||
|
||||
async def test_execute_plain(self, manager: CommandManager) -> None:
|
||||
"""execute_command returns the plain response text."""
|
||||
await manager.create_command("greet", "Hello!")
|
||||
response = await manager.execute_command("greet", [], "tester")
|
||||
assert response == "Hello!"
|
||||
|
||||
async def test_execute_increments_use_count(self, manager: CommandManager) -> None:
|
||||
"""execute_command increments use_count on each call."""
|
||||
await manager.create_command("greet", "Count: $(count)")
|
||||
r1 = await manager.execute_command("greet", [], "tester")
|
||||
assert r1 == "Count: 1"
|
||||
r2 = await manager.execute_command("greet", [], "tester")
|
||||
assert r2 == "Count: 2"
|
||||
|
||||
async def test_execute_user_placeholder(self, manager: CommandManager) -> None:
|
||||
"""execute_command substitutes the $(user) placeholder."""
|
||||
await manager.create_command("greet", "Hello $(user)!")
|
||||
response = await manager.execute_command("greet", [], "Alice")
|
||||
assert response == "Hello Alice!"
|
||||
|
||||
async def test_execute_not_found(self, manager: CommandManager) -> None:
|
||||
"""execute_command raises CommandNotFoundError for missing commands."""
|
||||
with pytest.raises(CommandNotFoundError):
|
||||
await manager.execute_command("nonexistent", [], "tester")
|
||||
|
||||
|
||||
class TestManagerEditCounter:
|
||||
"""CommandManager.edit_counter() sets/adjusts named counters."""
|
||||
|
||||
async def test_edit_counter_absolute(self, manager: CommandManager) -> None:
|
||||
"""edit_counter sets an absolute counter value."""
|
||||
result = await manager.edit_counter("deaths", value=42, relative=False)
|
||||
assert result == 42
|
||||
|
||||
async def test_edit_counter_relative_increment(
|
||||
self, manager: CommandManager
|
||||
) -> None:
|
||||
"""edit_counter increments a counter with a positive delta."""
|
||||
await manager.edit_counter("deaths", value=10, relative=False)
|
||||
result = await manager.edit_counter("deaths", value=5, relative=True)
|
||||
assert result == 15
|
||||
|
||||
async def test_edit_counter_relative_decrement(
|
||||
self, manager: CommandManager
|
||||
) -> None:
|
||||
"""edit_counter decrements a counter with a negative delta."""
|
||||
await manager.edit_counter("deaths", value=10, relative=False)
|
||||
result = await manager.edit_counter("deaths", value=-3, relative=True)
|
||||
assert result == 7
|
||||
|
||||
async def test_edit_counter_invalid_name(self, manager: CommandManager) -> None:
|
||||
"""edit_counter raises InvalidNameError for bad counter names."""
|
||||
with pytest.raises(InvalidNameError):
|
||||
await manager.edit_counter("BAD!", value=5, relative=False)
|
||||
|
||||
|
||||
class TestManagerCounterAccessor:
|
||||
"""CommandManager as CounterAccessor protocol for placeholders."""
|
||||
|
||||
async def test_get_counter(self, manager: CommandManager) -> None:
|
||||
"""Return 0 for missing counter, correct value for existing."""
|
||||
assert await manager.get_counter("deaths") == 0
|
||||
await manager.set_counter("deaths", 10)
|
||||
assert await manager.get_counter("deaths") == 10
|
||||
|
||||
async def test_set_counter(self, manager: CommandManager) -> None:
|
||||
"""Set a counter to an absolute value."""
|
||||
result = await manager.set_counter("deaths", 42)
|
||||
assert result == 42
|
||||
|
||||
async def test_adjust_counter(self, manager: CommandManager) -> None:
|
||||
"""Adjust a counter by a relative delta."""
|
||||
await manager.set_counter("deaths", 10)
|
||||
result = await manager.adjust_counter("deaths", 5)
|
||||
assert result == 15
|
||||
|
||||
async def test_execute_with_named_counter(self, manager: CommandManager) -> None:
|
||||
"""Execute a command that uses a named counter placeholder."""
|
||||
await manager.create_command("deaths", "Deaths: $(count deaths)")
|
||||
r1 = await manager.execute_command("deaths", [], "tester")
|
||||
assert r1 == "Deaths: 1"
|
||||
r2 = await manager.execute_command("deaths", [], "tester")
|
||||
assert r2 == "Deaths: 2"
|
||||
|
||||
async def test_execute_with_getcount(self, manager: CommandManager) -> None:
|
||||
"""Execute a command that reads a named counter."""
|
||||
await manager.create_command("show", "Deaths: $(getcount deaths)")
|
||||
await manager.set_counter("deaths", 42)
|
||||
response = await manager.execute_command("show", [], "tester")
|
||||
assert response == "Deaths: 42"
|
||||
|
||||
|
||||
class TestGetManager:
|
||||
"""get_manager() helper."""
|
||||
|
||||
def test_returns_manager(
|
||||
self, cmd_ctx: ModuleContext, repo: CommandRepository
|
||||
) -> None:
|
||||
"""get_manager returns the manager stored in context state."""
|
||||
mgr = CommandManager(cmd_ctx, repo)
|
||||
cmd_ctx.state["manager"] = mgr
|
||||
assert get_manager(cmd_ctx) is mgr
|
||||
|
||||
def test_raises_if_not_initialized(self, cmd_ctx: ModuleContext) -> None:
|
||||
"""get_manager raises RuntimeError when no manager is stored."""
|
||||
with pytest.raises(RuntimeError, match="CommandManager is not initialized"):
|
||||
get_manager(cmd_ctx)
|
||||
@@ -29,6 +29,7 @@ from freezegun import freeze_time
|
||||
|
||||
from owlbot.builtin_modules.custom_commands.placeholder_handlers import _format_duration
|
||||
from owlbot.builtin_modules.custom_commands.placeholders import process_placeholders
|
||||
from owlbot.builtin_modules.custom_commands.types import Command
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
@@ -40,6 +41,49 @@ CREATE_COUNTERS = (
|
||||
)
|
||||
|
||||
|
||||
class _StorageCounterAccessor:
|
||||
"""CounterAccessor backed by raw ModuleStorage for placeholder tests."""
|
||||
|
||||
def __init__(self, storage: ModuleStorage) -> None:
|
||||
self._storage = storage
|
||||
|
||||
async def get_counter(self, name: str) -> int:
|
||||
row = await self._storage.fetch_one(
|
||||
"SELECT value FROM counters WHERE name = ?", (name,)
|
||||
)
|
||||
return int(row["value"]) if row else 0
|
||||
|
||||
async def set_counter(self, name: str, value: int) -> int:
|
||||
result = await self._storage.fetch_value(
|
||||
"INSERT OR REPLACE INTO counters (name, value) "
|
||||
"VALUES (?, ?) RETURNING value",
|
||||
(name, value),
|
||||
)
|
||||
return int(result) # type: ignore[arg-type]
|
||||
|
||||
async def adjust_counter(self, name: str, delta: int) -> int:
|
||||
result = await self._storage.fetch_value(
|
||||
"INSERT INTO counters (name, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(name) DO UPDATE SET value = value + ? "
|
||||
"RETURNING value",
|
||||
(name, delta, delta),
|
||||
)
|
||||
return int(result) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _make_command(use_count: int = 1) -> Command:
|
||||
"""Build a minimal Command snapshot for placeholder tests."""
|
||||
return Command(
|
||||
name="test",
|
||||
response="",
|
||||
use_count=use_count,
|
||||
created_at="2026-01-01T00:00:00+00:00",
|
||||
updated_at="2026-01-01T00:00:00+00:00",
|
||||
requires_moderator=False,
|
||||
cooldown=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def placeholder_storage(storage: ModuleStorage) -> ModuleStorage:
|
||||
"""Storage instance with the counters table created."""
|
||||
@@ -60,8 +104,8 @@ async def process(
|
||||
template,
|
||||
args or [],
|
||||
user,
|
||||
use_count,
|
||||
storage,
|
||||
_make_command(use_count),
|
||||
_StorageCounterAccessor(storage),
|
||||
max_depth=max_depth,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user