Refactored custom commands module into layered architecture with typed domain objects and comprehensive tests.
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 15s
CI / Tests (Python 3.13) (push) Successful in 14s
CI / Tests (Python 3.14) (push) Successful in 11s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 15s
CI / Tests (Python 3.13) (push) Successful in 14s
CI / Tests (Python 3.14) (push) Successful in 11s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s
This commit is contained in:
@@ -14,17 +14,14 @@
|
|||||||
|
|
||||||
"""Custom commands module for Owlbot.
|
"""Custom commands module for Owlbot.
|
||||||
|
|
||||||
Allows moderators to create, edit, delete, and list custom chat commands at runtime.
|
Allows moderators to create, edit, delete, and list custom chat commands
|
||||||
Custom commands are stored in SQLite and dynamically registered
|
at runtime. Custom commands are stored in SQLite and dynamically registered
|
||||||
with the CommandRegistry.
|
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
|
from .commands import (
|
||||||
|
|
||||||
# Re-export decorated handlers so the module loader discovers them.
|
|
||||||
from .management_commands import (
|
|
||||||
addalias,
|
addalias,
|
||||||
addcommand,
|
addcommand,
|
||||||
commandcooldown,
|
commandcooldown,
|
||||||
@@ -36,6 +33,8 @@ from .management_commands import (
|
|||||||
removealias,
|
removealias,
|
||||||
resetcommand,
|
resetcommand,
|
||||||
)
|
)
|
||||||
|
from .manager import CommandManager
|
||||||
|
from .repository import CommandRepository
|
||||||
from .routes import command_list_page
|
from .routes import command_list_page
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -51,6 +50,7 @@ __all__ = [
|
|||||||
"removealias",
|
"removealias",
|
||||||
"resetcommand",
|
"resetcommand",
|
||||||
"setup",
|
"setup",
|
||||||
|
"teardown",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -58,64 +58,21 @@ __all__ = [
|
|||||||
async def setup(ctx: ModuleContext) -> None:
|
async def setup(ctx: ModuleContext) -> None:
|
||||||
"""Initialize the custom_commands module.
|
"""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.
|
:param ctx: Module context with config, storage, and other services.
|
||||||
"""
|
"""
|
||||||
ctx.config.register_defaults({"max_nesting_depth": 4, "default_cooldown": 5})
|
ctx.config.register_defaults({"max_nesting_depth": 4, "default_cooldown": 5})
|
||||||
await ctx.storage.execute("""
|
repo = CommandRepository(ctx.storage)
|
||||||
CREATE TABLE IF NOT EXISTS commands (
|
await repo.setup()
|
||||||
name TEXT PRIMARY KEY NOT NULL,
|
manager = CommandManager(ctx, repo)
|
||||||
response TEXT NOT NULL,
|
ctx.state["manager"] = manager
|
||||||
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 ctx.storage.execute("""
|
await manager.load_all()
|
||||||
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 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(
|
@on_teardown
|
||||||
"SELECT c.name, c.requires_moderator, c.cooldown, "
|
async def teardown(ctx: ModuleContext) -> None:
|
||||||
"GROUP_CONCAT(ca.alias) AS aliases "
|
"""Clean up the custom_commands module.
|
||||||
"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
|
|
||||||
|
|
||||||
loaded_count = len(rows) - skipped
|
:param ctx: Module context.
|
||||||
ctx.logger.info(f"Loaded {loaded_count} custom command(s) from database.")
|
"""
|
||||||
if skipped:
|
ctx.state["manager"] = None
|
||||||
ctx.logger.info(f"Skipped {skipped} conflicting custom command(s).")
|
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
import random
|
||||||
import re
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .types import NAME_RE
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .placeholders import PlaceholderContext
|
from .placeholders import PlaceholderContext
|
||||||
|
|
||||||
@@ -250,9 +251,6 @@ TIMEZONE_OFFSETS: dict[str, float] = {
|
|||||||
"YEKT": 5,
|
"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:
|
def _parse_placeholder_date(date_str: str) -> datetime | None:
|
||||||
"""Parse a placeholder date string.
|
"""Parse a placeholder date string.
|
||||||
@@ -341,7 +339,7 @@ async def _evaluate_count(
|
|||||||
"""``$(count)`` / ``$(count name [mod])`` -- use count or named counter."""
|
"""``$(count)`` / ``$(count name [mod])`` -- use count or named counter."""
|
||||||
if not args:
|
if not args:
|
||||||
# No arguments: return the command's use_count (backward compatible).
|
# No arguments: return the command's use_count (backward compatible).
|
||||||
return str(ctx.use_count)
|
return str(ctx.command.use_count)
|
||||||
|
|
||||||
# Named counter.
|
# Named counter.
|
||||||
counter_name = args[0].lower()
|
counter_name = args[0].lower()
|
||||||
@@ -357,30 +355,16 @@ async def _evaluate_count(
|
|||||||
"Invalid $(count): too many arguments, expected $(count name [modifier])"
|
"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:
|
try:
|
||||||
value = int(modifier_str)
|
value = int(modifier_str)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise PlaceholderError(
|
raise PlaceholderError(
|
||||||
"Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
"Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
||||||
) from e
|
) from e
|
||||||
result = await ctx.storage.fetch_value(
|
if modifier_str[0] in ("+", "-"):
|
||||||
"INSERT OR REPLACE INTO counters (name, value) VALUES (?, ?) RETURNING value",
|
result = await ctx.counters.adjust_counter(counter_name, value)
|
||||||
(counter_name, value),
|
else:
|
||||||
)
|
result = await ctx.counters.set_counter(counter_name, value)
|
||||||
return str(result)
|
return str(result)
|
||||||
|
|
||||||
|
|
||||||
@@ -402,10 +386,8 @@ async def _evaluate_getcount(
|
|||||||
"Invalid $(getcount): counter name may only contain "
|
"Invalid $(getcount): counter name may only contain "
|
||||||
"letters, numbers, and underscores"
|
"letters, numbers, and underscores"
|
||||||
)
|
)
|
||||||
row = await ctx.storage.fetch_one(
|
result = await ctx.counters.get_counter(counter_name)
|
||||||
"SELECT value FROM counters WHERE name = ?", (counter_name,)
|
return str(result)
|
||||||
)
|
|
||||||
return str(row["value"]) if row else "0"
|
|
||||||
|
|
||||||
|
|
||||||
async def _evaluate_rand(
|
async def _evaluate_rand(
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ from dataclasses import dataclass
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from owlbot.api.storage import ModuleStorage
|
from .types import Command, CounterAccessor
|
||||||
|
|
||||||
from .placeholder_handlers import HANDLERS, PlaceholderError
|
from .placeholder_handlers import HANDLERS, PlaceholderError
|
||||||
|
|
||||||
@@ -88,14 +88,14 @@ class PlaceholderContext:
|
|||||||
|
|
||||||
:param args_list: Command arguments (``$(1)``-``$(9)`` values).
|
:param args_list: Command arguments (``$(1)``-``$(9)`` values).
|
||||||
:param user_display_name: Display name of the invoking user.
|
:param user_display_name: Display name of the invoking user.
|
||||||
:param use_count: The command's current use count.
|
:param command: Snapshot of the command being executed.
|
||||||
:param storage: Module storage for database access.
|
:param counters: Counter accessor for named counter operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
args_list: list[str]
|
args_list: list[str]
|
||||||
user_display_name: str
|
user_display_name: str
|
||||||
use_count: int
|
command: Command
|
||||||
storage: ModuleStorage
|
counters: CounterAccessor
|
||||||
|
|
||||||
|
|
||||||
# The parser uses recursive descent to convert a template string into a list
|
# The parser uses recursive descent to convert a template string into a list
|
||||||
@@ -360,8 +360,8 @@ async def process_placeholders(
|
|||||||
template: str,
|
template: str,
|
||||||
args_list: list[str],
|
args_list: list[str],
|
||||||
user_display_name: str,
|
user_display_name: str,
|
||||||
use_count: int,
|
command: Command,
|
||||||
storage: ModuleStorage,
|
counters: CounterAccessor,
|
||||||
max_depth: int = DEFAULT_MAX_DEPTH,
|
max_depth: int = DEFAULT_MAX_DEPTH,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Replace placeholders in a response template.
|
"""Replace placeholders in a response template.
|
||||||
@@ -375,8 +375,8 @@ async def process_placeholders(
|
|||||||
:param template: The response template with placeholders.
|
:param template: The response template with placeholders.
|
||||||
:param args_list: List of arguments passed to the command.
|
:param args_list: List of arguments passed to the command.
|
||||||
:param user_display_name: The executing user's display name.
|
:param user_display_name: The executing user's display name.
|
||||||
:param use_count: The command's current use count.
|
:param command: Snapshot of the command being executed.
|
||||||
:param storage: Module storage for database access.
|
:param counters: Counter accessor for named counter operations.
|
||||||
:param max_depth: Maximum nesting depth for placeholders.
|
:param max_depth: Maximum nesting depth for placeholders.
|
||||||
:return: The processed response string, or the error message on failure.
|
:return: The processed response string, or the error message on failure.
|
||||||
"""
|
"""
|
||||||
@@ -384,8 +384,8 @@ async def process_placeholders(
|
|||||||
ctx = PlaceholderContext(
|
ctx = PlaceholderContext(
|
||||||
args_list=args_list,
|
args_list=args_list,
|
||||||
user_display_name=user_display_name,
|
user_display_name=user_display_name,
|
||||||
use_count=use_count,
|
command=command,
|
||||||
storage=storage,
|
counters=counters,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return await _evaluate(nodes, ctx)
|
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 owlbot.api import RouteContext, on_route
|
||||||
|
|
||||||
|
from .manager import get_manager
|
||||||
|
|
||||||
|
|
||||||
@on_route("/list", methods=["GET"])
|
@on_route("/list", methods=["GET"])
|
||||||
async def command_list_page(ctx: RouteContext) -> web.Response:
|
async def command_list_page(ctx: RouteContext) -> web.Response:
|
||||||
"""Serve an HTML page listing all custom commands in a table.
|
"""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.
|
:param ctx: The route context.
|
||||||
:return: HTML response with the command list table.
|
:return: HTML response with the command list table.
|
||||||
"""
|
"""
|
||||||
rows = await ctx.storage.fetch_all(
|
commands = await get_manager(ctx.module).list_commands()
|
||||||
"SELECT c.name, c.response, c.requires_moderator, c.cooldown, "
|
page = ctx.templates.render(
|
||||||
"GROUP_CONCAT(ca.alias, ', ') AS aliases "
|
"list.html", commands=commands, prefix=ctx.commands.prefix
|
||||||
"FROM commands c "
|
|
||||||
"LEFT JOIN command_aliases ca ON c.name = ca.command_name "
|
|
||||||
"GROUP BY c.name "
|
|
||||||
"ORDER BY c.name"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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")
|
return web.Response(text=page, content_type="text/html")
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
{% for cmd in commands %}
|
{% for cmd in commands %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ prefix }}{{ cmd.name }}</td>
|
<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>{{ cmd.response }}</td>
|
||||||
<td class="text-center">{{ cmd.cooldown }}</td>
|
<td class="text-center">{% if cmd.cooldown == 0 %}None{% else %}{{ cmd.cooldown }}s{% endif %}</td>
|
||||||
<td class="text-center">{{ cmd.permissions }}</td>
|
<td class="text-center">{% if cmd.requires_moderator %}Moderator{% else %}Everyone{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Data containers and domain errors for the custom_commands module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING, Protocol
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
NAME_RE = re.compile(r"^[a-z0-9_]+$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True, slots=True)
|
||||||
|
class Command:
|
||||||
|
"""Immutable snapshot of a custom command record."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
response: str
|
||||||
|
use_count: int
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
requires_moderator: bool
|
||||||
|
cooldown: int
|
||||||
|
aliases: frozenset[str] = dataclasses.field(default_factory=frozenset)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(
|
||||||
|
cls,
|
||||||
|
row: aiosqlite.Row,
|
||||||
|
aliases: frozenset[str] = frozenset(),
|
||||||
|
) -> Command:
|
||||||
|
"""Build a Command from a database row.
|
||||||
|
|
||||||
|
:param row: A row from the commands table.
|
||||||
|
:param aliases: Aliases for this command (populated via JOIN).
|
||||||
|
:return: A Command snapshot.
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
name=row["name"],
|
||||||
|
response=row["response"],
|
||||||
|
use_count=row["use_count"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
requires_moderator=bool(row["requires_moderator"]),
|
||||||
|
cooldown=row["cooldown"],
|
||||||
|
aliases=aliases,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CounterAccessor(Protocol):
|
||||||
|
"""Interface for counter read/write operations.
|
||||||
|
|
||||||
|
Used by the placeholder system to access counters without coupling
|
||||||
|
to the repository directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_counter(self, name: str) -> int:
|
||||||
|
"""Get a counter's value (0 if not found)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def set_counter(self, name: str, value: int) -> int:
|
||||||
|
"""Set a counter to an absolute value.
|
||||||
|
|
||||||
|
:param name: Counter name.
|
||||||
|
:param value: Absolute value to set.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def adjust_counter(self, name: str, delta: int) -> int:
|
||||||
|
"""Adjust a counter by a relative delta.
|
||||||
|
|
||||||
|
:param name: Counter name.
|
||||||
|
:param delta: Amount to add (can be negative).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class CommandError(Exception):
|
||||||
|
"""Base class for custom command domain errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandNotFoundError(CommandError):
|
||||||
|
"""No command matches the given name or alias."""
|
||||||
|
|
||||||
|
def __init__(self, identifier: str) -> None:
|
||||||
|
"""Initialize with the identifier that was not found.
|
||||||
|
|
||||||
|
:param identifier: The name or alias that was not found.
|
||||||
|
"""
|
||||||
|
self.identifier = identifier
|
||||||
|
super().__init__(f"command not found: {identifier}")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidNameError(CommandError):
|
||||||
|
"""Name does not match the allowed pattern."""
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
"""Initialize with the invalid name.
|
||||||
|
|
||||||
|
:param name: The name that failed validation.
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
super().__init__(f"invalid name: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
class CommandAlreadyExistsError(CommandError):
|
||||||
|
"""A command with this name or trigger already exists."""
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
"""Initialize with the conflicting name.
|
||||||
|
|
||||||
|
:param name: The name that already exists.
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
super().__init__(f"command already exists: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
class NotCustomCommandError(CommandError):
|
||||||
|
"""The command exists but is not a custom command."""
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
"""Initialize with the non-custom command name.
|
||||||
|
|
||||||
|
:param name: The name of the built-in command.
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
super().__init__(f"not a custom command: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
class AliasAlreadyExistsError(CommandError):
|
||||||
|
"""The alias is already assigned to a command."""
|
||||||
|
|
||||||
|
def __init__(self, alias: str, owner: str) -> None:
|
||||||
|
"""Initialize with the conflicting alias and its owner.
|
||||||
|
|
||||||
|
:param alias: The alias that already exists.
|
||||||
|
:param owner: The command that owns the alias.
|
||||||
|
"""
|
||||||
|
self.alias = alias
|
||||||
|
self.owner = owner
|
||||||
|
super().__init__(f"alias already exists: {alias} (owned by {owner})")
|
||||||
|
|
||||||
|
|
||||||
|
class AliasIsCanonicalNameError(CommandError):
|
||||||
|
"""Cannot alias a command to its own canonical name."""
|
||||||
|
|
||||||
|
def __init__(self, alias: str) -> None:
|
||||||
|
"""Initialize with the alias that matches the canonical name.
|
||||||
|
|
||||||
|
:param alias: The alias that is the same as the command name.
|
||||||
|
"""
|
||||||
|
self.alias = alias
|
||||||
|
super().__init__(f"alias is the canonical name: {alias}")
|
||||||
|
|
||||||
|
|
||||||
|
class CannotDeleteByAliasError(CommandError):
|
||||||
|
"""Cannot delete a command via its alias name."""
|
||||||
|
|
||||||
|
def __init__(self, alias: str, canonical: str) -> None:
|
||||||
|
"""Initialize with the alias and its canonical command name.
|
||||||
|
|
||||||
|
:param alias: The alias that was targeted for deletion.
|
||||||
|
:param canonical: The canonical command name that owns the alias.
|
||||||
|
"""
|
||||||
|
self.alias = alias
|
||||||
|
self.canonical = canonical
|
||||||
|
super().__init__(f"cannot delete alias {alias}, belongs to {canonical}")
|
||||||
@@ -0,0 +1,990 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Tests for the custom_commands module: types, repository, and manager."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from owlbot.api.context import ModuleContext
|
||||||
|
from owlbot.api.storage import ModuleStorage, StorageError
|
||||||
|
from owlbot.builtin_modules.custom_commands.manager import (
|
||||||
|
CommandManager,
|
||||||
|
get_manager,
|
||||||
|
)
|
||||||
|
from owlbot.builtin_modules.custom_commands.repository import CommandRepository
|
||||||
|
from owlbot.builtin_modules.custom_commands.types import (
|
||||||
|
NAME_RE,
|
||||||
|
AliasAlreadyExistsError,
|
||||||
|
AliasIsCanonicalNameError,
|
||||||
|
CannotDeleteByAliasError,
|
||||||
|
Command,
|
||||||
|
CommandAlreadyExistsError,
|
||||||
|
CommandError,
|
||||||
|
CommandNotFoundError,
|
||||||
|
InvalidNameError,
|
||||||
|
NotCustomCommandError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandFromRow:
|
||||||
|
"""Command.from_row() database row conversion."""
|
||||||
|
|
||||||
|
def test_builds_from_row(self) -> None:
|
||||||
|
"""from_row constructs a Command with correct field values."""
|
||||||
|
row = {
|
||||||
|
"name": "greet",
|
||||||
|
"response": "Hello $(user)!",
|
||||||
|
"use_count": 5,
|
||||||
|
"created_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
"updated_at": "2026-01-02T00:00:00+00:00",
|
||||||
|
"requires_moderator": 0,
|
||||||
|
"cooldown": 10,
|
||||||
|
}
|
||||||
|
cmd = Command.from_row(row) # type: ignore[arg-type]
|
||||||
|
assert cmd.name == "greet"
|
||||||
|
assert cmd.response == "Hello $(user)!"
|
||||||
|
assert cmd.use_count == 5
|
||||||
|
assert cmd.requires_moderator is False
|
||||||
|
assert cmd.cooldown == 10
|
||||||
|
assert cmd.aliases == frozenset()
|
||||||
|
|
||||||
|
def test_builds_with_aliases(self) -> None:
|
||||||
|
"""from_row populates aliases and coerces requires_moderator to bool."""
|
||||||
|
row = {
|
||||||
|
"name": "greet",
|
||||||
|
"response": "Hello!",
|
||||||
|
"use_count": 0,
|
||||||
|
"created_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
"requires_moderator": 1,
|
||||||
|
"cooldown": 0,
|
||||||
|
}
|
||||||
|
aliases = frozenset({"hi", "hey"})
|
||||||
|
cmd = Command.from_row(row, aliases) # type: ignore[arg-type]
|
||||||
|
assert cmd.requires_moderator is True
|
||||||
|
assert cmd.aliases == {"hi", "hey"}
|
||||||
|
|
||||||
|
def test_frozen(self) -> None:
|
||||||
|
"""Command instances are immutable (frozen dataclass)."""
|
||||||
|
row = {
|
||||||
|
"name": "x",
|
||||||
|
"response": "y",
|
||||||
|
"use_count": 0,
|
||||||
|
"created_at": "",
|
||||||
|
"updated_at": "",
|
||||||
|
"requires_moderator": 0,
|
||||||
|
"cooldown": 0,
|
||||||
|
}
|
||||||
|
cmd = Command.from_row(row) # type: ignore[arg-type]
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
cmd.name = "z" # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandErrors:
|
||||||
|
"""Domain error hierarchy and stored context."""
|
||||||
|
|
||||||
|
def test_all_inherit_from_command_error(self) -> None:
|
||||||
|
"""All domain errors are subclasses of CommandError."""
|
||||||
|
assert issubclass(CommandNotFoundError, CommandError)
|
||||||
|
assert issubclass(InvalidNameError, CommandError)
|
||||||
|
assert issubclass(CommandAlreadyExistsError, CommandError)
|
||||||
|
assert issubclass(NotCustomCommandError, CommandError)
|
||||||
|
assert issubclass(AliasAlreadyExistsError, CommandError)
|
||||||
|
assert issubclass(AliasIsCanonicalNameError, CommandError)
|
||||||
|
assert issubclass(CannotDeleteByAliasError, CommandError)
|
||||||
|
|
||||||
|
def test_not_found_stores_identifier(self) -> None:
|
||||||
|
"""CommandNotFoundError stores the identifier and includes it in str."""
|
||||||
|
err = CommandNotFoundError("greet")
|
||||||
|
assert err.identifier == "greet"
|
||||||
|
assert "greet" in str(err)
|
||||||
|
|
||||||
|
def test_invalid_name_stores_name(self) -> None:
|
||||||
|
"""InvalidNameError stores the invalid name."""
|
||||||
|
err = InvalidNameError("BAD NAME!")
|
||||||
|
assert err.name == "BAD NAME!"
|
||||||
|
|
||||||
|
def test_already_exists_stores_name(self) -> None:
|
||||||
|
"""CommandAlreadyExistsError stores the conflicting name."""
|
||||||
|
err = CommandAlreadyExistsError("greet")
|
||||||
|
assert err.name == "greet"
|
||||||
|
|
||||||
|
def test_not_custom_stores_name(self) -> None:
|
||||||
|
"""NotCustomCommandError stores the built-in command name."""
|
||||||
|
err = NotCustomCommandError("about")
|
||||||
|
assert err.name == "about"
|
||||||
|
|
||||||
|
def test_alias_already_exists_stores_context(self) -> None:
|
||||||
|
"""AliasAlreadyExistsError stores alias and owner."""
|
||||||
|
err = AliasAlreadyExistsError("hi", "greet")
|
||||||
|
assert err.alias == "hi"
|
||||||
|
assert err.owner == "greet"
|
||||||
|
|
||||||
|
def test_alias_is_canonical_stores_alias(self) -> None:
|
||||||
|
"""AliasIsCanonicalNameError stores the alias."""
|
||||||
|
err = AliasIsCanonicalNameError("greet")
|
||||||
|
assert err.alias == "greet"
|
||||||
|
|
||||||
|
def test_cannot_delete_alias_stores_context(self) -> None:
|
||||||
|
"""CannotDeleteByAliasError stores alias and canonical name."""
|
||||||
|
err = CannotDeleteByAliasError("hi", "greet")
|
||||||
|
assert err.alias == "hi"
|
||||||
|
assert err.canonical == "greet"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNamePattern:
|
||||||
|
"""NAME_RE validates identifiers."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name",
|
||||||
|
[
|
||||||
|
pytest.param("greet", id="alpha"),
|
||||||
|
pytest.param("hello_world", id="underscore"),
|
||||||
|
pytest.param("cmd1", id="trailing_digit"),
|
||||||
|
pytest.param("a", id="single_char"),
|
||||||
|
pytest.param("test_123", id="mixed"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_valid_names(self, name: str) -> None:
|
||||||
|
"""NAME_RE matches valid lowercase alphanumeric identifiers."""
|
||||||
|
assert NAME_RE.match(name) is not None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name",
|
||||||
|
[
|
||||||
|
pytest.param("", id="empty"),
|
||||||
|
pytest.param("Hello", id="uppercase"),
|
||||||
|
pytest.param("has space", id="space"),
|
||||||
|
pytest.param("special!", id="special_char"),
|
||||||
|
pytest.param("with-dash", id="dash"),
|
||||||
|
pytest.param("UPPER", id="all_upper"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_names(self, name: str) -> None:
|
||||||
|
"""NAME_RE rejects names with invalid characters or patterns."""
|
||||||
|
assert NAME_RE.match(name) is None
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Repository fixtures ----
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def cmd_storage() -> AsyncIterator[ModuleStorage]:
|
||||||
|
"""Yield an open in-memory ModuleStorage."""
|
||||||
|
async with ModuleStorage(None, "custom_commands") as storage:
|
||||||
|
yield storage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def repo(cmd_storage: ModuleStorage) -> CommandRepository:
|
||||||
|
"""CommandRepository backed by cmd_storage, with schema initialized."""
|
||||||
|
r = CommandRepository(cmd_storage)
|
||||||
|
await r.setup()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Repository command tests ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryCreate:
|
||||||
|
"""CommandRepository.create() inserts new commands."""
|
||||||
|
|
||||||
|
async def test_create_returns_command(self, repo: CommandRepository) -> None:
|
||||||
|
"""create() returns a Command snapshot with correct fields."""
|
||||||
|
cmd = await repo.create("greet", "Hello!", 5)
|
||||||
|
assert cmd.name == "greet"
|
||||||
|
assert cmd.response == "Hello!"
|
||||||
|
assert cmd.use_count == 0
|
||||||
|
assert cmd.requires_moderator is False
|
||||||
|
assert cmd.cooldown == 5
|
||||||
|
assert cmd.aliases == frozenset()
|
||||||
|
assert cmd.created_at is not None
|
||||||
|
assert cmd.updated_at is not None
|
||||||
|
|
||||||
|
async def test_create_duplicate_raises(self, repo: CommandRepository) -> None:
|
||||||
|
"""create() raises on duplicate command name."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
with pytest.raises(StorageError, match="UNIQUE constraint"):
|
||||||
|
await repo.create("greet", "Hi!", 5)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryGet:
|
||||||
|
"""CommandRepository.get() fetches by name or alias."""
|
||||||
|
|
||||||
|
async def test_get_by_name(self, repo: CommandRepository) -> None:
|
||||||
|
"""get() retrieves a command by its canonical name."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
cmd = await repo.get("greet")
|
||||||
|
assert cmd.name == "greet"
|
||||||
|
assert cmd.response == "Hello!"
|
||||||
|
|
||||||
|
async def test_get_not_found(self, repo: CommandRepository) -> None:
|
||||||
|
"""get() raises CommandNotFoundError for missing commands."""
|
||||||
|
with pytest.raises(CommandNotFoundError) as exc_info:
|
||||||
|
await repo.get("nonexistent")
|
||||||
|
assert exc_info.value.identifier == "nonexistent"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryDelete:
|
||||||
|
"""CommandRepository.delete() removes commands."""
|
||||||
|
|
||||||
|
async def test_delete_returns_command(self, repo: CommandRepository) -> None:
|
||||||
|
"""delete() returns the deleted command snapshot."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
deleted = await repo.delete("greet")
|
||||||
|
assert deleted.name == "greet"
|
||||||
|
|
||||||
|
async def test_delete_removes_from_db(self, repo: CommandRepository) -> None:
|
||||||
|
"""delete() makes the command unreachable via get()."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.delete("greet")
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await repo.get("greet")
|
||||||
|
|
||||||
|
async def test_delete_not_found(self, repo: CommandRepository) -> None:
|
||||||
|
"""delete() raises CommandNotFoundError for missing commands."""
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await repo.delete("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryUpdateResponse:
|
||||||
|
"""CommandRepository.update_response() changes a command's response."""
|
||||||
|
|
||||||
|
async def test_update_response(self, repo: CommandRepository) -> None:
|
||||||
|
"""update_response() returns updated Command with new response."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
updated = await repo.update_response("greet", "Hi there!")
|
||||||
|
assert updated.response == "Hi there!"
|
||||||
|
assert updated.name == "greet"
|
||||||
|
|
||||||
|
async def test_update_response_changes_updated_at(
|
||||||
|
self, repo: CommandRepository
|
||||||
|
) -> None:
|
||||||
|
"""update_response() bumps updated_at timestamp."""
|
||||||
|
created = await repo.create("greet", "Hello!", 5)
|
||||||
|
updated = await repo.update_response("greet", "Hi!")
|
||||||
|
assert updated.updated_at != created.updated_at
|
||||||
|
|
||||||
|
async def test_update_response_not_found(self, repo: CommandRepository) -> None:
|
||||||
|
"""update_response() raises CommandNotFoundError for missing commands."""
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await repo.update_response("nonexistent", "Hi!")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryUpdateModeratorFlag:
|
||||||
|
"""CommandRepository.update_moderator_flag() changes the mod-only flag."""
|
||||||
|
|
||||||
|
async def test_enable(self, repo: CommandRepository) -> None:
|
||||||
|
"""update_moderator_flag() sets requires_moderator to True."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
updated = await repo.update_moderator_flag("greet", enabled=True)
|
||||||
|
assert updated.requires_moderator is True
|
||||||
|
assert updated.cooldown == 5
|
||||||
|
|
||||||
|
async def test_disable(self, repo: CommandRepository) -> None:
|
||||||
|
"""update_moderator_flag() sets requires_moderator to False."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.update_moderator_flag("greet", enabled=True)
|
||||||
|
updated = await repo.update_moderator_flag("greet", enabled=False)
|
||||||
|
assert updated.requires_moderator is False
|
||||||
|
|
||||||
|
async def test_not_found(self, repo: CommandRepository) -> None:
|
||||||
|
"""update_moderator_flag() raises CommandNotFoundError for missing commands."""
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await repo.update_moderator_flag("nonexistent", enabled=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryUpdateCooldown:
|
||||||
|
"""CommandRepository.update_cooldown() changes the cooldown duration."""
|
||||||
|
|
||||||
|
async def test_update(self, repo: CommandRepository) -> None:
|
||||||
|
"""update_cooldown() sets the cooldown value."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
updated = await repo.update_cooldown("greet", seconds=30)
|
||||||
|
assert updated.cooldown == 30
|
||||||
|
assert updated.requires_moderator is False
|
||||||
|
|
||||||
|
async def test_not_found(self, repo: CommandRepository) -> None:
|
||||||
|
"""update_cooldown() raises CommandNotFoundError for missing commands."""
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await repo.update_cooldown("nonexistent", seconds=5)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryResetUseCount:
|
||||||
|
"""CommandRepository.reset_use_count() zeroes the counter."""
|
||||||
|
|
||||||
|
async def test_reset_use_count(self, repo: CommandRepository) -> None:
|
||||||
|
"""reset_use_count() sets use_count back to 0."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.increment_use_count("greet")
|
||||||
|
await repo.increment_use_count("greet")
|
||||||
|
reset = await repo.reset_use_count("greet")
|
||||||
|
assert reset.use_count == 0
|
||||||
|
|
||||||
|
async def test_reset_not_found(self, repo: CommandRepository) -> None:
|
||||||
|
"""reset_use_count() raises CommandNotFoundError for missing commands."""
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await repo.reset_use_count("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryIncrementUseCount:
|
||||||
|
"""CommandRepository.increment_use_count() bumps the counter."""
|
||||||
|
|
||||||
|
async def test_increment(self, repo: CommandRepository) -> None:
|
||||||
|
"""increment_use_count() increases use_count by 1 each call."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
cmd = await repo.increment_use_count("greet")
|
||||||
|
assert cmd.use_count == 1
|
||||||
|
cmd = await repo.increment_use_count("greet")
|
||||||
|
assert cmd.use_count == 2
|
||||||
|
|
||||||
|
async def test_increment_not_found(self, repo: CommandRepository) -> None:
|
||||||
|
"""increment_use_count() raises CommandNotFoundError for missing commands."""
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await repo.increment_use_count("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryListAll:
|
||||||
|
"""CommandRepository.list_all() returns all commands."""
|
||||||
|
|
||||||
|
async def test_list_all(self, repo: CommandRepository) -> None:
|
||||||
|
"""list_all() returns commands sorted by name."""
|
||||||
|
await repo.create("bravo", "B", 5)
|
||||||
|
await repo.create("alpha", "A", 5)
|
||||||
|
result = await repo.list_all()
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].name == "alpha"
|
||||||
|
assert result[1].name == "bravo"
|
||||||
|
|
||||||
|
async def test_list_all_empty(self, repo: CommandRepository) -> None:
|
||||||
|
"""list_all() returns empty list when no commands exist."""
|
||||||
|
assert await repo.list_all() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Repository alias tests ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryGetByAlias:
|
||||||
|
"""CommandRepository.get() resolves aliases."""
|
||||||
|
|
||||||
|
async def test_get_by_alias(self, repo: CommandRepository) -> None:
|
||||||
|
"""get() resolves an alias to its canonical command."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.add_alias("greet", "hi")
|
||||||
|
cmd = await repo.get("hi")
|
||||||
|
assert cmd.name == "greet"
|
||||||
|
assert "hi" in cmd.aliases
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryAddAlias:
|
||||||
|
"""CommandRepository.add_alias() adds aliases."""
|
||||||
|
|
||||||
|
async def test_add_alias(self, repo: CommandRepository) -> None:
|
||||||
|
"""add_alias() adds an alias and returns refreshed command."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
cmd = await repo.add_alias("greet", "hi")
|
||||||
|
assert "hi" in cmd.aliases
|
||||||
|
|
||||||
|
async def test_add_multiple_aliases(self, repo: CommandRepository) -> None:
|
||||||
|
"""add_alias() accumulates multiple aliases."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.add_alias("greet", "hi")
|
||||||
|
cmd = await repo.add_alias("greet", "hey")
|
||||||
|
assert cmd.aliases == frozenset({"hi", "hey"})
|
||||||
|
|
||||||
|
async def test_add_alias_duplicate(self, repo: CommandRepository) -> None:
|
||||||
|
"""add_alias() raises AliasAlreadyExistsError for duplicate aliases."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.add_alias("greet", "hi")
|
||||||
|
with pytest.raises(AliasAlreadyExistsError) as exc_info:
|
||||||
|
await repo.add_alias("greet", "hi")
|
||||||
|
assert exc_info.value.alias == "hi"
|
||||||
|
assert exc_info.value.owner == "greet"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryRemoveAlias:
|
||||||
|
"""CommandRepository.remove_alias() removes aliases."""
|
||||||
|
|
||||||
|
async def test_remove_alias(self, repo: CommandRepository) -> None:
|
||||||
|
"""remove_alias() removes the alias and returns refreshed command."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.add_alias("greet", "hi")
|
||||||
|
alias, cmd = await repo.remove_alias("hi")
|
||||||
|
assert alias == "hi"
|
||||||
|
assert "hi" not in cmd.aliases
|
||||||
|
|
||||||
|
async def test_remove_alias_not_found(self, repo: CommandRepository) -> None:
|
||||||
|
"""remove_alias() raises CommandNotFoundError for missing aliases."""
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await repo.remove_alias("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryDeleteCascade:
|
||||||
|
"""Deleting a command cascades to its aliases."""
|
||||||
|
|
||||||
|
async def test_delete_cascades_aliases(self, repo: CommandRepository) -> None:
|
||||||
|
"""delete() removes associated aliases via CASCADE."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.add_alias("greet", "hi")
|
||||||
|
await repo.delete("greet")
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await repo.get("hi")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryListAllWithAliases:
|
||||||
|
"""CommandRepository.list_all() includes aliases."""
|
||||||
|
|
||||||
|
async def test_list_all_includes_aliases(self, repo: CommandRepository) -> None:
|
||||||
|
"""list_all() populates alias sets for each command."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.add_alias("greet", "hi")
|
||||||
|
await repo.add_alias("greet", "hey")
|
||||||
|
result = await repo.list_all()
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].aliases == frozenset({"hi", "hey"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Repository counter tests ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryGetCounter:
|
||||||
|
"""CommandRepository.get_counter() reads counter values."""
|
||||||
|
|
||||||
|
async def test_get_counter_missing(self, repo: CommandRepository) -> None:
|
||||||
|
"""get_counter() returns 0 for nonexistent counters."""
|
||||||
|
assert await repo.get_counter("deaths") == 0
|
||||||
|
|
||||||
|
async def test_get_counter_existing(self, repo: CommandRepository) -> None:
|
||||||
|
"""get_counter() returns the stored value."""
|
||||||
|
await repo.set_counter("deaths", 10)
|
||||||
|
assert await repo.get_counter("deaths") == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositorySetCounter:
|
||||||
|
"""CommandRepository.set_counter() sets absolute values."""
|
||||||
|
|
||||||
|
async def test_set_counter_new(self, repo: CommandRepository) -> None:
|
||||||
|
"""set_counter() inserts a new counter with the given value."""
|
||||||
|
result = await repo.set_counter("deaths", 42)
|
||||||
|
assert result == 42
|
||||||
|
assert await repo.get_counter("deaths") == 42
|
||||||
|
|
||||||
|
async def test_set_counter_overwrite(self, repo: CommandRepository) -> None:
|
||||||
|
"""set_counter() overwrites an existing counter."""
|
||||||
|
await repo.set_counter("deaths", 10)
|
||||||
|
result = await repo.set_counter("deaths", 99)
|
||||||
|
assert result == 99
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryUpdateCounter:
|
||||||
|
"""CommandRepository.update_counter() applies relative deltas."""
|
||||||
|
|
||||||
|
async def test_update_counter_new(self, repo: CommandRepository) -> None:
|
||||||
|
"""update_counter() creates a counter if it does not exist."""
|
||||||
|
result = await repo.update_counter("deaths", 5)
|
||||||
|
assert result == 5
|
||||||
|
|
||||||
|
async def test_update_counter_increment(self, repo: CommandRepository) -> None:
|
||||||
|
"""update_counter() adds a positive delta to existing value."""
|
||||||
|
await repo.set_counter("deaths", 10)
|
||||||
|
result = await repo.update_counter("deaths", 3)
|
||||||
|
assert result == 13
|
||||||
|
|
||||||
|
async def test_update_counter_decrement(self, repo: CommandRepository) -> None:
|
||||||
|
"""update_counter() subtracts a negative delta from existing value."""
|
||||||
|
await repo.set_counter("deaths", 10)
|
||||||
|
result = await repo.update_counter("deaths", -3)
|
||||||
|
assert result == 7
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Manager stubs and fixtures ----
|
||||||
|
|
||||||
|
|
||||||
|
class _StubModuleCommands:
|
||||||
|
"""Minimal ModuleCommands stand-in that tracks registrations."""
|
||||||
|
|
||||||
|
def __init__(self, prefix: str = "!") -> None:
|
||||||
|
self.prefix = prefix
|
||||||
|
self._registered: dict[str, dict[str, Any]] = {}
|
||||||
|
self._all_triggers: set[str] = set()
|
||||||
|
self._external_triggers: set[str] = set()
|
||||||
|
|
||||||
|
def seed_external(self, name: str) -> None:
|
||||||
|
"""Pre-register a trigger as belonging to another module."""
|
||||||
|
self._external_triggers.add(name)
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
handler: Any,
|
||||||
|
*,
|
||||||
|
aliases: list[str] | tuple[str, ...] | None = None,
|
||||||
|
requires_authenticated: bool = False,
|
||||||
|
requires_moderator: bool = False,
|
||||||
|
cooldown: int | float = 0,
|
||||||
|
) -> None:
|
||||||
|
all_new = {name} | set(aliases or [])
|
||||||
|
for trigger in all_new:
|
||||||
|
if trigger in self._all_triggers or trigger in self._external_triggers:
|
||||||
|
raise ValueError(
|
||||||
|
f"Command trigger '{trigger}' conflicts with existing command"
|
||||||
|
)
|
||||||
|
self._registered[name] = {
|
||||||
|
"handler": handler,
|
||||||
|
"aliases": set(aliases or []),
|
||||||
|
"requires_moderator": requires_moderator,
|
||||||
|
"cooldown": cooldown,
|
||||||
|
}
|
||||||
|
self._all_triggers.update(all_new)
|
||||||
|
|
||||||
|
def unregister(self, name: str) -> bool:
|
||||||
|
if name not in self._registered:
|
||||||
|
return False
|
||||||
|
info = self._registered.pop(name)
|
||||||
|
self._all_triggers.discard(name)
|
||||||
|
for a in info["aliases"]:
|
||||||
|
self._all_triggers.discard(a)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def exists(self, trigger: str) -> bool:
|
||||||
|
return trigger in self._all_triggers or trigger in self._external_triggers
|
||||||
|
|
||||||
|
|
||||||
|
class _StubConfig:
|
||||||
|
"""Minimal ModuleConfig stand-in."""
|
||||||
|
|
||||||
|
def __init__(self, values: dict[str, Any] | None = None) -> None:
|
||||||
|
self._values: dict[str, Any] = values or {}
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
return self._values.get(key, default)
|
||||||
|
|
||||||
|
def register_defaults(self, defaults: dict[str, Any]) -> None:
|
||||||
|
for key, value in defaults.items():
|
||||||
|
self._values.setdefault(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stub_commands() -> _StubModuleCommands:
|
||||||
|
"""Return a fresh stub command registry."""
|
||||||
|
return _StubModuleCommands()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cmd_ctx(
|
||||||
|
cmd_storage: ModuleStorage, stub_commands: _StubModuleCommands
|
||||||
|
) -> ModuleContext:
|
||||||
|
"""ModuleContext with real storage and stub commands/config."""
|
||||||
|
return ModuleContext(
|
||||||
|
module_name="custom_commands",
|
||||||
|
config=_StubConfig({"default_cooldown": 5, "max_nesting_depth": 4}), # type: ignore[arg-type]
|
||||||
|
owncast_client=None, # type: ignore[arg-type]
|
||||||
|
storage=cmd_storage,
|
||||||
|
commands=stub_commands, # type: ignore[arg-type]
|
||||||
|
events=None, # type: ignore[arg-type]
|
||||||
|
routes=None, # type: ignore[arg-type]
|
||||||
|
http=None, # type: ignore[arg-type]
|
||||||
|
templates=None, # type: ignore[arg-type]
|
||||||
|
admin_client=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager(cmd_ctx: ModuleContext, repo: CommandRepository) -> CommandManager:
|
||||||
|
"""CommandManager backed by real repo and stub commands."""
|
||||||
|
return CommandManager(cmd_ctx, repo)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Manager tests ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerLoadAll:
|
||||||
|
"""CommandManager.load_all() loads commands into the registry."""
|
||||||
|
|
||||||
|
async def test_load_all(
|
||||||
|
self,
|
||||||
|
manager: CommandManager,
|
||||||
|
repo: CommandRepository,
|
||||||
|
stub_commands: _StubModuleCommands,
|
||||||
|
) -> None:
|
||||||
|
"""load_all registers all commands from the database."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.create("bye", "Goodbye!", 0)
|
||||||
|
await manager.load_all()
|
||||||
|
assert stub_commands.exists("greet")
|
||||||
|
assert stub_commands.exists("bye")
|
||||||
|
|
||||||
|
async def test_load_all_skips_conflicts(
|
||||||
|
self,
|
||||||
|
manager: CommandManager,
|
||||||
|
repo: CommandRepository,
|
||||||
|
stub_commands: _StubModuleCommands,
|
||||||
|
) -> None:
|
||||||
|
"""load_all skips commands that conflict with existing triggers."""
|
||||||
|
stub_commands.seed_external("greet")
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.create("bye", "Goodbye!", 0)
|
||||||
|
await manager.load_all()
|
||||||
|
assert not stub_commands._registered.get("greet")
|
||||||
|
assert stub_commands.exists("bye")
|
||||||
|
|
||||||
|
async def test_load_all_with_aliases(
|
||||||
|
self,
|
||||||
|
manager: CommandManager,
|
||||||
|
repo: CommandRepository,
|
||||||
|
stub_commands: _StubModuleCommands,
|
||||||
|
) -> None:
|
||||||
|
"""load_all registers commands together with their aliases."""
|
||||||
|
await repo.create("greet", "Hello!", 5)
|
||||||
|
await repo.add_alias("greet", "hi")
|
||||||
|
await manager.load_all()
|
||||||
|
assert stub_commands.exists("greet")
|
||||||
|
assert stub_commands.exists("hi")
|
||||||
|
|
||||||
|
async def test_load_all_empty(
|
||||||
|
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||||
|
) -> None:
|
||||||
|
"""load_all does nothing when the database is empty."""
|
||||||
|
await manager.load_all()
|
||||||
|
assert len(stub_commands._registered) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerCreateCommand:
|
||||||
|
"""CommandManager.create_command() creates and registers commands."""
|
||||||
|
|
||||||
|
async def test_create_command(
|
||||||
|
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||||
|
) -> None:
|
||||||
|
"""create_command creates and registers a command."""
|
||||||
|
cmd = await manager.create_command("greet", "Hello!")
|
||||||
|
assert cmd.name == "greet"
|
||||||
|
assert cmd.response == "Hello!"
|
||||||
|
assert cmd.cooldown == 5 # default_cooldown from config
|
||||||
|
assert stub_commands.exists("greet")
|
||||||
|
|
||||||
|
async def test_create_rejects_invalid_name(self, manager: CommandManager) -> None:
|
||||||
|
"""create_command raises InvalidNameError for bad names."""
|
||||||
|
with pytest.raises(InvalidNameError):
|
||||||
|
await manager.create_command("BAD!", "Hello!")
|
||||||
|
|
||||||
|
async def test_create_rejects_conflict(
|
||||||
|
self,
|
||||||
|
manager: CommandManager,
|
||||||
|
stub_commands: _StubModuleCommands,
|
||||||
|
) -> None:
|
||||||
|
"""create_command raises CommandAlreadyExistsError on conflict."""
|
||||||
|
stub_commands.seed_external("about")
|
||||||
|
with pytest.raises(CommandAlreadyExistsError) as exc_info:
|
||||||
|
await manager.create_command("about", "Info")
|
||||||
|
assert exc_info.value.name == "about"
|
||||||
|
|
||||||
|
async def test_create_rollback_on_db_failure(
|
||||||
|
self,
|
||||||
|
cmd_ctx: ModuleContext,
|
||||||
|
stub_commands: _StubModuleCommands,
|
||||||
|
) -> None:
|
||||||
|
"""If DB insert fails, the registry registration is rolled back."""
|
||||||
|
# Create a manager with a repo whose storage is closed.
|
||||||
|
closed_storage = ModuleStorage(None, "closed")
|
||||||
|
bad_repo = CommandRepository(closed_storage)
|
||||||
|
mgr = CommandManager(cmd_ctx, bad_repo)
|
||||||
|
with pytest.raises((StorageError, AttributeError)):
|
||||||
|
await mgr.create_command("greet", "Hello!")
|
||||||
|
assert not stub_commands.exists("greet")
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerEditCommand:
|
||||||
|
"""CommandManager.edit_command() updates a command's response."""
|
||||||
|
|
||||||
|
async def test_edit_command(self, manager: CommandManager) -> None:
|
||||||
|
"""edit_command updates the response text."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
updated = await manager.edit_command("greet", "Hi there!")
|
||||||
|
assert updated.response == "Hi there!"
|
||||||
|
|
||||||
|
async def test_edit_resolves_alias(self, manager: CommandManager) -> None:
|
||||||
|
"""edit_command resolves an alias to the canonical command."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
await manager.add_alias("greet", "hi")
|
||||||
|
updated = await manager.edit_command("hi", "Hi there!")
|
||||||
|
assert updated.name == "greet"
|
||||||
|
assert updated.response == "Hi there!"
|
||||||
|
|
||||||
|
async def test_edit_not_found(self, manager: CommandManager) -> None:
|
||||||
|
"""edit_command raises CommandNotFoundError for missing commands."""
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await manager.edit_command("nonexistent", "Hi!")
|
||||||
|
|
||||||
|
async def test_edit_not_custom(
|
||||||
|
self,
|
||||||
|
manager: CommandManager,
|
||||||
|
stub_commands: _StubModuleCommands,
|
||||||
|
) -> None:
|
||||||
|
"""edit_command raises NotCustomCommandError for built-in commands."""
|
||||||
|
stub_commands.seed_external("about")
|
||||||
|
with pytest.raises(NotCustomCommandError):
|
||||||
|
await manager.edit_command("about", "Hacked!")
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerDeleteCommand:
|
||||||
|
"""CommandManager.delete_command() deletes and unregisters commands."""
|
||||||
|
|
||||||
|
async def test_delete_command(
|
||||||
|
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||||
|
) -> None:
|
||||||
|
"""delete_command removes the command and unregisters its trigger."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
deleted = await manager.delete_command("greet")
|
||||||
|
assert deleted.name == "greet"
|
||||||
|
assert not stub_commands.exists("greet")
|
||||||
|
|
||||||
|
async def test_delete_rejects_alias(self, manager: CommandManager) -> None:
|
||||||
|
"""delete_command raises CannotDeleteByAliasError for aliases."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
await manager.add_alias("greet", "hi")
|
||||||
|
with pytest.raises(CannotDeleteByAliasError) as exc_info:
|
||||||
|
await manager.delete_command("hi")
|
||||||
|
assert exc_info.value.alias == "hi"
|
||||||
|
assert exc_info.value.canonical == "greet"
|
||||||
|
|
||||||
|
async def test_delete_not_found(self, manager: CommandManager) -> None:
|
||||||
|
"""delete_command raises CommandNotFoundError for missing commands."""
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await manager.delete_command("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerSetModOnly:
|
||||||
|
"""CommandManager.set_mod_only() updates moderator access."""
|
||||||
|
|
||||||
|
async def test_set_mod_only_on(
|
||||||
|
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||||
|
) -> None:
|
||||||
|
"""set_mod_only enables moderator-only access."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
updated = await manager.set_mod_only("greet", enabled=True)
|
||||||
|
assert updated.requires_moderator is True
|
||||||
|
assert stub_commands._registered["greet"]["requires_moderator"] is True
|
||||||
|
|
||||||
|
async def test_set_mod_only_off(self, manager: CommandManager) -> None:
|
||||||
|
"""set_mod_only disables moderator-only access."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
await manager.set_mod_only("greet", enabled=True)
|
||||||
|
updated = await manager.set_mod_only("greet", enabled=False)
|
||||||
|
assert updated.requires_moderator is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerSetCooldown:
|
||||||
|
"""CommandManager.set_cooldown() updates cooldown."""
|
||||||
|
|
||||||
|
async def test_set_cooldown(
|
||||||
|
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||||
|
) -> None:
|
||||||
|
"""set_cooldown updates the cooldown and re-registers the command."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
updated = await manager.set_cooldown("greet", 30)
|
||||||
|
assert updated.cooldown == 30
|
||||||
|
assert stub_commands._registered["greet"]["cooldown"] == 30
|
||||||
|
|
||||||
|
async def test_set_cooldown_zero(self, manager: CommandManager) -> None:
|
||||||
|
"""set_cooldown accepts zero to disable cooldown."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
updated = await manager.set_cooldown("greet", 0)
|
||||||
|
assert updated.cooldown == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerResetUseCount:
|
||||||
|
"""CommandManager.reset_use_count() zeroes the counter."""
|
||||||
|
|
||||||
|
async def test_reset_use_count(self, manager: CommandManager) -> None:
|
||||||
|
"""reset_use_count sets the counter back to zero."""
|
||||||
|
await manager.create_command("greet", "$(count)")
|
||||||
|
await manager.execute_command("greet", [], "tester")
|
||||||
|
reset = await manager.reset_use_count("greet")
|
||||||
|
assert reset.use_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerAddAlias:
|
||||||
|
"""CommandManager.add_alias() adds and re-registers aliases."""
|
||||||
|
|
||||||
|
async def test_add_alias(
|
||||||
|
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||||
|
) -> None:
|
||||||
|
"""add_alias registers a new alias for the command."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
cmd = await manager.add_alias("greet", "hi")
|
||||||
|
assert "hi" in cmd.aliases
|
||||||
|
assert stub_commands.exists("hi")
|
||||||
|
|
||||||
|
async def test_add_alias_rejects_canonical(self, manager: CommandManager) -> None:
|
||||||
|
"""add_alias raises AliasIsCanonicalNameError for the canonical name."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
with pytest.raises(AliasIsCanonicalNameError):
|
||||||
|
await manager.add_alias("greet", "greet")
|
||||||
|
|
||||||
|
async def test_add_alias_rejects_conflict(
|
||||||
|
self,
|
||||||
|
manager: CommandManager,
|
||||||
|
stub_commands: _StubModuleCommands,
|
||||||
|
) -> None:
|
||||||
|
"""add_alias raises CommandAlreadyExistsError on trigger conflict."""
|
||||||
|
stub_commands.seed_external("about")
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
with pytest.raises(CommandAlreadyExistsError):
|
||||||
|
await manager.add_alias("greet", "about")
|
||||||
|
|
||||||
|
async def test_add_alias_invalid_name(self, manager: CommandManager) -> None:
|
||||||
|
"""add_alias raises InvalidNameError for bad alias names."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
with pytest.raises(InvalidNameError):
|
||||||
|
await manager.add_alias("greet", "BAD!")
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerRemoveAlias:
|
||||||
|
"""CommandManager.remove_alias() removes and re-registers aliases."""
|
||||||
|
|
||||||
|
async def test_remove_alias(
|
||||||
|
self, manager: CommandManager, stub_commands: _StubModuleCommands
|
||||||
|
) -> None:
|
||||||
|
"""remove_alias removes the alias and keeps the canonical command."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
await manager.add_alias("greet", "hi")
|
||||||
|
cmd = await manager.remove_alias("hi")
|
||||||
|
assert "hi" not in cmd.aliases
|
||||||
|
assert not stub_commands.exists("hi")
|
||||||
|
assert stub_commands.exists("greet")
|
||||||
|
|
||||||
|
async def test_remove_alias_not_found(self, manager: CommandManager) -> None:
|
||||||
|
"""remove_alias raises CommandNotFoundError for missing aliases."""
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await manager.remove_alias("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerExecuteCommand:
|
||||||
|
"""CommandManager.execute_command() increments count and processes response."""
|
||||||
|
|
||||||
|
async def test_execute_plain(self, manager: CommandManager) -> None:
|
||||||
|
"""execute_command returns the plain response text."""
|
||||||
|
await manager.create_command("greet", "Hello!")
|
||||||
|
response = await manager.execute_command("greet", [], "tester")
|
||||||
|
assert response == "Hello!"
|
||||||
|
|
||||||
|
async def test_execute_increments_use_count(self, manager: CommandManager) -> None:
|
||||||
|
"""execute_command increments use_count on each call."""
|
||||||
|
await manager.create_command("greet", "Count: $(count)")
|
||||||
|
r1 = await manager.execute_command("greet", [], "tester")
|
||||||
|
assert r1 == "Count: 1"
|
||||||
|
r2 = await manager.execute_command("greet", [], "tester")
|
||||||
|
assert r2 == "Count: 2"
|
||||||
|
|
||||||
|
async def test_execute_user_placeholder(self, manager: CommandManager) -> None:
|
||||||
|
"""execute_command substitutes the $(user) placeholder."""
|
||||||
|
await manager.create_command("greet", "Hello $(user)!")
|
||||||
|
response = await manager.execute_command("greet", [], "Alice")
|
||||||
|
assert response == "Hello Alice!"
|
||||||
|
|
||||||
|
async def test_execute_not_found(self, manager: CommandManager) -> None:
|
||||||
|
"""execute_command raises CommandNotFoundError for missing commands."""
|
||||||
|
with pytest.raises(CommandNotFoundError):
|
||||||
|
await manager.execute_command("nonexistent", [], "tester")
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerEditCounter:
|
||||||
|
"""CommandManager.edit_counter() sets/adjusts named counters."""
|
||||||
|
|
||||||
|
async def test_edit_counter_absolute(self, manager: CommandManager) -> None:
|
||||||
|
"""edit_counter sets an absolute counter value."""
|
||||||
|
result = await manager.edit_counter("deaths", value=42, relative=False)
|
||||||
|
assert result == 42
|
||||||
|
|
||||||
|
async def test_edit_counter_relative_increment(
|
||||||
|
self, manager: CommandManager
|
||||||
|
) -> None:
|
||||||
|
"""edit_counter increments a counter with a positive delta."""
|
||||||
|
await manager.edit_counter("deaths", value=10, relative=False)
|
||||||
|
result = await manager.edit_counter("deaths", value=5, relative=True)
|
||||||
|
assert result == 15
|
||||||
|
|
||||||
|
async def test_edit_counter_relative_decrement(
|
||||||
|
self, manager: CommandManager
|
||||||
|
) -> None:
|
||||||
|
"""edit_counter decrements a counter with a negative delta."""
|
||||||
|
await manager.edit_counter("deaths", value=10, relative=False)
|
||||||
|
result = await manager.edit_counter("deaths", value=-3, relative=True)
|
||||||
|
assert result == 7
|
||||||
|
|
||||||
|
async def test_edit_counter_invalid_name(self, manager: CommandManager) -> None:
|
||||||
|
"""edit_counter raises InvalidNameError for bad counter names."""
|
||||||
|
with pytest.raises(InvalidNameError):
|
||||||
|
await manager.edit_counter("BAD!", value=5, relative=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerCounterAccessor:
|
||||||
|
"""CommandManager as CounterAccessor protocol for placeholders."""
|
||||||
|
|
||||||
|
async def test_get_counter(self, manager: CommandManager) -> None:
|
||||||
|
"""Return 0 for missing counter, correct value for existing."""
|
||||||
|
assert await manager.get_counter("deaths") == 0
|
||||||
|
await manager.set_counter("deaths", 10)
|
||||||
|
assert await manager.get_counter("deaths") == 10
|
||||||
|
|
||||||
|
async def test_set_counter(self, manager: CommandManager) -> None:
|
||||||
|
"""Set a counter to an absolute value."""
|
||||||
|
result = await manager.set_counter("deaths", 42)
|
||||||
|
assert result == 42
|
||||||
|
|
||||||
|
async def test_adjust_counter(self, manager: CommandManager) -> None:
|
||||||
|
"""Adjust a counter by a relative delta."""
|
||||||
|
await manager.set_counter("deaths", 10)
|
||||||
|
result = await manager.adjust_counter("deaths", 5)
|
||||||
|
assert result == 15
|
||||||
|
|
||||||
|
async def test_execute_with_named_counter(self, manager: CommandManager) -> None:
|
||||||
|
"""Execute a command that uses a named counter placeholder."""
|
||||||
|
await manager.create_command("deaths", "Deaths: $(count deaths)")
|
||||||
|
r1 = await manager.execute_command("deaths", [], "tester")
|
||||||
|
assert r1 == "Deaths: 1"
|
||||||
|
r2 = await manager.execute_command("deaths", [], "tester")
|
||||||
|
assert r2 == "Deaths: 2"
|
||||||
|
|
||||||
|
async def test_execute_with_getcount(self, manager: CommandManager) -> None:
|
||||||
|
"""Execute a command that reads a named counter."""
|
||||||
|
await manager.create_command("show", "Deaths: $(getcount deaths)")
|
||||||
|
await manager.set_counter("deaths", 42)
|
||||||
|
response = await manager.execute_command("show", [], "tester")
|
||||||
|
assert response == "Deaths: 42"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetManager:
|
||||||
|
"""get_manager() helper."""
|
||||||
|
|
||||||
|
def test_returns_manager(
|
||||||
|
self, cmd_ctx: ModuleContext, repo: CommandRepository
|
||||||
|
) -> None:
|
||||||
|
"""get_manager returns the manager stored in context state."""
|
||||||
|
mgr = CommandManager(cmd_ctx, repo)
|
||||||
|
cmd_ctx.state["manager"] = mgr
|
||||||
|
assert get_manager(cmd_ctx) is mgr
|
||||||
|
|
||||||
|
def test_raises_if_not_initialized(self, cmd_ctx: ModuleContext) -> None:
|
||||||
|
"""get_manager raises RuntimeError when no manager is stored."""
|
||||||
|
with pytest.raises(RuntimeError, match="CommandManager is not initialized"):
|
||||||
|
get_manager(cmd_ctx)
|
||||||
@@ -29,6 +29,7 @@ from freezegun import freeze_time
|
|||||||
|
|
||||||
from owlbot.builtin_modules.custom_commands.placeholder_handlers import _format_duration
|
from owlbot.builtin_modules.custom_commands.placeholder_handlers import _format_duration
|
||||||
from owlbot.builtin_modules.custom_commands.placeholders import process_placeholders
|
from owlbot.builtin_modules.custom_commands.placeholders import process_placeholders
|
||||||
|
from owlbot.builtin_modules.custom_commands.types import Command
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from owlbot.api.storage import ModuleStorage
|
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
|
@pytest.fixture
|
||||||
async def placeholder_storage(storage: ModuleStorage) -> ModuleStorage:
|
async def placeholder_storage(storage: ModuleStorage) -> ModuleStorage:
|
||||||
"""Storage instance with the counters table created."""
|
"""Storage instance with the counters table created."""
|
||||||
@@ -60,8 +104,8 @@ async def process(
|
|||||||
template,
|
template,
|
||||||
args or [],
|
args or [],
|
||||||
user,
|
user,
|
||||||
use_count,
|
_make_command(use_count),
|
||||||
storage,
|
_StorageCounterAccessor(storage),
|
||||||
max_depth=max_depth,
|
max_depth=max_depth,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user