Initial commit.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# 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.
|
||||
|
||||
"""Custom commands module for Owlbot.
|
||||
|
||||
Allows moderators to create, edit, delete, and list custom chat commands at runtime.
|
||||
Custom commands are stored in SQLite and dynamically registered
|
||||
with the CommandRegistry.
|
||||
"""
|
||||
|
||||
from owlbot.api import ModuleContext, on_setup
|
||||
|
||||
from .handler import custom_command_handler
|
||||
|
||||
# Re-export decorated handlers so the module loader discovers them.
|
||||
from .management_commands import (
|
||||
addalias,
|
||||
addcommand,
|
||||
commandcooldown,
|
||||
commandmodonly,
|
||||
deletecommand,
|
||||
editcommand,
|
||||
listcommands,
|
||||
removealias,
|
||||
resetcommand,
|
||||
)
|
||||
from .routes import command_list_page
|
||||
|
||||
__all__ = [
|
||||
"addalias",
|
||||
"addcommand",
|
||||
"command_list_page",
|
||||
"commandcooldown",
|
||||
"commandmodonly",
|
||||
"deletecommand",
|
||||
"editcommand",
|
||||
"listcommands",
|
||||
"removealias",
|
||||
"resetcommand",
|
||||
"setup",
|
||||
]
|
||||
|
||||
|
||||
@on_setup
|
||||
async def setup(ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Initialize the custom_commands module.
|
||||
|
||||
Creates the database schema and loads existing commands from the database.
|
||||
|
||||
:param ctx: Module context with config, storage, and other services.
|
||||
"""
|
||||
ctx.config.register_defaults({"max_nesting_depth": 4, "default_cooldown": 5})
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS commands (
|
||||
name TEXT PRIMARY KEY NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
use_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
requires_moderator INTEGER DEFAULT 0,
|
||||
cooldown INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS command_aliases (
|
||||
alias TEXT PRIMARY KEY NOT NULL,
|
||||
command_name TEXT NOT NULL,
|
||||
FOREIGN KEY (command_name) REFERENCES commands(name) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
name TEXT PRIMARY KEY NOT NULL,
|
||||
value INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT c.name, c.requires_moderator, c.cooldown, "
|
||||
"GROUP_CONCAT(ca.alias) AS aliases "
|
||||
"FROM commands c "
|
||||
"LEFT JOIN command_aliases ca ON c.name = ca.command_name "
|
||||
"GROUP BY c.name"
|
||||
)
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
aliases = row["aliases"].split(",") if row["aliases"] else []
|
||||
try:
|
||||
ctx.commands.register(
|
||||
name=row["name"],
|
||||
handler=custom_command_handler,
|
||||
aliases=aliases,
|
||||
requires_moderator=bool(row["requires_moderator"]),
|
||||
cooldown=row["cooldown"],
|
||||
)
|
||||
except ValueError:
|
||||
ctx.logger.warning(
|
||||
f"Skipping custom command '{row['name']}': "
|
||||
"conflicts with an existing command."
|
||||
)
|
||||
skipped += 1
|
||||
|
||||
loaded_count = len(rows) - skipped
|
||||
ctx.logger.info(f"Loaded {loaded_count} custom command(s) from database.")
|
||||
if skipped:
|
||||
ctx.logger.info(f"Skipped {skipped} conflicting custom command(s).")
|
||||
@@ -0,0 +1,175 @@
|
||||
# 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 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,
|
||||
)
|
||||
@@ -0,0 +1,484 @@
|
||||
# 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.)."""
|
||||
|
||||
import re
|
||||
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,
|
||||
)
|
||||
|
||||
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 re.match(r"^[a-z0-9_]+$", 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("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 re.match(r"^[a-z0-9_]+$", 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,477 @@
|
||||
# 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.
|
||||
|
||||
"""Handler functions for placeholder resolution.
|
||||
|
||||
Each handler is a plain async function that receives the placeholder name,
|
||||
pre-resolved argument list, and a :class:`PlaceholderContext`, and returns the
|
||||
replacement string. Handlers raise :exc:`PlaceholderError` to report bad
|
||||
arguments; the engine immediately stops processing and returns ``str(e)`` as
|
||||
the entire response, discarding any other template content.
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .placeholders import PlaceholderContext
|
||||
|
||||
|
||||
class PlaceholderError(Exception):
|
||||
"""Raised by a placeholder handler to report a resolution error."""
|
||||
|
||||
|
||||
# Sourced from the Wikipedia "List of time zone abbreviations" article, which
|
||||
# compiles data from the IANA Time Zone Database and other references.
|
||||
# https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations
|
||||
#
|
||||
# Keys are uppercase because the lookup normalises user input with .upper().
|
||||
# Ambiguous abbreviations use the most common interpretation:
|
||||
# ACT = Acre Time (-5), not ASEAN Common Time (+8)
|
||||
# AMT = Amazon Time (-4), not Armenia Time (+4)
|
||||
# AST = Atlantic Standard Time (-4), not Arabia Standard Time (+3)
|
||||
# BST = British Summer Time (+1), not Bangladesh/Bougainville
|
||||
# CDT = Central Daylight Time (-5), not Cuba Daylight Time (-4)
|
||||
# CST = Central Standard Time (-6), not China (+8) or Cuba (-5)
|
||||
# ECT = Ecuador Time (-5), not Eastern Caribbean Time (-4)
|
||||
# GST = Gulf Standard Time (+4), not South Georgia Time (-2)
|
||||
# IST = India Standard Time (+5:30), not Irish (+1) or Israel (+2)
|
||||
# LHST = Lord Howe Standard Time (+10:30), not summer (+11)
|
||||
# MST = Mountain Standard Time (-7), not Malaysia Standard Time (+8)
|
||||
# PST = Pacific Standard Time (-8), not Philippine Standard Time (+8)
|
||||
TIMEZONE_OFFSETS: dict[str, float] = {
|
||||
"ACDT": 10.5,
|
||||
"ACST": 9.5,
|
||||
"ACT": -5,
|
||||
"ACWST": 8.75,
|
||||
"ADT": -3,
|
||||
"AEDT": 11,
|
||||
"AEST": 10,
|
||||
"AFT": 4.5,
|
||||
"AKDT": -8,
|
||||
"AKST": -9,
|
||||
"ALMT": 6,
|
||||
"AMST": -3,
|
||||
"AMT": -4,
|
||||
"ANAT": 12,
|
||||
"AQTT": 5,
|
||||
"ART": -3,
|
||||
"AST": -4,
|
||||
"AWST": 8,
|
||||
"AZOST": 0,
|
||||
"AZOT": -1,
|
||||
"AZT": 4,
|
||||
"BIT": -12,
|
||||
"BIOT": 6,
|
||||
"BNT": 8,
|
||||
"BOT": -4,
|
||||
"BRST": -2,
|
||||
"BRT": -3,
|
||||
"BST": 1,
|
||||
"BTT": 6,
|
||||
"CAT": 2,
|
||||
"CCT": 6.5,
|
||||
"CDT": -5,
|
||||
"CEST": 2,
|
||||
"CET": 1,
|
||||
"CHADT": 13.75,
|
||||
"CHAST": 12.75,
|
||||
"CHOT": 8,
|
||||
"CHOST": 9,
|
||||
"CHST": 10,
|
||||
"CHUT": 10,
|
||||
"CIST": -8,
|
||||
"CKT": -10,
|
||||
"CLST": -3,
|
||||
"CLT": -4,
|
||||
"COST": -4,
|
||||
"COT": -5,
|
||||
"CST": -6,
|
||||
"CVT": -1,
|
||||
"CWST": 8.75,
|
||||
"CXT": 7,
|
||||
"DAVT": 7,
|
||||
"DDUT": 10,
|
||||
"DFT": 1,
|
||||
"EASST": -5,
|
||||
"EAST": -6,
|
||||
"EAT": 3,
|
||||
"ECT": -5,
|
||||
"EDT": -4,
|
||||
"EEST": 3,
|
||||
"EET": 2,
|
||||
"EGST": 0,
|
||||
"EGT": -1,
|
||||
"EST": -5,
|
||||
"FET": 3,
|
||||
"FJT": 12,
|
||||
"FKST": -3,
|
||||
"FKT": -4,
|
||||
"FNT": -2,
|
||||
"GALT": -6,
|
||||
"GAMT": -9,
|
||||
"GET": 4,
|
||||
"GFT": -3,
|
||||
"GILT": 12,
|
||||
"GIT": -9,
|
||||
"GMT": 0,
|
||||
"GST": 4,
|
||||
"GYT": -4,
|
||||
"HAEC": 2,
|
||||
"HDT": -9,
|
||||
"HKT": 8,
|
||||
"HMT": 5,
|
||||
"HOVST": 8,
|
||||
"HOVT": 7,
|
||||
"HST": -10,
|
||||
"ICT": 7,
|
||||
"IDLW": -12,
|
||||
"IDT": 3,
|
||||
"IOT": 6,
|
||||
"IRDT": 4.5,
|
||||
"IRKT": 8,
|
||||
"IRST": 3.5,
|
||||
"IST": 5.5,
|
||||
"JST": 9,
|
||||
"KALT": 2,
|
||||
"KGT": 6,
|
||||
"KOST": 11,
|
||||
"KRAT": 7,
|
||||
"KST": 9,
|
||||
"LHST": 10.5,
|
||||
"LINT": 14,
|
||||
"MAGT": 12,
|
||||
"MART": -9.5,
|
||||
"MAWT": 5,
|
||||
"MDT": -6,
|
||||
"MEST": 2,
|
||||
"MET": 1,
|
||||
"MHT": 12,
|
||||
"MIST": 11,
|
||||
"MIT": -9.5,
|
||||
"MMT": 6.5,
|
||||
"MSK": 3,
|
||||
"MST": -7,
|
||||
"MUT": 4,
|
||||
"MVT": 5,
|
||||
"MYT": 8,
|
||||
"NCT": 11,
|
||||
"NDT": -2.5,
|
||||
"NFT": 11,
|
||||
"NOVT": 7,
|
||||
"NPT": 5.75,
|
||||
"NST": -3.5,
|
||||
"NT": -3.5,
|
||||
"NUT": -11,
|
||||
"NZDT": 13,
|
||||
"NZDST": 13,
|
||||
"NZST": 12,
|
||||
"OMST": 6,
|
||||
"ORAT": 5,
|
||||
"PDT": -7,
|
||||
"PET": -5,
|
||||
"PETT": 12,
|
||||
"PGT": 10,
|
||||
"PHOT": 13,
|
||||
"PHST": 8,
|
||||
"PHT": 8,
|
||||
"PKT": 5,
|
||||
"PMDT": -2,
|
||||
"PMST": -3,
|
||||
"PONT": 11,
|
||||
"PST": -8,
|
||||
"PWT": 9,
|
||||
"PYST": -3,
|
||||
"PYT": -4,
|
||||
"RET": 4,
|
||||
"ROTT": -3,
|
||||
"SAKT": 11,
|
||||
"SAMT": 4,
|
||||
"SAST": 2,
|
||||
"SBT": 11,
|
||||
"SCT": 4,
|
||||
"SDT": -10,
|
||||
"SGT": 8,
|
||||
"SLST": 5.5,
|
||||
"SRET": 11,
|
||||
"SRT": -3,
|
||||
"SST": -11,
|
||||
"SYOT": 3,
|
||||
"TAHT": -10,
|
||||
"TFT": 5,
|
||||
"THA": 7,
|
||||
"TJT": 5,
|
||||
"TKT": 13,
|
||||
"TLT": 9,
|
||||
"TMT": 5,
|
||||
"TOT": 13,
|
||||
"TRT": 3,
|
||||
"TST": 8,
|
||||
"TVT": 12,
|
||||
"ULAST": 9,
|
||||
"ULAT": 8,
|
||||
"UTC": 0,
|
||||
"UYST": -2,
|
||||
"UYT": -3,
|
||||
"UZT": 5,
|
||||
"VET": -4,
|
||||
"VLAT": 10,
|
||||
"VOLT": 3,
|
||||
"VOST": 6,
|
||||
"VUT": 11,
|
||||
"WAKT": 12,
|
||||
"WAST": 2,
|
||||
"WAT": 1,
|
||||
"WEST": 1,
|
||||
"WET": 0,
|
||||
"WGST": -2,
|
||||
"WGT": -3,
|
||||
"WIB": 7,
|
||||
"WIT": 9,
|
||||
"WITA": 8,
|
||||
"WST": 8,
|
||||
"YAKT": 9,
|
||||
"YEKT": 5,
|
||||
}
|
||||
|
||||
# Pattern for validating counter names.
|
||||
_COUNTER_NAME_RE = re.compile(r"^[a-z0-9_]+$")
|
||||
|
||||
|
||||
def _parse_placeholder_date(date_str: str) -> datetime | None:
|
||||
"""Parse a placeholder date string.
|
||||
|
||||
Format: ``"Dec 25 2025 12:00:00 AM EST"``
|
||||
|
||||
:param date_str: Date string to parse.
|
||||
:return: datetime in UTC, or ``None`` if parsing fails.
|
||||
"""
|
||||
# Split off the timezone abbreviation (last token).
|
||||
parts = date_str.rsplit(maxsplit=1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
date_part, tz_abbrev = parts
|
||||
offset = TIMEZONE_OFFSETS.get(tz_abbrev.upper())
|
||||
if offset is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
dt = datetime.strptime(date_part, "%b %d %Y %I:%M:%S %p")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Convert to UTC by subtracting the offset.
|
||||
return (dt - timedelta(hours=offset)).replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _format_duration(seconds: int) -> str:
|
||||
"""Format seconds as human-readable duration.
|
||||
|
||||
Example: ``"1 day 3 hours 20 minutes 30 seconds"``
|
||||
|
||||
:param seconds: Total seconds (positive).
|
||||
:return: Human-readable duration string.
|
||||
"""
|
||||
days, remainder = divmod(seconds, 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
|
||||
parts = []
|
||||
if days:
|
||||
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||
if hours:
|
||||
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||
if minutes:
|
||||
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||
if secs or not parts:
|
||||
parts.append(f"{secs} second{'s' if secs != 1 else ''}")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
async def _evaluate_arg(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(1)`` through ``$(9)`` -- return the positional argument or ``""``."""
|
||||
if args:
|
||||
raise PlaceholderError(f"Invalid $({name}): does not accept arguments")
|
||||
index = int(name) - 1
|
||||
if 0 <= index < len(ctx.args_list):
|
||||
return ctx.args_list[index]
|
||||
return ""
|
||||
|
||||
|
||||
async def _evaluate_user(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(user)`` -- return the invoking user's display name."""
|
||||
if args:
|
||||
raise PlaceholderError("Invalid $(user): does not accept arguments")
|
||||
return ctx.user_display_name
|
||||
|
||||
|
||||
async def _evaluate_count(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(count)`` / ``$(count name [mod])`` -- use count or named counter."""
|
||||
if not args:
|
||||
# No arguments: return the command's use_count (backward compatible).
|
||||
return str(ctx.use_count)
|
||||
|
||||
# Named counter.
|
||||
counter_name = args[0].lower()
|
||||
if not _COUNTER_NAME_RE.match(counter_name):
|
||||
raise PlaceholderError(
|
||||
"Invalid $(count): counter name may only contain "
|
||||
"letters, numbers, and underscores"
|
||||
)
|
||||
modifier_str = args[1] if len(args) > 1 else "+1"
|
||||
|
||||
if len(args) > 2:
|
||||
raise PlaceholderError(
|
||||
"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
|
||||
row = await ctx.storage.fetch_one(
|
||||
"INSERT INTO counters (name, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(name) DO UPDATE SET value = value + ? "
|
||||
"RETURNING value",
|
||||
(counter_name, delta, delta),
|
||||
)
|
||||
if row is None:
|
||||
raise PlaceholderError("Internal error: counter update failed")
|
||||
return str(row["value"])
|
||||
else:
|
||||
try:
|
||||
value = int(modifier_str)
|
||||
except ValueError as e:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
||||
) from e
|
||||
row = await ctx.storage.fetch_one(
|
||||
"INSERT OR REPLACE INTO counters (name, value) VALUES (?, ?) "
|
||||
"RETURNING value",
|
||||
(counter_name, value),
|
||||
)
|
||||
if row is None:
|
||||
raise PlaceholderError("Internal error: counter update failed")
|
||||
return str(row["value"])
|
||||
|
||||
|
||||
async def _evaluate_getcount(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(getcount name)`` -- read a named counter's value."""
|
||||
if not args:
|
||||
raise PlaceholderError("Invalid $(getcount): a counter name is required")
|
||||
if len(args) > 1:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(getcount): too many arguments, expected $(getcount name)"
|
||||
)
|
||||
counter_name = args[0].lower()
|
||||
if not _COUNTER_NAME_RE.match(counter_name):
|
||||
raise PlaceholderError(
|
||||
"Invalid $(getcount): counter name may only contain "
|
||||
"letters, numbers, and underscores"
|
||||
)
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT value FROM counters WHERE name = ?", (counter_name,)
|
||||
)
|
||||
return str(row["value"]) if row else "0"
|
||||
|
||||
|
||||
async def _evaluate_rand(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(rand start stop)`` -- random integer in range."""
|
||||
if len(args) < 2:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(rand): too few arguments, expected $(rand start stop)"
|
||||
)
|
||||
if len(args) > 2:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(rand): too many arguments, expected $(rand start stop)"
|
||||
)
|
||||
try:
|
||||
start = int(args[0])
|
||||
stop = int(args[1])
|
||||
except ValueError as e:
|
||||
raise PlaceholderError(
|
||||
"Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)"
|
||||
) from e
|
||||
return str(random.randint(min(start, stop), max(start, stop)))
|
||||
|
||||
|
||||
async def _evaluate_countdown(
|
||||
name: str,
|
||||
args: list[str],
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""``$(countdown date)`` / ``$(countup date)`` -- time delta."""
|
||||
date_str = " ".join(args)
|
||||
if not date_str.strip():
|
||||
raise PlaceholderError(
|
||||
f"Invalid $({name}): missing date, expected "
|
||||
f"$({name} Dec 25 2025 12:00:00 AM EST)"
|
||||
)
|
||||
target = _parse_placeholder_date(date_str)
|
||||
if target is None:
|
||||
raise PlaceholderError(
|
||||
f"Invalid $({name}): unrecognized date format, "
|
||||
f"expected $({name} Dec 25 2025 12:00:00 AM EST)"
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
delta = (target - now) if name == "countdown" else (now - target)
|
||||
seconds = int(delta.total_seconds())
|
||||
if seconds > 0:
|
||||
return _format_duration(seconds)
|
||||
else:
|
||||
return "0 seconds"
|
||||
|
||||
|
||||
type PlaceholderHandler = Callable[
|
||||
[str, list[str], "PlaceholderContext"], Awaitable[str]
|
||||
]
|
||||
|
||||
HANDLERS: dict[str, PlaceholderHandler] = {}
|
||||
|
||||
for _i in range(1, 10):
|
||||
HANDLERS[str(_i)] = _evaluate_arg
|
||||
HANDLERS["user"] = _evaluate_user
|
||||
HANDLERS["count"] = _evaluate_count
|
||||
HANDLERS["getcount"] = _evaluate_getcount
|
||||
HANDLERS["rand"] = _evaluate_rand
|
||||
HANDLERS["countdown"] = _evaluate_countdown
|
||||
HANDLERS["countup"] = _evaluate_countdown
|
||||
@@ -0,0 +1,394 @@
|
||||
# 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.
|
||||
|
||||
"""Recursive descent placeholder processing for custom commands.
|
||||
|
||||
This module implements an AST-based pipeline for parsing and evaluating
|
||||
placeholders in custom command response templates. The pipeline has two
|
||||
stages:
|
||||
|
||||
1. **Parser**: A recursive descent parser (``_parse``) converts a template
|
||||
string into a list of ``Node`` objects (``TextNode`` for literal text,
|
||||
``PlaceholderNode`` for ``$(...)`` expressions). Nesting is supported up to
|
||||
a configurable maximum depth.
|
||||
|
||||
2. **Evaluator**: An async tree-walker (``_evaluate``) resolves the AST
|
||||
inside-out: children of each ``PlaceholderNode`` are evaluated first, then
|
||||
the resulting flat content string is dispatched to the matching handler in
|
||||
``placeholder_handlers`` for final resolution.
|
||||
|
||||
Supported placeholders: ``$(1)``-``$(9)``, ``$(count)``, ``$(count name [mod])``,
|
||||
``$(getcount name)``, ``$(user)``, ``$(rand start stop)``, ``$(countdown date)``,
|
||||
``$(countup date)``.
|
||||
|
||||
Example AST::
|
||||
|
||||
Template: "$(rand $(1) $(2))"
|
||||
Parsed: [PlaceholderNode("rand", [PlaceholderNode("1", []),
|
||||
TextNode(" "),
|
||||
PlaceholderNode("2", [])])]
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
|
||||
from .placeholder_handlers import HANDLERS, PlaceholderError
|
||||
|
||||
DEFAULT_MAX_DEPTH: int = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextNode:
|
||||
"""A span of literal text that needs no further processing.
|
||||
|
||||
:param text: The literal text content.
|
||||
"""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaceholderNode:
|
||||
"""A ``$(name ...)`` placeholder expression.
|
||||
|
||||
:param name: The placeholder name, extracted as literal text from the
|
||||
template (e.g. ``"rand"``, ``"1"``, ``"user"``). Never contains
|
||||
nested placeholders.
|
||||
:param children: The parsed body content after the name. May contain
|
||||
nested ``PlaceholderNode`` instances (for dynamic arguments) or be
|
||||
empty for no-argument placeholders like ``$(user)``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
children: list[Node]
|
||||
|
||||
|
||||
type Node = TextNode | PlaceholderNode
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaceholderContext:
|
||||
"""Bundles the runtime state needed to resolve placeholders.
|
||||
|
||||
:param args_list: Command arguments (``$(1)``-``$(9)`` values).
|
||||
:param user_display_name: Display name of the invoking user.
|
||||
:param use_count: The command's current use count.
|
||||
:param storage: Module storage for database access.
|
||||
"""
|
||||
|
||||
args_list: list[str]
|
||||
user_display_name: str
|
||||
use_count: int
|
||||
storage: ModuleStorage
|
||||
|
||||
|
||||
# The parser uses recursive descent to convert a template string into a list
|
||||
# of Node objects. It handles escaping (``\$(...)``), nesting (``$(rand
|
||||
# $(1) $(2))``), unclosed placeholders (degraded to literal text), and a
|
||||
# configurable maximum nesting depth.
|
||||
|
||||
|
||||
def _find_matching_close(template: str, pos: int) -> int:
|
||||
"""Find the position of the ``)`` that closes an escaped ``\\$(...)`` group.
|
||||
|
||||
Tracks nested ``$(`` / ``)`` pairs so that escaped groups containing inner
|
||||
placeholders (e.g. ``\\$(rand $(1) $(2))``) are consumed in their entirety.
|
||||
|
||||
:param template: The full template string.
|
||||
:param pos: The position immediately after the opening ``$(`` of the
|
||||
escaped group (i.e. the first character of the content).
|
||||
:return: The index of the matching ``)``, or ``-1`` if not found.
|
||||
"""
|
||||
depth = 1
|
||||
i = pos
|
||||
length = len(template)
|
||||
while i < length:
|
||||
if template[i : i + 2] == "$(":
|
||||
depth += 1
|
||||
i += 2
|
||||
elif template[i] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return -1
|
||||
|
||||
|
||||
def _parse_placeholder(
|
||||
template: str,
|
||||
pos: int,
|
||||
depth: int,
|
||||
max_depth: int,
|
||||
) -> tuple[PlaceholderNode, int] | None:
|
||||
"""Parse a single placeholder after the opening ``$(`` has been consumed.
|
||||
|
||||
Reads the placeholder name (word characters up to a space, ``)``, ``$``,
|
||||
or end-of-string), then parses children if the name is followed by a space.
|
||||
|
||||
:param template: The full template string.
|
||||
:param pos: Position immediately after ``$(`` (start of the name).
|
||||
:param depth: Current nesting depth.
|
||||
:param max_depth: Maximum allowed nesting depth.
|
||||
:return: A ``(PlaceholderNode, new_pos)`` tuple, or ``None`` if the
|
||||
placeholder is invalid (e.g. empty name).
|
||||
"""
|
||||
length = len(template)
|
||||
|
||||
# Read the placeholder name: word characters (\w) up to a delimiter.
|
||||
name_start = pos
|
||||
while pos < length and template[pos] not in (" ", ")", "$"):
|
||||
if not (template[pos].isalnum() or template[pos] == "_"):
|
||||
break
|
||||
pos += 1
|
||||
|
||||
name = template[name_start:pos]
|
||||
|
||||
# Empty name (e.g. $() or $($(...))). Degrade to literal.
|
||||
if not name:
|
||||
return None
|
||||
|
||||
# No arguments: immediate close or end of string.
|
||||
if pos >= length:
|
||||
# Unclosed placeholder at end of string. Return None so the
|
||||
# caller degrades "$(" to literal text; the name characters will
|
||||
# be re-scanned as literals since the caller's pos only advances
|
||||
# past "$(".
|
||||
return None
|
||||
|
||||
if template[pos] == ")":
|
||||
# $(name) -- no children.
|
||||
return PlaceholderNode(name, []), pos + 1
|
||||
|
||||
if template[pos] == " ":
|
||||
# $(name ... ) -- parse children after the space delimiter.
|
||||
child_nodes, new_pos, found_close = _parse_nodes(
|
||||
template,
|
||||
pos + 1,
|
||||
depth + 1,
|
||||
inside_placeholder=True,
|
||||
max_depth=max_depth,
|
||||
)
|
||||
if found_close:
|
||||
return PlaceholderNode(name, child_nodes), new_pos
|
||||
else:
|
||||
# Unclosed placeholder, degrade to literal. Return None so
|
||||
# the caller emits "$(" as literal and re-scans the rest.
|
||||
return None
|
||||
|
||||
# The character after the name is something unexpected (e.g. another $).
|
||||
# Treat as unclosed/invalid -- degrade.
|
||||
if template[pos : pos + 2] == "$(":
|
||||
# Something like $(name$(...)) with no space. Degrade.
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_nodes(
|
||||
template: str,
|
||||
pos: int,
|
||||
depth: int,
|
||||
inside_placeholder: bool,
|
||||
max_depth: int,
|
||||
) -> tuple[list[Node], int, bool]:
|
||||
"""Core recursive parser loop.
|
||||
|
||||
Scans *template* starting at *pos*, accumulating literal characters and
|
||||
recognising ``$(...)`` placeholder openings.
|
||||
|
||||
:param template: The full template string.
|
||||
:param pos: Current scan position.
|
||||
:param depth: Current nesting depth (0 = top level).
|
||||
:param inside_placeholder: ``True`` when parsing the children of a
|
||||
``PlaceholderNode`` -- a bare ``)`` closes the current group.
|
||||
:param max_depth: Maximum allowed nesting depth.
|
||||
:return: A 3-tuple ``(nodes, new_pos, found_close)`` where *found_close*
|
||||
is ``True`` if scanning stopped because a matching ``)`` was found.
|
||||
"""
|
||||
nodes: list[Node] = []
|
||||
buf: list[str] = []
|
||||
length = len(template)
|
||||
|
||||
def flush_buffer() -> None:
|
||||
"""Flush accumulated literal characters as a TextNode."""
|
||||
if buf:
|
||||
nodes.append(TextNode("".join(buf)))
|
||||
buf.clear()
|
||||
|
||||
while pos < length:
|
||||
# Escaped placeholder: \$(...) becomes literal text.
|
||||
if template[pos] == "\\" and template[pos + 1 : pos + 3] == "$(":
|
||||
# Find the matching close paren, accounting for inner $( pairs.
|
||||
close = _find_matching_close(template, pos + 3)
|
||||
if close == -1:
|
||||
# No matching close, treat everything from here as literal.
|
||||
buf.append(template[pos:])
|
||||
pos = length
|
||||
else:
|
||||
# Emit the content (without the leading backslash) as literal.
|
||||
buf.append(template[pos + 1 : close + 1])
|
||||
pos = close + 1
|
||||
continue
|
||||
|
||||
# Placeholder opening: $(
|
||||
if template[pos : pos + 2] == "$(":
|
||||
# If we've hit the nesting limit, treat $( as literal text.
|
||||
if depth >= max_depth:
|
||||
buf.append("$(")
|
||||
pos += 2
|
||||
continue
|
||||
|
||||
flush_buffer()
|
||||
|
||||
# Delegate to _parse_placeholder for name extraction and children.
|
||||
result = _parse_placeholder(template, pos + 2, depth, max_depth)
|
||||
if result is None:
|
||||
# Failed to parse a valid placeholder (empty name, etc.).
|
||||
# Degrade the $( to literal text and continue scanning.
|
||||
buf.append("$(")
|
||||
pos += 2
|
||||
else:
|
||||
node, pos = result
|
||||
nodes.append(node)
|
||||
continue
|
||||
|
||||
# Closing paren while inside a placeholder's children.
|
||||
if template[pos] == ")" and inside_placeholder:
|
||||
flush_buffer()
|
||||
return nodes, pos + 1, True
|
||||
|
||||
# Ordinary character: accumulate into the literal buffer.
|
||||
buf.append(template[pos])
|
||||
pos += 1
|
||||
|
||||
flush_buffer()
|
||||
return nodes, pos, False
|
||||
|
||||
|
||||
def _parse(template: str, max_depth: int = DEFAULT_MAX_DEPTH) -> list[Node]:
|
||||
"""Parse a template string into an AST of ``Node`` objects.
|
||||
|
||||
This is the entry point for the parser stage.
|
||||
|
||||
:param template: The response template with placeholders.
|
||||
:param max_depth: Maximum nesting depth for placeholders. ``$(`` tokens
|
||||
encountered at or beyond this depth are treated as literal text.
|
||||
:return: List of top-level nodes.
|
||||
|
||||
Example::
|
||||
|
||||
>>> _parse("Hello $(user)!")
|
||||
[TextNode("Hello "), PlaceholderNode("user", []), TextNode("!")]
|
||||
|
||||
>>> _parse("$(rand $(1) $(2))")
|
||||
[PlaceholderNode("rand", [PlaceholderNode("1", []),
|
||||
TextNode(" "),
|
||||
PlaceholderNode("2", [])])]
|
||||
"""
|
||||
nodes, _, _ = _parse_nodes(
|
||||
template, 0, 0, inside_placeholder=False, max_depth=max_depth
|
||||
)
|
||||
return nodes
|
||||
|
||||
|
||||
# Evaluation proceeds inside-out: for each PlaceholderNode the evaluator first
|
||||
# recursively evaluates all children to produce a flat args string, then
|
||||
# dispatches to the matching handler from HANDLERS.
|
||||
|
||||
|
||||
async def _evaluate_placeholder(
|
||||
node: PlaceholderNode,
|
||||
ctx: PlaceholderContext,
|
||||
) -> str:
|
||||
"""Evaluate a single ``PlaceholderNode``.
|
||||
|
||||
Children are evaluated first (inside-out) and the resulting string is
|
||||
split on whitespace to form the argument list. The handler for the
|
||||
placeholder name is then looked up and called with those arguments.
|
||||
|
||||
:param node: The placeholder node to evaluate.
|
||||
:param ctx: Runtime context for placeholder resolution.
|
||||
:return: The resolved replacement string.
|
||||
"""
|
||||
name = node.name.lower()
|
||||
if node.children:
|
||||
args_str = await _evaluate(node.children, ctx)
|
||||
args = args_str.split()
|
||||
else:
|
||||
args = []
|
||||
|
||||
handler = HANDLERS.get(name)
|
||||
if handler is None:
|
||||
content = name + (" " + " ".join(args) if args else "")
|
||||
return f"$({content})"
|
||||
|
||||
return await handler(name, args, ctx)
|
||||
|
||||
|
||||
async def _evaluate(nodes: list[Node], ctx: PlaceholderContext) -> str:
|
||||
"""Walk the AST and produce the final output string.
|
||||
|
||||
:param nodes: List of parsed nodes from ``_parse``.
|
||||
:param ctx: Runtime context for placeholder resolution.
|
||||
:return: The fully resolved string.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for node in nodes:
|
||||
if isinstance(node, TextNode):
|
||||
parts.append(node.text)
|
||||
else:
|
||||
parts.append(await _evaluate_placeholder(node, ctx))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
async def process_placeholders(
|
||||
template: str,
|
||||
args_list: list[str],
|
||||
user_display_name: str,
|
||||
use_count: int,
|
||||
storage: ModuleStorage,
|
||||
max_depth: int = DEFAULT_MAX_DEPTH,
|
||||
) -> str:
|
||||
"""Replace placeholders in a response template.
|
||||
|
||||
Parses the template into an AST, then evaluates it to produce the final
|
||||
output string with all placeholders resolved.
|
||||
|
||||
If any placeholder raises :exc:`PlaceholderError`, evaluation stops
|
||||
immediately and the error message is returned as the entire response.
|
||||
|
||||
:param template: The response template with placeholders.
|
||||
:param args_list: List of arguments passed to the command.
|
||||
:param user_display_name: The executing user's display name.
|
||||
:param use_count: The command's current use count.
|
||||
:param storage: Module storage for database access.
|
||||
:param max_depth: Maximum nesting depth for placeholders.
|
||||
:return: The processed response string, or the error message on failure.
|
||||
"""
|
||||
nodes = _parse(template, max_depth=max_depth)
|
||||
ctx = PlaceholderContext(
|
||||
args_list=args_list,
|
||||
user_display_name=user_display_name,
|
||||
use_count=use_count,
|
||||
storage=storage,
|
||||
)
|
||||
try:
|
||||
return await _evaluate(nodes, ctx)
|
||||
except PlaceholderError as e:
|
||||
return str(e)
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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.
|
||||
|
||||
"""Web routes for the custom commands module."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
from aiohttp import web
|
||||
|
||||
from owlbot.api import RouteContext, on_route
|
||||
|
||||
_template_dir = Path(__file__).resolve().parent / "templates"
|
||||
_jinja_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(_template_dir),
|
||||
autoescape=True,
|
||||
)
|
||||
|
||||
|
||||
@on_route("/list", methods=["GET"])
|
||||
async def command_list_page(ctx: RouteContext) -> web.Response:
|
||||
"""
|
||||
Serve an HTML page listing all custom commands in a table.
|
||||
|
||||
Columns: Command, Aliases, Response, Cooldown, Permissions.
|
||||
Accessible at /owlbot/custom_commands/list.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: HTML response with the command list table.
|
||||
"""
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT c.name, c.response, c.requires_moderator, c.cooldown, "
|
||||
"GROUP_CONCAT(ca.alias, ', ') AS aliases "
|
||||
"FROM commands c "
|
||||
"LEFT JOIN command_aliases ca ON c.name = ca.command_name "
|
||||
"GROUP BY c.name "
|
||||
"ORDER BY c.name"
|
||||
)
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
template = _jinja_env.get_template("list.html")
|
||||
page = template.render(commands=commands, prefix=ctx.commands.prefix)
|
||||
|
||||
return web.Response(text=page, content_type="text/html")
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Custom Commands</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 2rem; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ccc; padding: 0.5rem 0.75rem; text-align: left; }
|
||||
th { background: #f5f5f5; }
|
||||
td:nth-child(4), th:nth-child(4),
|
||||
td:nth-child(5), th:nth-child(5) { text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Custom Commands</h1>
|
||||
{% if commands %}
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Command</th>
|
||||
<th>Aliases</th>
|
||||
<th>Response</th>
|
||||
<th>Cooldown</th>
|
||||
<th>Permissions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for cmd in commands %}
|
||||
<tr>
|
||||
<td>{{ prefix }}{{ cmd.name }}</td>
|
||||
<td>{{ cmd.aliases }}</td>
|
||||
<td>{{ cmd.response }}</td>
|
||||
<td>{{ cmd.cooldown }}</td>
|
||||
<td>{{ cmd.permissions }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No custom commands defined.</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user