From 91f958cbac9185ac84d98984b62538e5a2ff5f26 Mon Sep 17 00:00:00 2001 From: Steve C Date: Sun, 22 Feb 2026 16:46:24 -0500 Subject: [PATCH 1/7] Add missing wait_for overloads for soundboard & voice effects --- discord/client.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index 88c390be0..2d78fc2f4 100644 --- a/discord/client.py +++ b/discord/client.py @@ -88,7 +88,7 @@ if TYPE_CHECKING: from .abc import Messageable, PrivateChannel, Snowflake, SnowflakeTime from .app_commands import Command, ContextMenu from .automod import AutoModAction, AutoModRule - from .channel import DMChannel, GroupChannel + from .channel import DMChannel, GroupChannel, VoiceChannelEffect from .ext.commands import AutoShardedBot, Bot, Context, CommandError from .guild import GuildChannel from .integrations import Integration @@ -1753,6 +1753,38 @@ class Client: timeout: Optional[float] = ..., ) -> Tuple[ScheduledEvent, User]: ... + @overload + async def wait_for( + self, + event: Literal['scheduled_event_update'], + /, + *, + check: Optional[Callable[[ScheduledEvent, ScheduledEvent], bool]] = ..., + timeout: Optional[float] = ..., + ) -> Tuple[ScheduledEvent, ScheduledEvent]: ... + + # Soundboard + + @overload + async def wait_for( + self, + event: Literal['soundboard_sound_create', 'soundboard_sound_delete'], + /, + *, + check: Optional[Callable[[SoundboardSound], bool]] = ..., + timeout: Optional[float] = ..., + ) -> SoundboardSound: ... + + @overload + async def wait_for( + self, + event: Literal['soundboard_sound_update'], + /, + *, + check: Optional[Callable[[SoundboardSound, SoundboardSound], bool]] = ..., + timeout: Optional[float] = ..., + ) -> Tuple[SoundboardSound, SoundboardSound]: ... + # Stages @overload @@ -1859,6 +1891,16 @@ class Client: timeout: Optional[float] = ..., ) -> Tuple[Member, VoiceState, VoiceState]: ... + @overload + async def wait_for( + self, + event: Literal['voice_channel_effect'], + /, + *, + check: Optional[Callable[[VoiceChannelEffect], bool]] = ..., + timeout: Optional[float] = ..., + ) -> VoiceChannelEffect: ... + # Polls @overload From 38d5d8e47a195cbaa61be2f227e6b6756a7cbf82 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 22 Feb 2026 23:47:15 +0200 Subject: [PATCH 2/7] Use walk_children within remove_view --- discord/ui/view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 57b0e2229..ed105b5d6 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -913,14 +913,14 @@ class ViewStore: if message_id is not None and not is_fully_dynamic: self._synced_message_views[message_id] = view - def remove_view(self, view: View) -> None: + def remove_view(self, view: BaseView) -> None: if view.__discord_ui_modal__: self._modals.pop(view.custom_id, None) # type: ignore return dispatch_info = self._views.get(view._cache_key) if dispatch_info: - for item in view._children: + for item in view.walk_children(): if isinstance(item, DynamicItem): pattern = item.__discord_ui_compiled_template__ self._dynamic_items.pop(pattern, None) From f780f044478162c38e86772775a64775737aeffb Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 22 Feb 2026 16:59:18 -0500 Subject: [PATCH 3/7] Update last_send when receiving a HEARTBEAT request --- discord/gateway.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/discord/gateway.py b/discord/gateway.py index 75acf7e98..3f283ef71 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -210,6 +210,10 @@ class KeepAliveHandler(threading.Thread): def tick(self) -> None: self._last_recv = time.perf_counter() + def beat(self) -> Dict[str, Any]: + self._last_send = time.perf_counter() + return self.get_payload() + def ack(self) -> None: ack_time = time.perf_counter() self._last_ack = ack_time @@ -541,7 +545,7 @@ class DiscordWebSocket: if op == self.HEARTBEAT: if self._keep_alive: - beat = self._keep_alive.get_payload() + beat = self._keep_alive.beat() await self.send_as_json(beat) return From 598a16e62f073812816e558629eeddcf2ef6be13 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:29:20 +0100 Subject: [PATCH 4/7] Add support for getting an integration's scopes --- discord/integrations.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/discord/integrations.py b/discord/integrations.py index 5fd238f55..891bd9d02 100644 --- a/discord/integrations.py +++ b/discord/integrations.py @@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import datetime -from typing import Any, Dict, Optional, TYPE_CHECKING, Type, Tuple +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Type, Tuple from .utils import _get_as_snowflake, parse_time, MISSING from .user import User from .enums import try_enum, ExpireBehaviour @@ -98,6 +98,10 @@ class Integration: The account linked to this integration. user: :class:`User` The user that added this integration. + scopes: List[:class:`str`] + The OAuth2 scopes the application has been authorized for. + + .. versionadded:: 2.7 """ __slots__ = ( @@ -109,6 +113,7 @@ class Integration: 'account', 'user', 'enabled', + 'scopes', ) def __init__(self, *, data: IntegrationPayload, guild: Guild) -> None: @@ -128,6 +133,7 @@ class Integration: user = data.get('user') self.user: Optional[User] = User(state=self._state, data=user) if user else None self.enabled: bool = data['enabled'] + self.scopes: List[str] = data.get('scopes', []) async def delete(self, *, reason: Optional[str] = None) -> None: """|coro| @@ -184,6 +190,10 @@ class StreamIntegration(Integration): The integration account information. synced_at: :class:`datetime.datetime` An aware UTC datetime representing when the integration was last synced. + scopes: List[:class:`str`] + The OAuth2 scopes the application has been authorized for. + + .. versionadded:: 2.7 """ __slots__ = ( @@ -352,6 +362,10 @@ class BotIntegration(Integration): The integration account information. application: :class:`IntegrationApplication` The application tied to this integration. + scopes: List[:class:`str`] + The OAuth2 scopes the application has been authorized for. + + .. versionadded:: 2.7 """ __slots__ = ('application',) From fd5a218d7c2c1258aa98ee58f4368dc2a7cc6e3c Mon Sep 17 00:00:00 2001 From: n6ck <90283633+n6ck@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:12:39 -0800 Subject: [PATCH 5/7] Add Message.is_forwardable to check if a message can be forwarded --- discord/message.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/discord/message.py b/discord/message.py index 7b209fc59..19d78dd53 100644 --- a/discord/message.py +++ b/discord/message.py @@ -3051,3 +3051,30 @@ class Message(PartialMessage, Hashable): The newly edited message. """ return await self.edit(attachments=[a for a in self.attachments if a not in attachments]) + + def is_forwardable(self) -> bool: + """:class:`bool`: Whether the message can be forwarded using :meth:`Message.forward`. + + A message is forwardable only if it is a basic message type and does not + contain a poll, call, or activity, and is not a system message. + + .. versionadded:: 2.7 + """ + if self.type not in ( + MessageType.default, + MessageType.reply, + MessageType.chat_input_command, + MessageType.context_menu_command, + ): + return False + + if self.poll is not None: + return False + + if self.call is not None: + return False + + if self.activity is not None: + return False + + return True 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 6/7] 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 ~~~~~~~~~~~~~ From ef1cb6a089bd84b53bb40f294f5f8b2e3c28c96a Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 23 Feb 2026 02:30:05 -0500 Subject: [PATCH 7/7] Prevent empty dictionaries from being added to the ViewStore Fix #10405 --- discord/ui/view.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index ed105b5d6..8dc6b01aa 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -899,7 +899,7 @@ class ViewStore: self._modals[view.custom_id] = view # type: ignore return - dispatch_info = self._views.setdefault(message_id, {}) + dispatch_info = self._views.get(message_id, {}) is_fully_dynamic = True for item in view.walk_children(): if isinstance(item, DynamicItem): @@ -910,6 +910,9 @@ class ViewStore: is_fully_dynamic = False view._cache_key = message_id + if dispatch_info: + self._views[message_id] = dispatch_info + if message_id is not None and not is_fully_dynamic: self._synced_message_views[message_id] = view @@ -927,8 +930,8 @@ class ViewStore: elif item.is_dispatchable(): dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore - if len(dispatch_info) == 0: - self._views.pop(view._cache_key, None) + if dispatch_info is not None and len(dispatch_info) == 0: + self._views.pop(view._cache_key, None) self._synced_message_views.pop(view._cache_key, None) # type: ignore