Initial commit.

This commit is contained in:
2026-02-14 15:20:52 -05:00
commit 067b7c5a0a
48 changed files with 12169 additions and 0 deletions
@@ -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>