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}")