Added editcounter management command for setting and adjusting named counters.
CI / Formatting (push) Successful in 11s
CI / Linting (push) Successful in 11s
CI / Tests (push) Successful in 20s
CI / Type Checking (push) Successful in 21s

This commit is contained in:
2026-02-18 11:26:01 -05:00
parent 986100f544
commit a415d5b7e3
4 changed files with 73 additions and 8 deletions
+1 -1
Submodule docs updated: df285f62d9...7882423a22
@@ -31,6 +31,7 @@ from .management_commands import (
commandmodonly, commandmodonly,
deletecommand, deletecommand,
editcommand, editcommand,
editcounter,
listcommands, listcommands,
removealias, removealias,
resetcommand, resetcommand,
@@ -45,6 +46,7 @@ __all__ = [
"commandmodonly", "commandmodonly",
"deletecommand", "deletecommand",
"editcommand", "editcommand",
"editcounter",
"listcommands", "listcommands",
"removealias", "removealias",
"resetcommand", "resetcommand",
@@ -14,7 +14,6 @@
"""Management commands for custom commands (add, edit, delete, etc.).""" """Management commands for custom commands (add, edit, delete, etc.)."""
import re
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -27,6 +26,7 @@ from .handler import (
resolve_command_name, resolve_command_name,
update_and_reregister, update_and_reregister,
) )
from .placeholder_handlers import NAME_RE
if TYPE_CHECKING: if TYPE_CHECKING:
import aiosqlite import aiosqlite
@@ -83,7 +83,7 @@ async def addcommand(ctx: CommandContext) -> None:
name = _clean_raw_name(args[0], prefix) name = _clean_raw_name(args[0], prefix)
if not re.match(r"^[a-z0-9_]+$", name): if not NAME_RE.match(name):
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
"Invalid command name. Only letters, numbers, and underscores are allowed." "Invalid command name. Only letters, numbers, and underscores are allowed."
) )
@@ -284,6 +284,69 @@ async def resetcommand(ctx: CommandContext) -> None:
await ctx.owncast_client.send_message(f"Command {prefix}{name} counter reset.") await ctx.owncast_client.send_message(f"Command {prefix}{name} counter reset.")
@on_command("editcounter", aliases=["editcount"], requires_moderator=True)
async def editcounter(ctx: CommandContext) -> None:
"""
Set, increment, or decrement a named counter.
Usage: !editcounter <name> <value>
The value can be an absolute number (e.g. ``15``), or a relative
modifier prefixed with ``+`` or ``-`` (e.g. ``+1``, ``-3``).
If the counter does not exist it is created on the fly.
:param ctx: The command context.
"""
prefix = ctx.commands.prefix
args = ctx.args_list
if len(args) != 2:
await ctx.owncast_client.send_message(
f"Usage: {prefix}editcounter name <value>"
)
return
counter_name = args[0].lower()
if not NAME_RE.match(counter_name):
await ctx.owncast_client.send_message(
"Invalid counter name. Only letters, numbers, and underscores are allowed."
)
return
value_str = args[1]
try:
value = int(value_str)
except ValueError:
await ctx.owncast_client.send_message(
"Invalid value. Must be an integer (e.g. 15, +1, -3)."
)
return
# Fetch old value (default 0 if counter doesn't exist yet).
row = await ctx.storage.fetch_one(
"SELECT value FROM counters WHERE name = ?", (counter_name,)
)
old_value = row["value"] if row else 0
# Leading +/- means relative delta; otherwise absolute.
new_value = old_value + value if value_str[0] in ("+", "-") else value
await ctx.storage.execute(
"INSERT INTO counters (name, value) VALUES (?, ?) "
"ON CONFLICT(name) DO UPDATE SET value = ?",
(counter_name, new_value, new_value),
)
ctx.logger.info(
f"Counter '{counter_name}' changed from {old_value} to {new_value}."
)
await ctx.owncast_client.send_message(
f"Changed the {counter_name} counter from {old_value} to {new_value}."
)
@on_command("commandcooldown", aliases=["cmdcooldown"], requires_moderator=True) @on_command("commandcooldown", aliases=["cmdcooldown"], requires_moderator=True)
async def commandcooldown(ctx: CommandContext) -> None: async def commandcooldown(ctx: CommandContext) -> None:
""" """
@@ -357,7 +420,7 @@ async def addalias(ctx: CommandContext) -> None:
alias = _clean_raw_name(args[1], prefix) alias = _clean_raw_name(args[1], prefix)
if not re.match(r"^[a-z0-9_]+$", alias): if not NAME_RE.match(alias):
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
"Invalid alias name. Only letters, numbers, and underscores are allowed." "Invalid alias name. Only letters, numbers, and underscores are allowed."
) )
@@ -248,8 +248,8 @@ TIMEZONE_OFFSETS: dict[str, float] = {
"YEKT": 5, "YEKT": 5,
} }
# Pattern for validating counter names. # Pattern for validating identifiers (command names, counter names, aliases).
_COUNTER_NAME_RE = re.compile(r"^[a-z0-9_]+$") NAME_RE = re.compile(r"^[a-z0-9_]+$")
def _parse_placeholder_date(date_str: str) -> datetime | None: def _parse_placeholder_date(date_str: str) -> datetime | None:
@@ -341,7 +341,7 @@ async def _evaluate_count(
# Named counter. # Named counter.
counter_name = args[0].lower() counter_name = args[0].lower()
if not _COUNTER_NAME_RE.match(counter_name): if not NAME_RE.match(counter_name):
raise PlaceholderError( raise PlaceholderError(
"Invalid $(count): counter name may only contain " "Invalid $(count): counter name may only contain "
"letters, numbers, and underscores" "letters, numbers, and underscores"
@@ -395,7 +395,7 @@ async def _evaluate_getcount(
"Invalid $(getcount): too many arguments, expected $(getcount name)" "Invalid $(getcount): too many arguments, expected $(getcount name)"
) )
counter_name = args[0].lower() counter_name = args[0].lower()
if not _COUNTER_NAME_RE.match(counter_name): if not NAME_RE.match(counter_name):
raise PlaceholderError( raise PlaceholderError(
"Invalid $(getcount): counter name may only contain " "Invalid $(getcount): counter name may only contain "
"letters, numbers, and underscores" "letters, numbers, and underscores"