CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 14s
CI / Tests (Python 3.13) (push) Successful in 14s
CI / Tests (Python 3.14) (push) Successful in 11s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
456 lines
12 KiB
Python
456 lines
12 KiB
Python
# 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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
from collections.abc import Awaitable, Callable
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import TYPE_CHECKING
|
|
|
|
from .types import NAME_RE
|
|
|
|
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) # codespell:ignore ect
|
|
# GST = Gulf Standard Time (+4), not South Georgia Time (-2)
|
|
# IST = India Standard Time (+5:30), not Irish or Israel # codespell:ignore ist
|
|
# 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, # codespell:ignore ect
|
|
"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, # codespell:ignore ist
|
|
"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, # codespell:ignore pont
|
|
"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, # codespell:ignore taht
|
|
"TFT": 5,
|
|
"THA": 7, # codespell:ignore tha
|
|
"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, # codespell:ignore wast
|
|
"WAT": 1,
|
|
"WEST": 1,
|
|
"WET": 0,
|
|
"WGST": -2,
|
|
"WGT": -3,
|
|
"WIB": 7,
|
|
"WIT": 9, # codespell:ignore wit
|
|
"WITA": 8,
|
|
"WST": 8,
|
|
"YAKT": 9,
|
|
"YEKT": 5,
|
|
}
|
|
|
|
|
|
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: # noqa: PLR2004 # just checking argument count
|
|
return None
|
|
|
|
date_part, tz_abbrev = parts
|
|
offset = TIMEZONE_OFFSETS.get(tz_abbrev.upper())
|
|
if offset is None:
|
|
return None
|
|
|
|
try:
|
|
# Intentionally naive: tz abbreviation is resolved separately via
|
|
# TIMEZONE_OFFSETS since strptime's %z only handles numeric offsets.
|
|
dt = datetime.strptime(date_part, "%b %d %Y %I:%M:%S %p") # noqa: DTZ007
|
|
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, # noqa: ARG001 # required by placeholder handler signature
|
|
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, # noqa: ARG001 # required by placeholder handler signature
|
|
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.command.use_count)
|
|
|
|
# Named counter.
|
|
counter_name = args[0].lower()
|
|
if not 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: # noqa: PLR2004 # just checking argument count
|
|
raise PlaceholderError(
|
|
"Invalid $(count): too many arguments, expected $(count name [modifier])"
|
|
)
|
|
|
|
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
|
|
if modifier_str[0] in ("+", "-"):
|
|
result = await ctx.counters.adjust_counter(counter_name, value)
|
|
else:
|
|
result = await ctx.counters.set_counter(counter_name, value)
|
|
return str(result)
|
|
|
|
|
|
async def _evaluate_getcount(
|
|
name: str, # noqa: ARG001 # required by placeholder handler signature
|
|
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 NAME_RE.match(counter_name):
|
|
raise PlaceholderError(
|
|
"Invalid $(getcount): counter name may only contain "
|
|
"letters, numbers, and underscores"
|
|
)
|
|
result = await ctx.counters.get_counter(counter_name)
|
|
return str(result)
|
|
|
|
|
|
async def _evaluate_rand(
|
|
name: str, # noqa: ARG001 # required by placeholder handler signature
|
|
args: list[str],
|
|
ctx: PlaceholderContext, # noqa: ARG001 # required by placeholder handler signature
|
|
) -> str:
|
|
"""``$(rand start stop)`` -- random integer in range."""
|
|
if len(args) < 2: # noqa: PLR2004 # just checking argument count
|
|
raise PlaceholderError(
|
|
"Invalid $(rand): too few arguments, expected $(rand start stop)"
|
|
)
|
|
if len(args) > 2: # noqa: PLR2004 # just checking argument count
|
|
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))) # noqa: S311 # not security-sensitive; chat command RNG
|
|
|
|
|
|
async def _evaluate_countdown(
|
|
name: str,
|
|
args: list[str],
|
|
ctx: PlaceholderContext, # noqa: ARG001 # required by placeholder handler signature
|
|
) -> 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)
|
|
return "0 seconds"
|
|
|
|
|
|
type PlaceholderHandler = Callable[
|
|
[str, list[str], "PlaceholderContext"], Awaitable[str]
|
|
]
|
|
|
|
HANDLERS: dict[str, PlaceholderHandler] = {
|
|
**{str(i): _evaluate_arg for i in range(1, 10)},
|
|
"user": _evaluate_user,
|
|
"count": _evaluate_count,
|
|
"getcount": _evaluate_getcount,
|
|
"rand": _evaluate_rand,
|
|
"countdown": _evaluate_countdown,
|
|
"countup": _evaluate_countdown,
|
|
}
|