Initial commit.
This commit is contained in:
@@ -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}")
|
||||
Reference in New Issue
Block a user