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,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