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

This commit is contained in:
2026-04-12 14:21:33 -04:00
parent 0b28ec39d0
commit 3413b1dfe4
13 changed files with 2394 additions and 844 deletions
@@ -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}")
+990
View File
@@ -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)
+46 -2
View File
@@ -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,
)