Files
Owlbot/owlbot/builtin_modules/custom_commands/placeholder_handlers.py
T
LogalDeveloper 0ff3c7a6b4
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
Enabled all Ruff lint rules and resolved findings with justified inline suppressions.
2026-04-13 15:31:06 -04:00

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