From 8bad09e1d8defd9fc877bcc8edf675635be73bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Mon, 23 Feb 2026 03:13:10 +0000 Subject: [PATCH] Add Discord timestamp converter and transformer --- discord/app_commands/transformers.py | 39 +++++++++++++++++++++++++++- discord/ext/commands/converter.py | 24 +++++++++++++++++ discord/ext/commands/errors.py | 19 ++++++++++++++ discord/utils.py | 1 + docs/ext/commands/api.rst | 5 ++++ docs/interactions/api.rst | 8 ++++++ 6 files changed, 95 insertions(+), 1 deletion(-) diff --git a/discord/app_commands/transformers.py b/discord/app_commands/transformers.py index 212991cbe..531f06e66 100644 --- a/discord/app_commands/transformers.py +++ b/discord/app_commands/transformers.py @@ -23,6 +23,7 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations +import datetime import inspect from dataclasses import dataclass @@ -52,7 +53,7 @@ from ..channel import StageChannel, VoiceChannel, TextChannel, CategoryChannel, from ..abc import GuildChannel from ..threads import Thread from ..enums import Enum as InternalEnum, AppCommandOptionType, ChannelType, Locale -from ..utils import MISSING, maybe_coroutine, _human_join +from ..utils import MISSING, maybe_coroutine, _human_join, TIMESTAMP_PATTERN from ..user import User from ..role import Role from ..member import Member @@ -62,6 +63,7 @@ from .._types import ClientT __all__ = ( 'Transformer', 'Transform', + 'Timestamp', 'Range', ) @@ -681,6 +683,41 @@ class UnionChannelTransformer(BaseChannelTransformer[ClientT]): return resolved +if TYPE_CHECKING: + Timestamp = datetime.datetime +else: + + class Timestamp(Transformer[ClientT]): + """A type annotation that can be applied to a parameter for transforming a :ddocs:`Discord style timestamp ` input to a + :class:`datetime.datetime`. + + + .. versionadded:: 2.7 + + .. warning:: + Due to a Discord limitation, no timezone is provided with the input. The UTC timezone has been supplanted instead. + + Examples + --------- + + .. code-block:: python3 + + @app_commands.command() + async def datetime(interaction: discord.Interaction, value: app_commands.Timestamp): + await interaction.response.send_message(value.isoformat()) + """ + + @property + def type(self) -> AppCommandOptionType: + return AppCommandOptionType.string + + async def transform(self, interaction: Interaction[ClientT], value: Any, /): + match = TIMESTAMP_PATTERN.match(value) + if not match: + raise TransformerError(value, AppCommandOptionType.string, self) + return datetime.datetime.fromtimestamp(int(match[1]), tz=datetime.timezone.utc) + + CHANNEL_TO_TYPES: Dict[Any, List[ChannelType]] = { AppCommandChannel: [ ChannelType.stage_voice, diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index baf22c626..a4b9b3b7d 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations +import datetime import inspect import re from typing import ( @@ -86,6 +87,7 @@ __all__ = ( 'clean_content', 'Greedy', 'Range', + 'Timestamp', 'run_converters', ) @@ -893,6 +895,28 @@ class GuildStickerConverter(IDConverter[discord.GuildSticker]): return result +if TYPE_CHECKING: + Timestamp = datetime.datetime +else: + + class Timestamp(Converter[str]): + """Converts to a :class:`datetime.datetime`. + + Conversion is attempted based on the :ddocs:`Discord style timestamp ` input format. + + .. versionadded:: 2.7 + + .. warning:: + Due to a Discord limitation, no timezone is provided with the input. The UTC timezone has been supplanted instead. + """ + + async def convert(self, ctx: Context[BotT], argument: str) -> datetime.datetime: + match = discord.utils.TIMESTAMP_PATTERN.match(argument) + if not match: + raise BadTimestampArgument(argument) + return datetime.datetime.fromtimestamp(int(match[1]), tz=datetime.timezone.utc) + + class ScheduledEventConverter(IDConverter[discord.ScheduledEvent]): """Converts to a :class:`~discord.ScheduledEvent`. diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index 97841ec6a..3c8b60181 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -79,6 +79,7 @@ __all__ = ( 'SoundboardSoundNotFound', 'PartialEmojiConversionFailure', 'BadBoolArgument', + 'BadTimestampArgument', 'MissingRole', 'BotMissingRole', 'MissingAnyRole', @@ -602,6 +603,24 @@ class BadBoolArgument(BadArgument): super().__init__(f'{argument} is not a recognised boolean option') +class BadTimestampArgument(BadArgument): + """Exception raised when a timestamp argument was not convertable. + + This inherits from :exc:`BadArgument` + + .. versionadded:: 2.7 + + Attributes + ----------- + argument: :class:`str` + The datetime/timestamp argument supplied by the caller that was not a valid timestamp format. + """ + + def __init__(self, argument: str) -> None: + self.argument: str = argument + super().__init__(f'{argument} is not a recognised datetime or timestamp option') + + class RangeError(BadArgument): """Exception raised when an argument is out of range. diff --git a/discord/utils.py b/discord/utils.py index ce4b9e396..ba7bdd3e1 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -118,6 +118,7 @@ __all__ = ( DISCORD_EPOCH = 1420070400000 DEFAULT_FILE_SIZE_LIMIT_BYTES = 10485760 +TIMESTAMP_PATTERN: re.Pattern[str] = re.compile(r'') class _MissingSentinel: diff --git a/docs/ext/commands/api.rst b/docs/ext/commands/api.rst index 3da5cae16..7f4dc28c8 100644 --- a/docs/ext/commands/api.rst +++ b/docs/ext/commands/api.rst @@ -536,6 +536,11 @@ Converters .. autoclass:: discord.ext.commands.SoundboardSoundConverter :members: +.. attributetable:: discord.ext.commands.Timestamp + +.. autoclass:: discord.ext.commands.Timestamp + :members: + .. attributetable:: discord.ext.commands.clean_content .. autoclass:: discord.ext.commands.clean_content diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 2a5543c60..a7a015f3a 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -1169,6 +1169,14 @@ Range .. autoclass:: discord.app_commands.Range :members: +Timestamp +++++++++++ + +.. attributetable:: discord.app_commands.Timestamp + +.. autoclass:: discord.app_commands.Timestamp + :members: + Translations ~~~~~~~~~~~~~