Browse Source

Merge branch 'master' of https://github.com/Rapptz/discord.py into feat/interaction-callback

pull/9957/head
DA-344 4 months ago
parent
commit
24dca21322
  1. 7
      README.rst
  2. 2
      discord/__init__.py
  3. 30
      discord/abc.py
  4. 20
      discord/app_commands/commands.py
  5. 32
      discord/app_commands/transformers.py
  6. 4
      discord/app_commands/tree.py
  7. 6
      discord/audit_logs.py
  8. 269
      discord/channel.py
  9. 108
      discord/client.py
  10. 28
      discord/enums.py
  11. 8
      discord/ext/commands/context.py
  12. 40
      discord/ext/commands/converter.py
  13. 2
      discord/ext/commands/cooldowns.py
  14. 21
      discord/ext/commands/errors.py
  15. 13
      discord/ext/commands/hybrid.py
  16. 10
      discord/ext/commands/parameters.py
  17. 9
      discord/ext/tasks/__init__.py
  18. 34
      discord/flags.py
  19. 28
      discord/gateway.py
  20. 291
      discord/guild.py
  21. 155
      discord/http.py
  22. 3
      discord/interactions.py
  23. 6
      discord/member.py
  24. 447
      discord/message.py
  25. 12
      discord/player.py
  26. 100
      discord/poll.py
  27. 18
      discord/raw_models.py
  28. 58
      discord/shard.py
  29. 163
      discord/sku.py
  30. 325
      discord/soundboard.py
  31. 136
      discord/state.py
  32. 107
      discord/subscription.py
  33. 13
      discord/types/audit_log.py
  34. 15
      discord/types/channel.py
  35. 2
      discord/types/embed.py
  36. 2
      discord/types/emoji.py
  37. 23
      discord/types/gateway.py
  38. 4
      discord/types/guild.py
  39. 46
      discord/types/interactions.py
  40. 40
      discord/types/message.py
  41. 49
      discord/types/soundboard.py
  42. 43
      discord/types/subscription.py
  43. 7
      discord/types/voice.py
  44. 136
      discord/utils.py
  45. 25
      discord/voice_client.py
  46. 305
      docs/api.rst
  47. 4
      docs/ext/commands/api.rst
  48. 7
      pyproject.toml
  49. 66
      tests/test_colour.py
  50. 269
      tests/test_embed.py
  51. 56
      tests/test_files.py
  52. 167
      tests/test_ui_buttons.py
  53. 102
      tests/test_ui_modals.py

7
README.rst

@ -27,6 +27,13 @@ Installing
To install the library without full voice support, you can just run the following command: To install the library without full voice support, you can just run the following command:
.. note::
A `Virtual Environment <https://docs.python.org/3/library/venv.html>`__ is recommended to install
the library, especially on Linux where the system Python is externally managed and restricts which
packages you can install on it.
.. code:: sh .. code:: sh
# Linux/macOS # Linux/macOS

2
discord/__init__.py

@ -70,6 +70,8 @@ from .components import *
from .threads import * from .threads import *
from .automod import * from .automod import *
from .poll import * from .poll import *
from .soundboard import *
from .subscription import *
class VersionInfo(NamedTuple): class VersionInfo(NamedTuple):

30
discord/abc.py

@ -26,6 +26,7 @@ from __future__ import annotations
import copy import copy
import time import time
import secrets
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from typing import ( from typing import (
@ -1005,11 +1006,15 @@ class GuildChannel:
base_attrs: Dict[str, Any], base_attrs: Dict[str, Any],
*, *,
name: Optional[str] = None, name: Optional[str] = None,
category: Optional[CategoryChannel] = None,
reason: Optional[str] = None, reason: Optional[str] = None,
) -> Self: ) -> Self:
base_attrs['permission_overwrites'] = [x._asdict() for x in self._overwrites] base_attrs['permission_overwrites'] = [x._asdict() for x in self._overwrites]
base_attrs['parent_id'] = self.category_id base_attrs['parent_id'] = self.category_id
base_attrs['name'] = name or self.name base_attrs['name'] = name or self.name
if category is not None:
base_attrs['parent_id'] = category.id
guild_id = self.guild.id guild_id = self.guild.id
cls = self.__class__ cls = self.__class__
data = await self._state.http.create_channel(guild_id, self.type.value, reason=reason, **base_attrs) data = await self._state.http.create_channel(guild_id, self.type.value, reason=reason, **base_attrs)
@ -1019,7 +1024,13 @@ class GuildChannel:
self.guild._channels[obj.id] = obj # type: ignore # obj is a GuildChannel self.guild._channels[obj.id] = obj # type: ignore # obj is a GuildChannel
return obj return obj
async def clone(self, *, name: Optional[str] = None, reason: Optional[str] = None) -> Self: async def clone(
self,
*,
name: Optional[str] = None,
category: Optional[CategoryChannel] = None,
reason: Optional[str] = None,
) -> Self:
"""|coro| """|coro|
Clones this channel. This creates a channel with the same properties Clones this channel. This creates a channel with the same properties
@ -1034,6 +1045,11 @@ class GuildChannel:
name: Optional[:class:`str`] name: Optional[:class:`str`]
The name of the new channel. If not provided, defaults to this The name of the new channel. If not provided, defaults to this
channel name. channel name.
category: Optional[:class:`~discord.CategoryChannel`]
The category the new channel belongs to.
This parameter is ignored if cloning a category channel.
.. versionadded:: 2.5
reason: Optional[:class:`str`] reason: Optional[:class:`str`]
The reason for cloning this channel. Shows up on the audit log. The reason for cloning this channel. Shows up on the audit log.
@ -1515,10 +1531,11 @@ class Messageable:
.. versionadded:: 1.4 .. versionadded:: 1.4
reference: Union[:class:`~discord.Message`, :class:`~discord.MessageReference`, :class:`~discord.PartialMessage`] reference: Union[:class:`~discord.Message`, :class:`~discord.MessageReference`, :class:`~discord.PartialMessage`]
A reference to the :class:`~discord.Message` to which you are replying, this can be created using A reference to the :class:`~discord.Message` to which you are referencing, this can be created using
:meth:`~discord.Message.to_reference` or passed directly as a :class:`~discord.Message`. You can control :meth:`~discord.Message.to_reference` or passed directly as a :class:`~discord.Message`.
whether this mentions the author of the referenced message using the :attr:`~discord.AllowedMentions.replied_user` In the event of a replying reference, you can control whether this mentions the author of the referenced
attribute of ``allowed_mentions`` or by setting ``mention_author``. message using the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions`` or by
setting ``mention_author``.
.. versionadded:: 1.6 .. versionadded:: 1.6
@ -1598,6 +1615,9 @@ class Messageable:
else: else:
flags = MISSING flags = MISSING
if nonce is None:
nonce = secrets.randbits(64)
with handle_message_parameters( with handle_message_parameters(
content=content, content=content,
tts=tts, tts=tts,

20
discord/app_commands/commands.py

@ -2821,7 +2821,7 @@ def allowed_installs(
return inner return inner
def default_permissions(**perms: bool) -> Callable[[T], T]: def default_permissions(perms_obj: Optional[Permissions] = None, /, **perms: bool) -> Callable[[T], T]:
r"""A decorator that sets the default permissions needed to execute this command. r"""A decorator that sets the default permissions needed to execute this command.
When this decorator is used, by default users must have these permissions to execute the command. When this decorator is used, by default users must have these permissions to execute the command.
@ -2845,8 +2845,12 @@ def default_permissions(**perms: bool) -> Callable[[T], T]:
----------- -----------
\*\*perms: :class:`bool` \*\*perms: :class:`bool`
Keyword arguments denoting the permissions to set as the default. Keyword arguments denoting the permissions to set as the default.
perms_obj: :class:`~discord.Permissions`
A permissions object as positional argument. This can be used in combination with ``**perms``.
Example .. versionadded:: 2.5
Examples
--------- ---------
.. code-block:: python3 .. code-block:: python3
@ -2855,8 +2859,20 @@ def default_permissions(**perms: bool) -> Callable[[T], T]:
@app_commands.default_permissions(manage_messages=True) @app_commands.default_permissions(manage_messages=True)
async def test(interaction: discord.Interaction): async def test(interaction: discord.Interaction):
await interaction.response.send_message('You may or may not have manage messages.') await interaction.response.send_message('You may or may not have manage messages.')
.. code-block:: python3
ADMIN_PERMS = discord.Permissions(administrator=True)
@app_commands.command()
@app_commands.default_permissions(ADMIN_PERMS, manage_messages=True)
async def test(interaction: discord.Interaction):
await interaction.response.send_message('You may or may not have manage messages.')
""" """
if perms_obj is not None:
permissions = perms_obj | Permissions(**perms)
else:
permissions = Permissions(**perms) permissions = Permissions(**perms)
def decorator(func: T) -> T: def decorator(func: T) -> T:

32
discord/app_commands/transformers.py

@ -34,6 +34,7 @@ from typing import (
ClassVar, ClassVar,
Coroutine, Coroutine,
Dict, Dict,
Generic,
List, List,
Literal, Literal,
Optional, Optional,
@ -56,6 +57,7 @@ from ..user import User
from ..role import Role from ..role import Role
from ..member import Member from ..member import Member
from ..message import Attachment from ..message import Attachment
from .._types import ClientT
__all__ = ( __all__ = (
'Transformer', 'Transformer',
@ -191,7 +193,7 @@ class CommandParameter:
return self.name if self._rename is MISSING else str(self._rename) return self.name if self._rename is MISSING else str(self._rename)
class Transformer: class Transformer(Generic[ClientT]):
"""The base class that allows a type annotation in an application command parameter """The base class that allows a type annotation in an application command parameter
to map into a :class:`~discord.AppCommandOptionType` and transform the raw value into one to map into a :class:`~discord.AppCommandOptionType` and transform the raw value into one
from this type. from this type.
@ -304,7 +306,7 @@ class Transformer:
else: else:
return name return name
async def transform(self, interaction: Interaction, value: Any, /) -> Any: async def transform(self, interaction: Interaction[ClientT], value: Any, /) -> Any:
"""|maybecoro| """|maybecoro|
Transforms the converted option value into another value. Transforms the converted option value into another value.
@ -324,7 +326,7 @@ class Transformer:
raise NotImplementedError('Derived classes need to implement this.') raise NotImplementedError('Derived classes need to implement this.')
async def autocomplete( async def autocomplete(
self, interaction: Interaction, value: Union[int, float, str], / self, interaction: Interaction[ClientT], value: Union[int, float, str], /
) -> List[Choice[Union[int, float, str]]]: ) -> List[Choice[Union[int, float, str]]]:
"""|coro| """|coro|
@ -352,7 +354,7 @@ class Transformer:
raise NotImplementedError('Derived classes can implement this.') raise NotImplementedError('Derived classes can implement this.')
class IdentityTransformer(Transformer): class IdentityTransformer(Transformer[ClientT]):
def __init__(self, type: AppCommandOptionType) -> None: def __init__(self, type: AppCommandOptionType) -> None:
self._type = type self._type = type
@ -360,7 +362,7 @@ class IdentityTransformer(Transformer):
def type(self) -> AppCommandOptionType: def type(self) -> AppCommandOptionType:
return self._type return self._type
async def transform(self, interaction: Interaction, value: Any, /) -> Any: async def transform(self, interaction: Interaction[ClientT], value: Any, /) -> Any:
return value return value
@ -489,7 +491,7 @@ class EnumNameTransformer(Transformer):
return self._enum[value] return self._enum[value]
class InlineTransformer(Transformer): class InlineTransformer(Transformer[ClientT]):
def __init__(self, annotation: Any) -> None: def __init__(self, annotation: Any) -> None:
super().__init__() super().__init__()
self.annotation: Any = annotation self.annotation: Any = annotation
@ -502,7 +504,7 @@ class InlineTransformer(Transformer):
def type(self) -> AppCommandOptionType: def type(self) -> AppCommandOptionType:
return AppCommandOptionType.string return AppCommandOptionType.string
async def transform(self, interaction: Interaction, value: Any, /) -> Any: async def transform(self, interaction: Interaction[ClientT], value: Any, /) -> Any:
return await self.annotation.transform(interaction, value) return await self.annotation.transform(interaction, value)
@ -611,18 +613,18 @@ else:
return transformer return transformer
class MemberTransformer(Transformer): class MemberTransformer(Transformer[ClientT]):
@property @property
def type(self) -> AppCommandOptionType: def type(self) -> AppCommandOptionType:
return AppCommandOptionType.user return AppCommandOptionType.user
async def transform(self, interaction: Interaction, value: Any, /) -> Member: async def transform(self, interaction: Interaction[ClientT], value: Any, /) -> Member:
if not isinstance(value, Member): if not isinstance(value, Member):
raise TransformerError(value, self.type, self) raise TransformerError(value, self.type, self)
return value return value
class BaseChannelTransformer(Transformer): class BaseChannelTransformer(Transformer[ClientT]):
def __init__(self, *channel_types: Type[Any]) -> None: def __init__(self, *channel_types: Type[Any]) -> None:
super().__init__() super().__init__()
if len(channel_types) == 1: if len(channel_types) == 1:
@ -654,22 +656,22 @@ class BaseChannelTransformer(Transformer):
def channel_types(self) -> List[ChannelType]: def channel_types(self) -> List[ChannelType]:
return self._channel_types return self._channel_types
async def transform(self, interaction: Interaction, value: Any, /): async def transform(self, interaction: Interaction[ClientT], value: Any, /):
resolved = value.resolve() resolved = value.resolve()
if resolved is None or not isinstance(resolved, self._types): if resolved is None or not isinstance(resolved, self._types):
raise TransformerError(value, AppCommandOptionType.channel, self) raise TransformerError(value, AppCommandOptionType.channel, self)
return resolved return resolved
class RawChannelTransformer(BaseChannelTransformer): class RawChannelTransformer(BaseChannelTransformer[ClientT]):
async def transform(self, interaction: Interaction, value: Any, /): async def transform(self, interaction: Interaction[ClientT], value: Any, /):
if not isinstance(value, self._types): if not isinstance(value, self._types):
raise TransformerError(value, AppCommandOptionType.channel, self) raise TransformerError(value, AppCommandOptionType.channel, self)
return value return value
class UnionChannelTransformer(BaseChannelTransformer): class UnionChannelTransformer(BaseChannelTransformer[ClientT]):
async def transform(self, interaction: Interaction, value: Any, /): async def transform(self, interaction: Interaction[ClientT], value: Any, /):
if isinstance(value, self._types): if isinstance(value, self._types):
return value return value

4
discord/app_commands/tree.py

@ -73,7 +73,7 @@ if TYPE_CHECKING:
from .commands import ContextMenuCallback, CommandCallback, P, T from .commands import ContextMenuCallback, CommandCallback, P, T
ErrorFunc = Callable[ ErrorFunc = Callable[
[Interaction, AppCommandError], [Interaction[ClientT], AppCommandError],
Coroutine[Any, Any, Any], Coroutine[Any, Any, Any],
] ]
@ -833,7 +833,7 @@ class CommandTree(Generic[ClientT]):
else: else:
_log.error('Ignoring exception in command tree', exc_info=error) _log.error('Ignoring exception in command tree', exc_info=error)
def error(self, coro: ErrorFunc) -> ErrorFunc: def error(self, coro: ErrorFunc[ClientT]) -> ErrorFunc[ClientT]:
"""A decorator that registers a coroutine as a local error handler. """A decorator that registers a coroutine as a local error handler.
This must match the signature of the :meth:`on_error` callback. This must match the signature of the :meth:`on_error` callback.

6
discord/audit_logs.py

@ -235,6 +235,10 @@ def _transform_automod_actions(entry: AuditLogEntry, data: List[AutoModerationAc
return [AutoModRuleAction.from_data(action) for action in data] return [AutoModRuleAction.from_data(action) for action in data]
def _transform_default_emoji(entry: AuditLogEntry, data: str) -> PartialEmoji:
return PartialEmoji(name=data)
E = TypeVar('E', bound=enums.Enum) E = TypeVar('E', bound=enums.Enum)
@ -341,6 +345,8 @@ class AuditLogChanges:
'available_tags': (None, _transform_forum_tags), 'available_tags': (None, _transform_forum_tags),
'flags': (None, _transform_overloaded_flags), 'flags': (None, _transform_overloaded_flags),
'default_reaction_emoji': (None, _transform_default_reaction), 'default_reaction_emoji': (None, _transform_default_reaction),
'emoji_name': ('emoji', _transform_default_emoji),
'user_id': ('user', _transform_member_id)
} }
# fmt: on # fmt: on

269
discord/channel.py

@ -47,7 +47,16 @@ import datetime
import discord.abc import discord.abc
from .scheduled_event import ScheduledEvent from .scheduled_event import ScheduledEvent
from .permissions import PermissionOverwrite, Permissions from .permissions import PermissionOverwrite, Permissions
from .enums import ChannelType, ForumLayoutType, ForumOrderType, PrivacyLevel, try_enum, VideoQualityMode, EntityType from .enums import (
ChannelType,
ForumLayoutType,
ForumOrderType,
PrivacyLevel,
try_enum,
VideoQualityMode,
EntityType,
VoiceChannelEffectAnimationType,
)
from .mixins import Hashable from .mixins import Hashable
from . import utils from . import utils
from .utils import MISSING from .utils import MISSING
@ -56,8 +65,10 @@ from .errors import ClientException
from .stage_instance import StageInstance from .stage_instance import StageInstance
from .threads import Thread from .threads import Thread
from .partial_emoji import _EmojiTag, PartialEmoji from .partial_emoji import _EmojiTag, PartialEmoji
from .flags import ChannelFlags from .flags import ChannelFlags, MessageFlags
from .http import handle_message_parameters from .http import handle_message_parameters
from .object import Object
from .soundboard import BaseSoundboardSound, SoundboardDefaultSound
__all__ = ( __all__ = (
'TextChannel', 'TextChannel',
@ -69,6 +80,8 @@ __all__ = (
'ForumChannel', 'ForumChannel',
'GroupChannel', 'GroupChannel',
'PartialMessageable', 'PartialMessageable',
'VoiceChannelEffect',
'VoiceChannelSoundEffect',
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -76,7 +89,6 @@ if TYPE_CHECKING:
from .types.threads import ThreadArchiveDuration from .types.threads import ThreadArchiveDuration
from .role import Role from .role import Role
from .object import Object
from .member import Member, VoiceState from .member import Member, VoiceState
from .abc import Snowflake, SnowflakeTime from .abc import Snowflake, SnowflakeTime
from .embeds import Embed from .embeds import Embed
@ -100,8 +112,11 @@ if TYPE_CHECKING:
ForumChannel as ForumChannelPayload, ForumChannel as ForumChannelPayload,
MediaChannel as MediaChannelPayload, MediaChannel as MediaChannelPayload,
ForumTag as ForumTagPayload, ForumTag as ForumTagPayload,
VoiceChannelEffect as VoiceChannelEffectPayload,
) )
from .types.snowflake import SnowflakeList from .types.snowflake import SnowflakeList
from .types.soundboard import BaseSoundboardSound as BaseSoundboardSoundPayload
from .soundboard import SoundboardSound
OverwriteKeyT = TypeVar('OverwriteKeyT', Role, BaseUser, Object, Union[Role, Member, Object]) OverwriteKeyT = TypeVar('OverwriteKeyT', Role, BaseUser, Object, Union[Role, Member, Object])
@ -111,6 +126,121 @@ class ThreadWithMessage(NamedTuple):
message: Message message: Message
class VoiceChannelEffectAnimation(NamedTuple):
id: int
type: VoiceChannelEffectAnimationType
class VoiceChannelSoundEffect(BaseSoundboardSound):
"""Represents a Discord voice channel sound effect.
.. versionadded:: 2.5
.. container:: operations
.. describe:: x == y
Checks if two sound effects are equal.
.. describe:: x != y
Checks if two sound effects are not equal.
.. describe:: hash(x)
Returns the sound effect's hash.
Attributes
------------
id: :class:`int`
The ID of the sound.
volume: :class:`float`
The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%).
"""
__slots__ = ('_state',)
def __init__(self, *, state: ConnectionState, id: int, volume: float):
data: BaseSoundboardSoundPayload = {
'sound_id': id,
'volume': volume,
}
super().__init__(state=state, data=data)
def __repr__(self) -> str:
return f"<{self.__class__.__name__} id={self.id} volume={self.volume}>"
@property
def created_at(self) -> Optional[datetime.datetime]:
"""Optional[:class:`datetime.datetime`]: Returns the snowflake's creation time in UTC.
Returns ``None`` if it's a default sound."""
if self.is_default():
return None
else:
return utils.snowflake_time(self.id)
def is_default(self) -> bool:
""":class:`bool`: Whether it's a default sound or not."""
# if it's smaller than the Discord Epoch it cannot be a snowflake
return self.id < utils.DISCORD_EPOCH
class VoiceChannelEffect:
"""Represents a Discord voice channel effect.
.. versionadded:: 2.5
Attributes
------------
channel: :class:`VoiceChannel`
The channel in which the effect is sent.
user: Optional[:class:`Member`]
The user who sent the effect. ``None`` if not found in cache.
animation: Optional[:class:`VoiceChannelEffectAnimation`]
The animation the effect has. Returns ``None`` if the effect has no animation.
emoji: Optional[:class:`PartialEmoji`]
The emoji of the effect.
sound: Optional[:class:`VoiceChannelSoundEffect`]
The sound of the effect. Returns ``None`` if it's an emoji effect.
"""
__slots__ = ('channel', 'user', 'animation', 'emoji', 'sound')
def __init__(self, *, state: ConnectionState, data: VoiceChannelEffectPayload, guild: Guild):
self.channel: VoiceChannel = guild.get_channel(int(data['channel_id'])) # type: ignore # will always be a VoiceChannel
self.user: Optional[Member] = guild.get_member(int(data['user_id']))
self.animation: Optional[VoiceChannelEffectAnimation] = None
animation_id = data.get('animation_id')
if animation_id is not None:
animation_type = try_enum(VoiceChannelEffectAnimationType, data['animation_type']) # type: ignore # cannot be None here
self.animation = VoiceChannelEffectAnimation(id=animation_id, type=animation_type)
emoji = data.get('emoji')
self.emoji: Optional[PartialEmoji] = PartialEmoji.from_dict(emoji) if emoji is not None else None
self.sound: Optional[VoiceChannelSoundEffect] = None
sound_id: Optional[int] = utils._get_as_snowflake(data, 'sound_id')
if sound_id is not None:
sound_volume = data.get('sound_volume') or 0.0
self.sound = VoiceChannelSoundEffect(state=state, id=sound_id, volume=sound_volume)
def __repr__(self) -> str:
attrs = [
('channel', self.channel),
('user', self.user),
('animation', self.animation),
('emoji', self.emoji),
('sound', self.sound),
]
inner = ' '.join('%s=%r' % t for t in attrs)
return f"<{self.__class__.__name__} {inner}>"
def is_sound(self) -> bool:
""":class:`bool`: Whether the effect is a sound or not."""
return self.sound is not None
class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable):
"""Represents a Discord guild text channel. """Represents a Discord guild text channel.
@ -395,9 +525,26 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable):
return self.__class__(state=self._state, guild=self.guild, data=payload) # type: ignore return self.__class__(state=self._state, guild=self.guild, data=payload) # type: ignore
@utils.copy_doc(discord.abc.GuildChannel.clone) @utils.copy_doc(discord.abc.GuildChannel.clone)
async def clone(self, *, name: Optional[str] = None, reason: Optional[str] = None) -> TextChannel: async def clone(
self,
*,
name: Optional[str] = None,
category: Optional[CategoryChannel] = None,
reason: Optional[str] = None,
) -> TextChannel:
base: Dict[Any, Any] = {
'topic': self.topic,
'nsfw': self.nsfw,
'default_auto_archive_duration': self.default_auto_archive_duration,
'default_thread_rate_limit_per_user': self.default_thread_slowmode_delay,
}
if not self.is_news():
base['rate_limit_per_user'] = self.slowmode_delay
return await self._clone_impl( return await self._clone_impl(
{'topic': self.topic, 'nsfw': self.nsfw, 'rate_limit_per_user': self.slowmode_delay}, name=name, reason=reason base,
name=name,
category=category,
reason=reason,
) )
async def delete_messages(self, messages: Iterable[Snowflake], *, reason: Optional[str] = None) -> None: async def delete_messages(self, messages: Iterable[Snowflake], *, reason: Optional[str] = None) -> None:
@ -1249,6 +1396,27 @@ class VocalGuildChannel(discord.abc.Messageable, discord.abc.Connectable, discor
data = await self._state.http.create_webhook(self.id, name=str(name), avatar=avatar, reason=reason) data = await self._state.http.create_webhook(self.id, name=str(name), avatar=avatar, reason=reason)
return Webhook.from_state(data, state=self._state) return Webhook.from_state(data, state=self._state)
@utils.copy_doc(discord.abc.GuildChannel.clone)
async def clone(
self, *, name: Optional[str] = None, category: Optional[CategoryChannel] = None, reason: Optional[str] = None
) -> Self:
base = {
'bitrate': self.bitrate,
'user_limit': self.user_limit,
'rate_limit_per_user': self.slowmode_delay,
'nsfw': self.nsfw,
'video_quality_mode': self.video_quality_mode.value,
}
if self.rtc_region:
base['rtc_region'] = self.rtc_region
return await self._clone_impl(
base,
name=name,
category=category,
reason=reason,
)
class VoiceChannel(VocalGuildChannel): class VoiceChannel(VocalGuildChannel):
"""Represents a Discord guild voice channel. """Represents a Discord guild voice channel.
@ -1343,10 +1511,6 @@ class VoiceChannel(VocalGuildChannel):
""":class:`ChannelType`: The channel's Discord type.""" """:class:`ChannelType`: The channel's Discord type."""
return ChannelType.voice return ChannelType.voice
@utils.copy_doc(discord.abc.GuildChannel.clone)
async def clone(self, *, name: Optional[str] = None, reason: Optional[str] = None) -> VoiceChannel:
return await self._clone_impl({'bitrate': self.bitrate, 'user_limit': self.user_limit}, name=name, reason=reason)
@overload @overload
async def edit(self) -> None: async def edit(self) -> None:
... ...
@ -1456,6 +1620,35 @@ class VoiceChannel(VocalGuildChannel):
# the payload will always be the proper channel payload # the payload will always be the proper channel payload
return self.__class__(state=self._state, guild=self.guild, data=payload) # type: ignore return self.__class__(state=self._state, guild=self.guild, data=payload) # type: ignore
async def send_sound(self, sound: Union[SoundboardSound, SoundboardDefaultSound], /) -> None:
"""|coro|
Sends a soundboard sound for this channel.
You must have :attr:`~Permissions.speak` and :attr:`~Permissions.use_soundboard` to do this.
Additionally, you must have :attr:`~Permissions.use_external_sounds` if the sound is from
a different guild.
.. versionadded:: 2.5
Parameters
-----------
sound: Union[:class:`SoundboardSound`, :class:`SoundboardDefaultSound`]
The sound to send for this channel.
Raises
-------
Forbidden
You do not have permissions to send a sound for this channel.
HTTPException
Sending the sound failed.
"""
payload = {'sound_id': sound.id}
if not isinstance(sound, SoundboardDefaultSound) and self.guild.id != sound.guild.id:
payload['source_guild_id'] = sound.guild.id
await self._state.http.send_soundboard_sound(self.id, **payload)
class StageChannel(VocalGuildChannel): class StageChannel(VocalGuildChannel):
"""Represents a Discord guild stage channel. """Represents a Discord guild stage channel.
@ -1588,10 +1781,6 @@ class StageChannel(VocalGuildChannel):
""":class:`ChannelType`: The channel's Discord type.""" """:class:`ChannelType`: The channel's Discord type."""
return ChannelType.stage_voice return ChannelType.stage_voice
@utils.copy_doc(discord.abc.GuildChannel.clone)
async def clone(self, *, name: Optional[str] = None, reason: Optional[str] = None) -> StageChannel:
return await self._clone_impl({}, name=name, reason=reason)
@property @property
def instance(self) -> Optional[StageInstance]: def instance(self) -> Optional[StageInstance]:
"""Optional[:class:`StageInstance`]: The running stage instance of the stage channel. """Optional[:class:`StageInstance`]: The running stage instance of the stage channel.
@ -1869,7 +2058,13 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable):
return self.nsfw return self.nsfw
@utils.copy_doc(discord.abc.GuildChannel.clone) @utils.copy_doc(discord.abc.GuildChannel.clone)
async def clone(self, *, name: Optional[str] = None, reason: Optional[str] = None) -> CategoryChannel: async def clone(
self,
*,
name: Optional[str] = None,
category: Optional[CategoryChannel] = None,
reason: Optional[str] = None,
) -> CategoryChannel:
return await self._clone_impl({'nsfw': self.nsfw}, name=name, reason=reason) return await self._clone_impl({'nsfw': self.nsfw}, name=name, reason=reason)
@overload @overload
@ -2297,6 +2492,14 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
def _sorting_bucket(self) -> int: def _sorting_bucket(self) -> int:
return ChannelType.text.value return ChannelType.text.value
@property
def members(self) -> List[Member]:
"""List[:class:`Member`]: Returns all members that can see this channel.
.. versionadded:: 2.5
"""
return [m for m in self.guild.members if self.permissions_for(m).read_messages]
@property @property
def _scheduled_event_entity_type(self) -> Optional[EntityType]: def _scheduled_event_entity_type(self) -> Optional[EntityType]:
return None return None
@ -2386,9 +2589,33 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
return self._type == ChannelType.media.value return self._type == ChannelType.media.value
@utils.copy_doc(discord.abc.GuildChannel.clone) @utils.copy_doc(discord.abc.GuildChannel.clone)
async def clone(self, *, name: Optional[str] = None, reason: Optional[str] = None) -> ForumChannel: async def clone(
self,
*,
name: Optional[str] = None,
category: Optional[CategoryChannel],
reason: Optional[str] = None,
) -> ForumChannel:
base = {
'topic': self.topic,
'rate_limit_per_user': self.slowmode_delay,
'nsfw': self.nsfw,
'default_auto_archive_duration': self.default_auto_archive_duration,
'available_tags': [tag.to_dict() for tag in self.available_tags],
'default_thread_rate_limit_per_user': self.default_thread_slowmode_delay,
}
if self.default_sort_order:
base['default_sort_order'] = self.default_sort_order.value
if self.default_reaction_emoji:
base['default_reaction_emoji'] = self.default_reaction_emoji._to_forum_tag_payload()
if not self.is_media() and self.default_layout:
base['default_forum_layout'] = self.default_layout.value
return await self._clone_impl( return await self._clone_impl(
{'topic': self.topic, 'nsfw': self.nsfw, 'rate_limit_per_user': self.slowmode_delay}, name=name, reason=reason base,
name=name,
category=category,
reason=reason,
) )
@overload @overload
@ -2719,8 +2946,6 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
raise TypeError(f'view parameter must be View not {view.__class__.__name__}') raise TypeError(f'view parameter must be View not {view.__class__.__name__}')
if suppress_embeds: if suppress_embeds:
from .message import MessageFlags # circular import
flags = MessageFlags._from_value(4) flags = MessageFlags._from_value(4)
else: else:
flags = MISSING flags = MISSING
@ -3312,6 +3537,14 @@ class PartialMessageable(discord.abc.Messageable, Hashable):
return Permissions.none() return Permissions.none()
@property
def mention(self) -> str:
""":class:`str`: Returns a string that allows you to mention the channel.
.. versionadded:: 2.5
"""
return f'<#{self.id}>'
def get_partial_message(self, message_id: int, /) -> PartialMessage: def get_partial_message(self, message_id: int, /) -> PartialMessage:
"""Creates a :class:`PartialMessage` from the message ID. """Creates a :class:`PartialMessage` from the message ID.

108
discord/client.py

@ -53,7 +53,7 @@ from .user import User, ClientUser
from .invite import Invite from .invite import Invite
from .template import Template from .template import Template
from .widget import Widget from .widget import Widget
from .guild import Guild from .guild import Guild, GuildPreview
from .emoji import Emoji from .emoji import Emoji
from .channel import _threaded_channel_factory, PartialMessageable from .channel import _threaded_channel_factory, PartialMessageable
from .enums import ChannelType, EntitlementOwnerType from .enums import ChannelType, EntitlementOwnerType
@ -77,6 +77,7 @@ from .ui.dynamic import DynamicItem
from .stage_instance import StageInstance from .stage_instance import StageInstance
from .threads import Thread from .threads import Thread
from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory
from .soundboard import SoundboardDefaultSound, SoundboardSound
if TYPE_CHECKING: if TYPE_CHECKING:
from types import TracebackType from types import TracebackType
@ -118,6 +119,7 @@ if TYPE_CHECKING:
from .voice_client import VoiceProtocol from .voice_client import VoiceProtocol
from .audit_logs import AuditLogEntry from .audit_logs import AuditLogEntry
from .poll import PollAnswer from .poll import PollAnswer
from .subscription import Subscription
# fmt: off # fmt: off
@ -250,7 +252,7 @@ class Client:
.. versionadded:: 2.0 .. versionadded:: 2.0
connector: Optional[:class:`aiohttp.BaseConnector`] connector: Optional[:class:`aiohttp.BaseConnector`]
The aiohhtp connector to use for this client. This can be used to control underlying aiohttp The aiohttp connector to use for this client. This can be used to control underlying aiohttp
behavior, such as setting a dns resolver or sslcontext. behavior, such as setting a dns resolver or sslcontext.
.. versionadded:: 2.5 .. versionadded:: 2.5
@ -383,6 +385,14 @@ class Client:
""" """
return self._connection.stickers return self._connection.stickers
@property
def soundboard_sounds(self) -> List[SoundboardSound]:
"""List[:class:`.SoundboardSound`]: The soundboard sounds that the connected client has.
.. versionadded:: 2.5
"""
return self._connection.soundboard_sounds
@property @property
def cached_messages(self) -> Sequence[Message]: def cached_messages(self) -> Sequence[Message]:
"""Sequence[:class:`.Message`]: Read-only list of messages the connected client has cached. """Sequence[:class:`.Message`]: Read-only list of messages the connected client has cached.
@ -1109,6 +1119,23 @@ class Client:
""" """
return self._connection.get_sticker(id) return self._connection.get_sticker(id)
def get_soundboard_sound(self, id: int, /) -> Optional[SoundboardSound]:
"""Returns a soundboard sound with the given ID.
.. versionadded:: 2.5
Parameters
----------
id: :class:`int`
The ID to search for.
Returns
--------
Optional[:class:`.SoundboardSound`]
The soundboard sound or ``None`` if not found.
"""
return self._connection.get_soundboard_sound(id)
def get_all_channels(self) -> Generator[GuildChannel, None, None]: def get_all_channels(self) -> Generator[GuildChannel, None, None]:
"""A generator that retrieves every :class:`.abc.GuildChannel` the client can 'access'. """A generator that retrieves every :class:`.abc.GuildChannel` the client can 'access'.
@ -1347,6 +1374,18 @@ class Client:
) -> Union[str, bytes]: ) -> Union[str, bytes]:
... ...
# Entitlements
@overload
async def wait_for(
self,
event: Literal['entitlement_create', 'entitlement_update', 'entitlement_delete'],
/,
*,
check: Optional[Callable[[Entitlement], bool]],
timeout: Optional[float] = None,
) -> Entitlement:
...
# Guilds # Guilds
@overload @overload
@ -1755,6 +1794,18 @@ class Client:
) -> Coroutine[Any, Any, Tuple[StageInstance, StageInstance]]: ) -> Coroutine[Any, Any, Tuple[StageInstance, StageInstance]]:
... ...
# Subscriptions
@overload
async def wait_for(
self,
event: Literal['subscription_create', 'subscription_update', 'subscription_delete'],
/,
*,
check: Optional[Callable[[Subscription], bool]],
timeout: Optional[float] = None,
) -> Subscription:
...
# Threads # Threads
@overload @overload
async def wait_for( async def wait_for(
@ -2305,6 +2356,29 @@ class Client:
data = await self.http.get_guild(guild_id, with_counts=with_counts) data = await self.http.get_guild(guild_id, with_counts=with_counts)
return Guild(data=data, state=self._connection) return Guild(data=data, state=self._connection)
async def fetch_guild_preview(self, guild_id: int) -> GuildPreview:
"""|coro|
Retrieves a preview of a :class:`.Guild` from an ID. If the guild is discoverable,
you don't have to be a member of it.
.. versionadded:: 2.5
Raises
------
NotFound
The guild doesn't exist, or is not discoverable and you are not in it.
HTTPException
Getting the guild failed.
Returns
--------
:class:`.GuildPreview`
The guild preview from the ID.
"""
data = await self.http.get_guild_preview(guild_id)
return GuildPreview(data=data, state=self._connection)
async def create_guild( async def create_guild(
self, self,
*, *,
@ -2750,6 +2824,7 @@ class Client:
user: Optional[Snowflake] = None, user: Optional[Snowflake] = None,
guild: Optional[Snowflake] = None, guild: Optional[Snowflake] = None,
exclude_ended: bool = False, exclude_ended: bool = False,
exclude_deleted: bool = True,
) -> AsyncIterator[Entitlement]: ) -> AsyncIterator[Entitlement]:
"""Retrieves an :term:`asynchronous iterator` of the :class:`.Entitlement` that applications has. """Retrieves an :term:`asynchronous iterator` of the :class:`.Entitlement` that applications has.
@ -2791,6 +2866,10 @@ class Client:
The guild to filter by. The guild to filter by.
exclude_ended: :class:`bool` exclude_ended: :class:`bool`
Whether to exclude ended entitlements. Defaults to ``False``. Whether to exclude ended entitlements. Defaults to ``False``.
exclude_deleted: :class:`bool`
Whether to exclude deleted entitlements. Defaults to ``True``.
.. versionadded:: 2.5
Raises Raises
------- -------
@ -2827,6 +2906,7 @@ class Client:
user_id=user.id if user else None, user_id=user.id if user else None,
guild_id=guild.id if guild else None, guild_id=guild.id if guild else None,
exclude_ended=exclude_ended, exclude_ended=exclude_ended,
exclude_deleted=exclude_deleted,
) )
if data: if data:
@ -2875,7 +2955,7 @@ class Client:
data, state, limit = await strategy(retrieve, state, limit) data, state, limit = await strategy(retrieve, state, limit)
# Terminate loop on next iteration; there's no data left after this # Terminate loop on next iteration; there's no data left after this
if len(data) < 1000: if len(data) < 100:
limit = 0 limit = 0
for e in data: for e in data:
@ -2964,6 +3044,26 @@ class Client:
data = await self.http.get_sticker_pack(sticker_pack_id) data = await self.http.get_sticker_pack(sticker_pack_id)
return StickerPack(state=self._connection, data=data) return StickerPack(state=self._connection, data=data)
async def fetch_soundboard_default_sounds(self) -> List[SoundboardDefaultSound]:
"""|coro|
Retrieves all default soundboard sounds.
.. versionadded:: 2.5
Raises
-------
HTTPException
Retrieving the default soundboard sounds failed.
Returns
---------
List[:class:`.SoundboardDefaultSound`]
All default soundboard sounds.
"""
data = await self.http.get_soundboard_default_sounds()
return [SoundboardDefaultSound(state=self._connection, data=sound) for sound in data]
async def create_dm(self, user: Snowflake) -> DMChannel: async def create_dm(self, user: Snowflake) -> DMChannel:
"""|coro| """|coro|
@ -3100,7 +3200,7 @@ class Client:
Parameters Parameters
---------- ----------
name: :class:`str` name: :class:`str`
The emoji name. Must be at least 2 characters. The emoji name. Must be between 2 and 32 characters long.
image: :class:`bytes` image: :class:`bytes`
The :term:`py:bytes-like object` representing the image data to use. The :term:`py:bytes-like object` representing the image data to use.
Only JPG, PNG and GIF images are supported. Only JPG, PNG and GIF images are supported.

28
discord/enums.py

@ -74,6 +74,9 @@ __all__ = (
'EntitlementType', 'EntitlementType',
'EntitlementOwnerType', 'EntitlementOwnerType',
'PollLayoutType', 'PollLayoutType',
'VoiceChannelEffectAnimationType',
'SubscriptionStatus',
'MessageReferenceType',
) )
@ -218,6 +221,12 @@ class ChannelType(Enum):
return self.name return self.name
class MessageReferenceType(Enum):
default = 0
reply = 0
forward = 1
class MessageType(Enum): class MessageType(Enum):
default = 0 default = 0
recipient_add = 1 recipient_add = 1
@ -256,6 +265,8 @@ class MessageType(Enum):
guild_incident_alert_mode_disabled = 37 guild_incident_alert_mode_disabled = 37
guild_incident_report_raid = 38 guild_incident_report_raid = 38
guild_incident_report_false_alarm = 39 guild_incident_report_false_alarm = 39
purchase_notification = 44
poll_result = 46
class SpeakingState(Enum): class SpeakingState(Enum):
@ -377,6 +388,9 @@ class AuditLogAction(Enum):
thread_update = 111 thread_update = 111
thread_delete = 112 thread_delete = 112
app_command_permission_update = 121 app_command_permission_update = 121
soundboard_sound_create = 130
soundboard_sound_update = 131
soundboard_sound_delete = 132
automod_rule_create = 140 automod_rule_create = 140
automod_rule_update = 141 automod_rule_update = 141
automod_rule_delete = 142 automod_rule_delete = 142
@ -447,6 +461,9 @@ class AuditLogAction(Enum):
AuditLogAction.automod_timeout_member: None, AuditLogAction.automod_timeout_member: None,
AuditLogAction.creator_monetization_request_created: None, AuditLogAction.creator_monetization_request_created: None,
AuditLogAction.creator_monetization_terms_accepted: None, AuditLogAction.creator_monetization_terms_accepted: None,
AuditLogAction.soundboard_sound_create: AuditLogActionCategory.create,
AuditLogAction.soundboard_sound_update: AuditLogActionCategory.update,
AuditLogAction.soundboard_sound_delete: AuditLogActionCategory.delete,
} }
# fmt: on # fmt: on
return lookup[self] return lookup[self]
@ -835,6 +852,17 @@ class ReactionType(Enum):
burst = 1 burst = 1
class VoiceChannelEffectAnimationType(Enum):
premium = 0
basic = 1
class SubscriptionStatus(Enum):
active = 0
ending = 1
inactive = 2
def create_unknown_value(cls: Type[E], val: Any) -> E: def create_unknown_value(cls: Type[E], val: Any) -> E:
value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below
name = f'unknown_{val}' name = f'unknown_{val}'

8
discord/ext/commands/context.py

@ -87,11 +87,15 @@ class DeferTyping:
self.ctx: Context[BotT] = ctx self.ctx: Context[BotT] = ctx
self.ephemeral: bool = ephemeral self.ephemeral: bool = ephemeral
async def do_defer(self) -> None:
if self.ctx.interaction and not self.ctx.interaction.response.is_done():
await self.ctx.interaction.response.defer(ephemeral=self.ephemeral)
def __await__(self) -> Generator[Any, None, None]: def __await__(self) -> Generator[Any, None, None]:
return self.ctx.defer(ephemeral=self.ephemeral).__await__() return self.do_defer().__await__()
async def __aenter__(self) -> None: async def __aenter__(self) -> None:
await self.ctx.defer(ephemeral=self.ephemeral) await self.do_defer()
async def __aexit__( async def __aexit__(
self, self,

40
discord/ext/commands/converter.py

@ -82,6 +82,7 @@ __all__ = (
'GuildChannelConverter', 'GuildChannelConverter',
'GuildStickerConverter', 'GuildStickerConverter',
'ScheduledEventConverter', 'ScheduledEventConverter',
'SoundboardSoundConverter',
'clean_content', 'clean_content',
'Greedy', 'Greedy',
'Range', 'Range',
@ -951,6 +952,44 @@ class ScheduledEventConverter(IDConverter[discord.ScheduledEvent]):
return result return result
class SoundboardSoundConverter(IDConverter[discord.SoundboardSound]):
"""Converts to a :class:`~discord.SoundboardSound`.
Lookups are done for the local guild if available. Otherwise, for a DM context,
lookup is done by the global cache.
The lookup strategy is as follows (in order):
1. Lookup by ID.
2. Lookup by name.
.. versionadded:: 2.5
"""
async def convert(self, ctx: Context[BotT], argument: str) -> discord.SoundboardSound:
guild = ctx.guild
match = self._get_id_match(argument)
result = None
if match:
# ID match
sound_id = int(match.group(1))
if guild:
result = guild.get_soundboard_sound(sound_id)
else:
result = ctx.bot.get_soundboard_sound(sound_id)
else:
# lookup by name
if guild:
result = discord.utils.get(guild.soundboard_sounds, name=argument)
else:
result = discord.utils.get(ctx.bot.soundboard_sounds, name=argument)
if result is None:
raise SoundboardSoundNotFound(argument)
return result
class clean_content(Converter[str]): class clean_content(Converter[str]):
"""Converts the argument to mention scrubbed version of """Converts the argument to mention scrubbed version of
said content. said content.
@ -1263,6 +1302,7 @@ CONVERTER_MAPPING: Dict[type, Any] = {
discord.GuildSticker: GuildStickerConverter, discord.GuildSticker: GuildStickerConverter,
discord.ScheduledEvent: ScheduledEventConverter, discord.ScheduledEvent: ScheduledEventConverter,
discord.ForumChannel: ForumChannelConverter, discord.ForumChannel: ForumChannelConverter,
discord.SoundboardSound: SoundboardSoundConverter,
} }

2
discord/ext/commands/cooldowns.py

@ -71,7 +71,7 @@ class BucketType(Enum):
elif self is BucketType.member: elif self is BucketType.member:
return ((msg.guild and msg.guild.id), msg.author.id) return ((msg.guild and msg.guild.id), msg.author.id)
elif self is BucketType.category: elif self is BucketType.category:
return (msg.channel.category or msg.channel).id # type: ignore return (getattr(msg.channel, 'category', None) or msg.channel).id
elif self is BucketType.role: elif self is BucketType.role:
# we return the channel id of a private-channel as there are only roles in guilds # we return the channel id of a private-channel as there are only roles in guilds
# and that yields the same result as for a guild with only the @everyone role # and that yields the same result as for a guild with only the @everyone role

21
discord/ext/commands/errors.py

@ -75,6 +75,7 @@ __all__ = (
'EmojiNotFound', 'EmojiNotFound',
'GuildStickerNotFound', 'GuildStickerNotFound',
'ScheduledEventNotFound', 'ScheduledEventNotFound',
'SoundboardSoundNotFound',
'PartialEmojiConversionFailure', 'PartialEmojiConversionFailure',
'BadBoolArgument', 'BadBoolArgument',
'MissingRole', 'MissingRole',
@ -564,6 +565,24 @@ class ScheduledEventNotFound(BadArgument):
super().__init__(f'ScheduledEvent "{argument}" not found.') super().__init__(f'ScheduledEvent "{argument}" not found.')
class SoundboardSoundNotFound(BadArgument):
"""Exception raised when the bot can not find the soundboard sound.
This inherits from :exc:`BadArgument`
.. versionadded:: 2.5
Attributes
-----------
argument: :class:`str`
The sound supplied by the caller that was not found
"""
def __init__(self, argument: str) -> None:
self.argument: str = argument
super().__init__(f'SoundboardSound "{argument}" not found.')
class BadBoolArgument(BadArgument): class BadBoolArgument(BadArgument):
"""Exception raised when a boolean argument was not convertable. """Exception raised when a boolean argument was not convertable.
@ -1061,7 +1080,7 @@ class ExtensionNotFound(ExtensionError):
""" """
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
msg = f'Extension {name!r} could not be loaded.' msg = f'Extension {name!r} could not be loaded or found.'
super().__init__(msg, name=name) super().__init__(msg, name=name)

13
discord/ext/commands/hybrid.py

@ -43,7 +43,7 @@ import inspect
from discord import app_commands from discord import app_commands
from discord.utils import MISSING, maybe_coroutine, async_all from discord.utils import MISSING, maybe_coroutine, async_all
from .core import Command, Group from .core import Command, Group
from .errors import BadArgument, CommandRegistrationError, CommandError, HybridCommandError, ConversionError from .errors import BadArgument, CommandRegistrationError, CommandError, HybridCommandError, ConversionError, DisabledCommand
from .converter import Converter, Range, Greedy, run_converters, CONVERTER_MAPPING from .converter import Converter, Range, Greedy, run_converters, CONVERTER_MAPPING
from .parameters import Parameter from .parameters import Parameter
from .flags import is_flag, FlagConverter from .flags import is_flag, FlagConverter
@ -234,6 +234,12 @@ def replace_parameter(
descriptions[name] = flag.description descriptions[name] = flag.description
if flag.name != flag.attribute: if flag.name != flag.attribute:
renames[name] = flag.name renames[name] = flag.name
if pseudo.default is not pseudo.empty:
# This ensures the default is wrapped around _CallableDefault if callable
# else leaves it as-is.
pseudo = pseudo.replace(
default=_CallableDefault(flag.default) if callable(flag.default) else flag.default
)
mapping[name] = pseudo mapping[name] = pseudo
@ -283,7 +289,7 @@ def replace_parameters(
param = param.replace(default=default) param = param.replace(default=default)
if isinstance(param.default, Parameter): if isinstance(param.default, Parameter):
# If we're here, then then it hasn't been handled yet so it should be removed completely # If we're here, then it hasn't been handled yet so it should be removed completely
param = param.replace(default=parameter.empty) param = param.replace(default=parameter.empty)
# Flags are flattened out and thus don't get their parameter in the actual mapping # Flags are flattened out and thus don't get their parameter in the actual mapping
@ -526,6 +532,9 @@ class HybridCommand(Command[CogT, P, T]):
self.app_command.binding = value self.app_command.binding = value
async def can_run(self, ctx: Context[BotT], /) -> bool: async def can_run(self, ctx: Context[BotT], /) -> bool:
if not self.enabled:
raise DisabledCommand(f'{self.name} command is disabled')
if ctx.interaction is not None and self.app_command: if ctx.interaction is not None and self.app_command:
return await self.app_command._check_can_run(ctx.interaction) return await self.app_command._check_can_run(ctx.interaction)
else: else:

10
discord/ext/commands/parameters.py

@ -135,7 +135,7 @@ class Parameter(inspect.Parameter):
if displayed_name is MISSING: if displayed_name is MISSING:
displayed_name = self._displayed_name displayed_name = self._displayed_name
return self.__class__( ret = self.__class__(
name=name, name=name,
kind=kind, kind=kind,
default=default, default=default,
@ -144,6 +144,8 @@ class Parameter(inspect.Parameter):
displayed_default=displayed_default, displayed_default=displayed_default,
displayed_name=displayed_name, displayed_name=displayed_name,
) )
ret._fallback = self._fallback
return ret
if not TYPE_CHECKING: # this is to prevent anything breaking if inspect internals change if not TYPE_CHECKING: # this is to prevent anything breaking if inspect internals change
name = _gen_property('name') name = _gen_property('name')
@ -247,6 +249,12 @@ def parameter(
.. versionadded:: 2.3 .. versionadded:: 2.3
""" """
if isinstance(default, Parameter):
if displayed_default is empty:
displayed_default = default._displayed_default
default = default._default
return Parameter( return Parameter(
name='empty', name='empty',
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,

9
discord/ext/tasks/__init__.py

@ -111,12 +111,17 @@ class SleepHandle:
self.loop: asyncio.AbstractEventLoop = loop self.loop: asyncio.AbstractEventLoop = loop
self.future: asyncio.Future[None] = loop.create_future() self.future: asyncio.Future[None] = loop.create_future()
relative_delta = discord.utils.compute_timedelta(dt) relative_delta = discord.utils.compute_timedelta(dt)
self.handle = loop.call_later(relative_delta, self.future.set_result, None) self.handle = loop.call_later(relative_delta, self._wrapped_set_result, self.future)
@staticmethod
def _wrapped_set_result(future: asyncio.Future) -> None:
if not future.done():
future.set_result(None)
def recalculate(self, dt: datetime.datetime) -> None: def recalculate(self, dt: datetime.datetime) -> None:
self.handle.cancel() self.handle.cancel()
relative_delta = discord.utils.compute_timedelta(dt) relative_delta = discord.utils.compute_timedelta(dt)
self.handle: asyncio.TimerHandle = self.loop.call_later(relative_delta, self.future.set_result, None) self.handle: asyncio.TimerHandle = self.loop.call_later(relative_delta, self._wrapped_set_result, self.future)
def wait(self) -> asyncio.Future[Any]: def wait(self) -> asyncio.Future[Any]:
return self.future return self.future

34
discord/flags.py

@ -135,7 +135,7 @@ class BaseFlags:
setattr(self, key, value) setattr(self, key, value)
@classmethod @classmethod
def _from_value(cls, value): def _from_value(cls, value: int) -> Self:
self = cls.__new__(cls) self = cls.__new__(cls)
self.value = value self.value = value
return self return self
@ -490,6 +490,14 @@ class MessageFlags(BaseFlags):
""" """
return 8192 return 8192
@flag_value
def forwarded(self):
""":class:`bool`: Returns ``True`` if the message is a forwarded message.
.. versionadded:: 2.5
"""
return 16384
@fill_with_flags() @fill_with_flags()
class PublicUserFlags(BaseFlags): class PublicUserFlags(BaseFlags):
@ -871,34 +879,52 @@ class Intents(BaseFlags):
@alias_flag_value @alias_flag_value
def emojis(self): def emojis(self):
""":class:`bool`: Alias of :attr:`.emojis_and_stickers`. """:class:`bool`: Alias of :attr:`.expressions`.
.. versionchanged:: 2.0 .. versionchanged:: 2.0
Changed to an alias. Changed to an alias.
""" """
return 1 << 3 return 1 << 3
@flag_value @alias_flag_value
def emojis_and_stickers(self): def emojis_and_stickers(self):
""":class:`bool`: Whether guild emoji and sticker related events are enabled. """:class:`bool`: Alias of :attr:`.expressions`.
.. versionadded:: 2.0 .. versionadded:: 2.0
.. versionchanged:: 2.5
Changed to an alias.
"""
return 1 << 3
@flag_value
def expressions(self):
""":class:`bool`: Whether guild emoji, sticker, and soundboard sound related events are enabled.
.. versionadded:: 2.5
This corresponds to the following events: This corresponds to the following events:
- :func:`on_guild_emojis_update` - :func:`on_guild_emojis_update`
- :func:`on_guild_stickers_update` - :func:`on_guild_stickers_update`
- :func:`on_soundboard_sound_create`
- :func:`on_soundboard_sound_update`
- :func:`on_soundboard_sound_delete`
This also corresponds to the following attributes and classes in terms of cache: This also corresponds to the following attributes and classes in terms of cache:
- :class:`Emoji` - :class:`Emoji`
- :class:`GuildSticker` - :class:`GuildSticker`
- :class:`SoundboardSound`
- :meth:`Client.get_emoji` - :meth:`Client.get_emoji`
- :meth:`Client.get_sticker` - :meth:`Client.get_sticker`
- :meth:`Client.get_soundboard_sound`
- :meth:`Client.emojis` - :meth:`Client.emojis`
- :meth:`Client.stickers` - :meth:`Client.stickers`
- :meth:`Client.soundboard_sounds`
- :attr:`Guild.emojis` - :attr:`Guild.emojis`
- :attr:`Guild.stickers` - :attr:`Guild.stickers`
- :attr:`Guild.soundboard_sounds`
""" """
return 1 << 3 return 1 << 3

28
discord/gateway.py

@ -21,6 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -32,7 +33,6 @@ import sys
import time import time
import threading import threading
import traceback import traceback
import zlib
from typing import Any, Callable, Coroutine, Deque, Dict, List, TYPE_CHECKING, NamedTuple, Optional, TypeVar, Tuple from typing import Any, Callable, Coroutine, Deque, Dict, List, TYPE_CHECKING, NamedTuple, Optional, TypeVar, Tuple
@ -325,8 +325,7 @@ class DiscordWebSocket:
# ws related stuff # ws related stuff
self.session_id: Optional[str] = None self.session_id: Optional[str] = None
self.sequence: Optional[int] = None self.sequence: Optional[int] = None
self._zlib: zlib._Decompress = zlib.decompressobj() self._decompressor: utils._DecompressionContext = utils._ActiveDecompressionContext()
self._buffer: bytearray = bytearray()
self._close_code: Optional[int] = None self._close_code: Optional[int] = None
self._rate_limiter: GatewayRatelimiter = GatewayRatelimiter() self._rate_limiter: GatewayRatelimiter = GatewayRatelimiter()
@ -355,7 +354,7 @@ class DiscordWebSocket:
sequence: Optional[int] = None, sequence: Optional[int] = None,
resume: bool = False, resume: bool = False,
encoding: str = 'json', encoding: str = 'json',
zlib: bool = True, compress: bool = True,
) -> Self: ) -> Self:
"""Creates a main websocket for Discord from a :class:`Client`. """Creates a main websocket for Discord from a :class:`Client`.
@ -366,10 +365,12 @@ class DiscordWebSocket:
gateway = gateway or cls.DEFAULT_GATEWAY gateway = gateway or cls.DEFAULT_GATEWAY
if zlib: if not compress:
url = gateway.with_query(v=INTERNAL_API_VERSION, encoding=encoding, compress='zlib-stream')
else:
url = gateway.with_query(v=INTERNAL_API_VERSION, encoding=encoding) url = gateway.with_query(v=INTERNAL_API_VERSION, encoding=encoding)
else:
url = gateway.with_query(
v=INTERNAL_API_VERSION, encoding=encoding, compress=utils._ActiveDecompressionContext.COMPRESSION_TYPE
)
socket = await client.http.ws_connect(str(url)) socket = await client.http.ws_connect(str(url))
ws = cls(socket, loop=client.loop) ws = cls(socket, loop=client.loop)
@ -488,13 +489,11 @@ class DiscordWebSocket:
async def received_message(self, msg: Any, /) -> None: async def received_message(self, msg: Any, /) -> None:
if type(msg) is bytes: if type(msg) is bytes:
self._buffer.extend(msg) msg = self._decompressor.decompress(msg)
if len(msg) < 4 or msg[-4:] != b'\x00\x00\xff\xff': # Received a partial gateway message
if msg is None:
return return
msg = self._zlib.decompress(self._buffer)
msg = msg.decode('utf-8')
self._buffer = bytearray()
self.log_receive(msg) self.log_receive(msg)
msg = utils._from_json(msg) msg = utils._from_json(msg)
@ -607,7 +606,10 @@ class DiscordWebSocket:
def _can_handle_close(self) -> bool: def _can_handle_close(self) -> bool:
code = self._close_code or self.socket.close_code code = self._close_code or self.socket.close_code
return code not in (1000, 4004, 4010, 4011, 4012, 4013, 4014) # If the socket is closed remotely with 1000 and it's not our own explicit close
# then it's an improper close that should be handled and reconnected
is_improper_close = self._close_code is None and self.socket.close_code == 1000
return is_improper_close or code not in (1000, 4004, 4010, 4011, 4012, 4013, 4014)
async def poll_event(self) -> None: async def poll_event(self) -> None:
"""Polls for a DISPATCH event and handles the general gateway loop. """Polls for a DISPATCH event and handles the general gateway loop.

291
discord/guild.py

@ -94,10 +94,12 @@ from .object import OLDEST_OBJECT, Object
from .welcome_screen import WelcomeScreen, WelcomeChannel from .welcome_screen import WelcomeScreen, WelcomeChannel
from .automod import AutoModRule, AutoModTrigger, AutoModRuleAction from .automod import AutoModRule, AutoModTrigger, AutoModRuleAction
from .partial_emoji import _EmojiTag, PartialEmoji from .partial_emoji import _EmojiTag, PartialEmoji
from .soundboard import SoundboardSound
__all__ = ( __all__ = (
'Guild', 'Guild',
'GuildPreview',
'BanEntry', 'BanEntry',
) )
@ -108,6 +110,7 @@ if TYPE_CHECKING:
from .types.guild import ( from .types.guild import (
Ban as BanPayload, Ban as BanPayload,
Guild as GuildPayload, Guild as GuildPayload,
GuildPreview as GuildPreviewPayload,
RolePositionUpdate as RolePositionUpdatePayload, RolePositionUpdate as RolePositionUpdatePayload,
GuildFeature, GuildFeature,
IncidentData, IncidentData,
@ -159,6 +162,121 @@ class _GuildLimit(NamedTuple):
filesize: int filesize: int
class GuildPreview(Hashable):
"""Represents a preview of a Discord guild.
.. versionadded:: 2.5
.. container:: operations
.. describe:: x == y
Checks if two guild previews are equal.
.. describe:: x != y
Checks if two guild previews are not equal.
.. describe:: hash(x)
Returns the guild's hash.
.. describe:: str(x)
Returns the guild's name.
Attributes
----------
name: :class:`str`
The guild preview's name.
id: :class:`int`
The guild preview's ID.
features: List[:class:`str`]
A list of features the guild has. See :attr:`Guild.features` for more information.
description: Optional[:class:`str`]
The guild preview's description.
emojis: Tuple[:class:`Emoji`, ...]
All emojis that the guild owns.
stickers: Tuple[:class:`GuildSticker`, ...]
All stickers that the guild owns.
approximate_member_count: :class:`int`
The approximate number of members in the guild.
approximate_presence_count: :class:`int`
The approximate number of members currently active in in the guild. Offline members are excluded.
"""
__slots__ = (
'_state',
'_icon',
'_splash',
'_discovery_splash',
'id',
'name',
'emojis',
'stickers',
'features',
'description',
"approximate_member_count",
"approximate_presence_count",
)
def __init__(self, *, data: GuildPreviewPayload, state: ConnectionState) -> None:
self._state: ConnectionState = state
self.id = int(data['id'])
self.name: str = data['name']
self._icon: Optional[str] = data.get('icon')
self._splash: Optional[str] = data.get('splash')
self._discovery_splash: Optional[str] = data.get('discovery_splash')
self.emojis: Tuple[Emoji, ...] = tuple(
map(
lambda d: Emoji(guild=state._get_or_create_unavailable_guild(self.id), state=state, data=d),
data.get('emojis', []),
)
)
self.stickers: Tuple[GuildSticker, ...] = tuple(
map(lambda d: GuildSticker(state=state, data=d), data.get('stickers', []))
)
self.features: List[GuildFeature] = data.get('features', [])
self.description: Optional[str] = data.get('description')
self.approximate_member_count: int = data.get('approximate_member_count')
self.approximate_presence_count: int = data.get('approximate_presence_count')
def __str__(self) -> str:
return self.name
def __repr__(self) -> str:
return (
f'<{self.__class__.__name__} id={self.id} name={self.name!r} description={self.description!r} '
f'features={self.features}>'
)
@property
def created_at(self) -> datetime.datetime:
""":class:`datetime.datetime`: Returns the guild's creation time in UTC."""
return utils.snowflake_time(self.id)
@property
def icon(self) -> Optional[Asset]:
"""Optional[:class:`Asset`]: Returns the guild's icon asset, if available."""
if self._icon is None:
return None
return Asset._from_guild_icon(self._state, self.id, self._icon)
@property
def splash(self) -> Optional[Asset]:
"""Optional[:class:`Asset`]: Returns the guild's invite splash asset, if available."""
if self._splash is None:
return None
return Asset._from_guild_image(self._state, self.id, self._splash, path='splashes')
@property
def discovery_splash(self) -> Optional[Asset]:
"""Optional[:class:`Asset`]: Returns the guild's discovery splash asset, if available."""
if self._discovery_splash is None:
return None
return Asset._from_guild_image(self._state, self.id, self._discovery_splash, path='discovery-splashes')
class Guild(Hashable): class Guild(Hashable):
"""Represents a Discord guild. """Represents a Discord guild.
@ -328,6 +446,7 @@ class Guild(Hashable):
'_safety_alerts_channel_id', '_safety_alerts_channel_id',
'max_stage_video_users', 'max_stage_video_users',
'_incidents_data', '_incidents_data',
'_soundboard_sounds',
) )
_PREMIUM_GUILD_LIMITS: ClassVar[Dict[Optional[int], _GuildLimit]] = { _PREMIUM_GUILD_LIMITS: ClassVar[Dict[Optional[int], _GuildLimit]] = {
@ -345,6 +464,7 @@ class Guild(Hashable):
self._threads: Dict[int, Thread] = {} self._threads: Dict[int, Thread] = {}
self._stage_instances: Dict[int, StageInstance] = {} self._stage_instances: Dict[int, StageInstance] = {}
self._scheduled_events: Dict[int, ScheduledEvent] = {} self._scheduled_events: Dict[int, ScheduledEvent] = {}
self._soundboard_sounds: Dict[int, SoundboardSound] = {}
self._state: ConnectionState = state self._state: ConnectionState = state
self._member_count: Optional[int] = None self._member_count: Optional[int] = None
self._from_data(data) self._from_data(data)
@ -390,6 +510,12 @@ class Guild(Hashable):
del self._threads[k] del self._threads[k]
return to_remove return to_remove
def _add_soundboard_sound(self, sound: SoundboardSound, /) -> None:
self._soundboard_sounds[sound.id] = sound
def _remove_soundboard_sound(self, sound: SoundboardSound, /) -> None:
self._soundboard_sounds.pop(sound.id, None)
def __str__(self) -> str: def __str__(self) -> str:
return self.name or '' return self.name or ''
@ -547,6 +673,11 @@ class Guild(Hashable):
scheduled_event = ScheduledEvent(data=s, state=self._state) scheduled_event = ScheduledEvent(data=s, state=self._state)
self._scheduled_events[scheduled_event.id] = scheduled_event self._scheduled_events[scheduled_event.id] = scheduled_event
if 'soundboard_sounds' in guild:
for s in guild['soundboard_sounds']:
soundboard_sound = SoundboardSound(guild=self, data=s, state=self._state)
self._add_soundboard_sound(soundboard_sound)
@property @property
def channels(self) -> Sequence[GuildChannel]: def channels(self) -> Sequence[GuildChannel]:
"""Sequence[:class:`abc.GuildChannel`]: A list of channels that belongs to this guild.""" """Sequence[:class:`abc.GuildChannel`]: A list of channels that belongs to this guild."""
@ -996,6 +1127,37 @@ class Guild(Hashable):
""" """
return self._scheduled_events.get(scheduled_event_id) return self._scheduled_events.get(scheduled_event_id)
@property
def soundboard_sounds(self) -> Sequence[SoundboardSound]:
"""Sequence[:class:`SoundboardSound`]: Returns a sequence of the guild's soundboard sounds.
.. versionadded:: 2.5
"""
return utils.SequenceProxy(self._soundboard_sounds.values())
def get_soundboard_sound(self, sound_id: int, /) -> Optional[SoundboardSound]:
"""Returns a soundboard sound with the given ID.
.. versionadded:: 2.5
Parameters
-----------
sound_id: :class:`int`
The ID to search for.
Returns
--------
Optional[:class:`SoundboardSound`]
The soundboard sound or ``None`` if not found.
"""
return self._soundboard_sounds.get(sound_id)
def _resolve_soundboard_sound(self, id: Optional[int], /) -> Optional[SoundboardSound]:
if id is None:
return
return self._soundboard_sounds.get(id)
@property @property
def owner(self) -> Optional[Member]: def owner(self) -> Optional[Member]:
"""Optional[:class:`Member`]: The member that owns the guild.""" """Optional[:class:`Member`]: The member that owns the guild."""
@ -4308,6 +4470,8 @@ class Guild(Hashable):
------- -------
Forbidden Forbidden
You do not have permission to view the automod rule. You do not have permission to view the automod rule.
NotFound
The automod rule does not exist within this guild.
Returns Returns
-------- --------
@ -4496,3 +4660,130 @@ class Guild(Hashable):
return False return False
return self.raid_detected_at > utils.utcnow() return self.raid_detected_at > utils.utcnow()
async def fetch_soundboard_sound(self, sound_id: int, /) -> SoundboardSound:
"""|coro|
Retrieves a :class:`SoundboardSound` with the specified ID.
.. versionadded:: 2.5
.. note::
Using this, in order to receive :attr:`SoundboardSound.user`, you must have :attr:`~Permissions.create_expressions`
or :attr:`~Permissions.manage_expressions`.
.. note::
This method is an API call. For general usage, consider :attr:`get_soundboard_sound` instead.
Raises
-------
NotFound
The sound requested could not be found.
HTTPException
Retrieving the sound failed.
Returns
--------
:class:`SoundboardSound`
The retrieved sound.
"""
data = await self._state.http.get_soundboard_sound(self.id, sound_id)
return SoundboardSound(guild=self, state=self._state, data=data)
async def fetch_soundboard_sounds(self) -> List[SoundboardSound]:
"""|coro|
Retrieves a list of all soundboard sounds for the guild.
.. versionadded:: 2.5
.. note::
Using this, in order to receive :attr:`SoundboardSound.user`, you must have :attr:`~Permissions.create_expressions`
or :attr:`~Permissions.manage_expressions`.
.. note::
This method is an API call. For general usage, consider :attr:`soundboard_sounds` instead.
Raises
-------
HTTPException
Retrieving the sounds failed.
Returns
--------
List[:class:`SoundboardSound`]
The retrieved soundboard sounds.
"""
data = await self._state.http.get_soundboard_sounds(self.id)
return [SoundboardSound(guild=self, state=self._state, data=sound) for sound in data['items']]
async def create_soundboard_sound(
self,
*,
name: str,
sound: bytes,
volume: float = 1,
emoji: Optional[EmojiInputType] = None,
reason: Optional[str] = None,
) -> SoundboardSound:
"""|coro|
Creates a :class:`SoundboardSound` for the guild.
You must have :attr:`Permissions.create_expressions` to do this.
.. versionadded:: 2.5
Parameters
----------
name: :class:`str`
The name of the sound. Must be between 2 and 32 characters.
sound: :class:`bytes`
The :term:`py:bytes-like object` representing the sound data.
Only MP3 and OGG sound files that don't exceed the duration of 5.2s are supported.
volume: :class:`float`
The volume of the sound. Must be between 0 and 1. Defaults to ``1``.
emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]]
The emoji of the sound.
reason: Optional[:class:`str`]
The reason for creating the sound. Shows up on the audit log.
Raises
-------
Forbidden
You do not have permissions to create a soundboard sound.
HTTPException
Creating the soundboard sound failed.
Returns
-------
:class:`SoundboardSound`
The newly created soundboard sound.
"""
payload: Dict[str, Any] = {
'name': name,
'sound': utils._bytes_to_base64_data(sound, audio=True),
'volume': volume,
'emoji_id': None,
'emoji_name': None,
}
if emoji is not None:
if isinstance(emoji, _EmojiTag):
partial_emoji = emoji._to_partial()
elif isinstance(emoji, str):
partial_emoji = PartialEmoji.from_str(emoji)
else:
partial_emoji = None
if partial_emoji is not None:
if partial_emoji.id is None:
payload['emoji_name'] = partial_emoji.name
else:
payload['emoji_id'] = partial_emoji.id
data = await self._state.http.create_soundboard_sound(self.id, reason=reason, **payload)
return SoundboardSound(guild=self, state=self._state, data=data)

155
discord/http.py

@ -93,8 +93,11 @@ if TYPE_CHECKING:
sku, sku,
poll, poll,
voice, voice,
soundboard,
subscription,
) )
from .types.snowflake import Snowflake, SnowflakeList from .types.snowflake import Snowflake, SnowflakeList
from .types.gateway import SessionStartLimit
from types import TracebackType from types import TracebackType
@ -195,6 +198,7 @@ def handle_message_parameters(
if nonce is not None: if nonce is not None:
payload['nonce'] = str(nonce) payload['nonce'] = str(nonce)
payload['enforce_nonce'] = True
if message_reference is not MISSING: if message_reference is not MISSING:
payload['message_reference'] = message_reference payload['message_reference'] = message_reference
@ -306,7 +310,7 @@ class Route:
self.metadata: Optional[str] = metadata self.metadata: Optional[str] = metadata
url = self.BASE + self.path url = self.BASE + self.path
if parameters: if parameters:
url = url.format_map({k: _uriquote(v) if isinstance(v, str) else v for k, v in parameters.items()}) url = url.format_map({k: _uriquote(v, safe='') if isinstance(v, str) else v for k, v in parameters.items()})
self.url: str = url self.url: str = url
# major parameters: # major parameters:
@ -775,7 +779,15 @@ class HTTPClient:
raise RuntimeError('Unreachable code in HTTP handling') raise RuntimeError('Unreachable code in HTTP handling')
async def get_from_cdn(self, url: str) -> bytes: async def get_from_cdn(self, url: str) -> bytes:
async with self.__session.get(url) as resp: kwargs = {}
# Proxy support
if self.proxy is not None:
kwargs['proxy'] = self.proxy
if self.proxy_auth is not None:
kwargs['proxy_auth'] = self.proxy_auth
async with self.__session.get(url, **kwargs) as resp:
if resp.status == 200: if resp.status == 200:
return await resp.read() return await resp.read()
elif resp.status == 404: elif resp.status == 404:
@ -1441,6 +1453,9 @@ class HTTPClient:
params = {'with_counts': int(with_counts)} params = {'with_counts': int(with_counts)}
return self.request(Route('GET', '/guilds/{guild_id}', guild_id=guild_id), params=params) return self.request(Route('GET', '/guilds/{guild_id}', guild_id=guild_id), params=params)
def get_guild_preview(self, guild_id: Snowflake) -> Response[guild.GuildPreview]:
return self.request(Route('GET', '/guilds/{guild_id}/preview', guild_id=guild_id))
def delete_guild(self, guild_id: Snowflake) -> Response[None]: def delete_guild(self, guild_id: Snowflake) -> Response[None]:
return self.request(Route('DELETE', '/guilds/{guild_id}', guild_id=guild_id)) return self.request(Route('DELETE', '/guilds/{guild_id}', guild_id=guild_id))
@ -2445,6 +2460,7 @@ class HTTPClient:
limit: Optional[int] = None, limit: Optional[int] = None,
guild_id: Optional[Snowflake] = None, guild_id: Optional[Snowflake] = None,
exclude_ended: Optional[bool] = None, exclude_ended: Optional[bool] = None,
exclude_deleted: Optional[bool] = None,
) -> Response[List[sku.Entitlement]]: ) -> Response[List[sku.Entitlement]]:
params: Dict[str, Any] = {} params: Dict[str, Any] = {}
@ -2462,6 +2478,8 @@ class HTTPClient:
params['guild_id'] = guild_id params['guild_id'] = guild_id
if exclude_ended is not None: if exclude_ended is not None:
params['exclude_ended'] = int(exclude_ended) params['exclude_ended'] = int(exclude_ended)
if exclude_deleted is not None:
params['exclude_deleted'] = int(exclude_deleted)
return self.request( return self.request(
Route('GET', '/applications/{application_id}/entitlements', application_id=application_id), params=params Route('GET', '/applications/{application_id}/entitlements', application_id=application_id), params=params
@ -2515,6 +2533,77 @@ class HTTPClient:
), ),
) )
# Soundboard
def get_soundboard_default_sounds(self) -> Response[List[soundboard.SoundboardDefaultSound]]:
return self.request(Route('GET', '/soundboard-default-sounds'))
def get_soundboard_sound(self, guild_id: Snowflake, sound_id: Snowflake) -> Response[soundboard.SoundboardSound]:
return self.request(
Route('GET', '/guilds/{guild_id}/soundboard-sounds/{sound_id}', guild_id=guild_id, sound_id=sound_id)
)
def get_soundboard_sounds(self, guild_id: Snowflake) -> Response[Dict[str, List[soundboard.SoundboardSound]]]:
return self.request(Route('GET', '/guilds/{guild_id}/soundboard-sounds', guild_id=guild_id))
def create_soundboard_sound(
self, guild_id: Snowflake, *, reason: Optional[str], **payload: Any
) -> Response[soundboard.SoundboardSound]:
valid_keys = (
'name',
'sound',
'volume',
'emoji_id',
'emoji_name',
)
payload = {k: v for k, v in payload.items() if k in valid_keys and v is not None}
return self.request(
Route('POST', '/guilds/{guild_id}/soundboard-sounds', guild_id=guild_id), json=payload, reason=reason
)
def edit_soundboard_sound(
self, guild_id: Snowflake, sound_id: Snowflake, *, reason: Optional[str], **payload: Any
) -> Response[soundboard.SoundboardSound]:
valid_keys = (
'name',
'volume',
'emoji_id',
'emoji_name',
)
payload = {k: v for k, v in payload.items() if k in valid_keys}
return self.request(
Route(
'PATCH',
'/guilds/{guild_id}/soundboard-sounds/{sound_id}',
guild_id=guild_id,
sound_id=sound_id,
),
json=payload,
reason=reason,
)
def delete_soundboard_sound(self, guild_id: Snowflake, sound_id: Snowflake, *, reason: Optional[str]) -> Response[None]:
return self.request(
Route(
'DELETE',
'/guilds/{guild_id}/soundboard-sounds/{sound_id}',
guild_id=guild_id,
sound_id=sound_id,
),
reason=reason,
)
def send_soundboard_sound(self, channel_id: Snowflake, **payload: Any) -> Response[None]:
valid_keys = ('sound_id', 'source_guild_id')
payload = {k: v for k, v in payload.items() if k in valid_keys}
return self.request(
(Route('POST', '/channels/{channel_id}/send-soundboard-sound', channel_id=channel_id)), json=payload
)
# Application # Application
def application_info(self) -> Response[appinfo.AppInfo]: def application_info(self) -> Response[appinfo.AppInfo]:
@ -2626,30 +2715,58 @@ class HTTPClient:
) )
) )
# Misc # Subscriptions
async def get_gateway(self, *, encoding: str = 'json', zlib: bool = True) -> str: def list_sku_subscriptions(
try: self,
data = await self.request(Route('GET', '/gateway')) sku_id: Snowflake,
except HTTPException as exc: before: Optional[Snowflake] = None,
raise GatewayNotFound() from exc after: Optional[Snowflake] = None,
if zlib: limit: Optional[int] = None,
value = '{0}?encoding={1}&v={2}&compress=zlib-stream' user_id: Optional[Snowflake] = None,
else: ) -> Response[List[subscription.Subscription]]:
value = '{0}?encoding={1}&v={2}' params = {}
return value.format(data['url'], encoding, INTERNAL_API_VERSION)
if before is not None:
params['before'] = before
if after is not None:
params['after'] = after
async def get_bot_gateway(self, *, encoding: str = 'json', zlib: bool = True) -> Tuple[int, str]: if limit is not None:
params['limit'] = limit
if user_id is not None:
params['user_id'] = user_id
return self.request(
Route(
'GET',
'/skus/{sku_id}/subscriptions',
sku_id=sku_id,
),
params=params,
)
def get_sku_subscription(self, sku_id: Snowflake, subscription_id: Snowflake) -> Response[subscription.Subscription]:
return self.request(
Route(
'GET',
'/skus/{sku_id}/subscriptions/{subscription_id}',
sku_id=sku_id,
subscription_id=subscription_id,
)
)
# Misc
async def get_bot_gateway(self) -> Tuple[int, str, SessionStartLimit]:
try: try:
data = await self.request(Route('GET', '/gateway/bot')) data = await self.request(Route('GET', '/gateway/bot'))
except HTTPException as exc: except HTTPException as exc:
raise GatewayNotFound() from exc raise GatewayNotFound() from exc
if zlib: return data['shards'], data['url'], data['session_start_limit']
value = '{0}?encoding={1}&v={2}&compress=zlib-stream'
else:
value = '{0}?encoding={1}&v={2}'
return data['shards'], value.format(data['url'], encoding, INTERNAL_API_VERSION)
def get_user(self, user_id: Snowflake) -> Response[user.User]: def get_user(self, user_id: Snowflake) -> Response[user.User]:
return self.request(Route('GET', '/users/{user_id}', user_id=user_id)) return self.request(Route('GET', '/users/{user_id}', user_id=user_id))

3
discord/interactions.py

@ -199,6 +199,9 @@ class Interaction(Generic[ClientT]):
self.command_failed: bool = False self.command_failed: bool = False
self._from_data(data) self._from_data(data)
def __repr__(self) -> str:
return f'<{self.__class__.__name__} id={self.id} type={self.type!r} guild_id={self.guild_id!r} user={self.user!r}>'
def _from_data(self, data: InteractionPayload): def _from_data(self, data: InteractionPayload):
self.id: int = int(data['id']) self.id: int = int(data['id'])
self.type: InteractionType = try_enum(InteractionType, data['type']) self.type: InteractionType = try_enum(InteractionType, data['type'])

6
discord/member.py

@ -303,7 +303,7 @@ class Member(discord.abc.Messageable, _UserTag):
"Nitro boost" on the guild, if available. This could be ``None``. "Nitro boost" on the guild, if available. This could be ``None``.
timed_out_until: Optional[:class:`datetime.datetime`] timed_out_until: Optional[:class:`datetime.datetime`]
An aware datetime object that specifies the date and time in UTC that the member's time out will expire. An aware datetime object that specifies the date and time in UTC that the member's time out will expire.
This will be set to ``None`` if the user is not timed out. This will be set to ``None`` or a time in the past if the user is not timed out.
.. versionadded:: 2.0 .. versionadded:: 2.0
""" """
@ -1076,7 +1076,7 @@ class Member(discord.abc.Messageable, _UserTag):
You must have :attr:`~Permissions.manage_roles` to You must have :attr:`~Permissions.manage_roles` to
use this, and the added :class:`Role`\s must appear lower in the list use this, and the added :class:`Role`\s must appear lower in the list
of roles than the highest role of the member. of roles than the highest role of the client.
Parameters Parameters
----------- -----------
@ -1115,7 +1115,7 @@ class Member(discord.abc.Messageable, _UserTag):
You must have :attr:`~Permissions.manage_roles` to You must have :attr:`~Permissions.manage_roles` to
use this, and the removed :class:`Role`\s must appear lower in the list use this, and the removed :class:`Role`\s must appear lower in the list
of roles than the highest role of the member. of roles than the highest role of the client.
Parameters Parameters
----------- -----------

447
discord/message.py

@ -32,6 +32,7 @@ from os import PathLike
from typing import ( from typing import (
Dict, Dict,
TYPE_CHECKING, TYPE_CHECKING,
Literal,
Sequence, Sequence,
Union, Union,
List, List,
@ -49,7 +50,7 @@ from .asset import Asset
from .reaction import Reaction from .reaction import Reaction
from .emoji import Emoji from .emoji import Emoji
from .partial_emoji import PartialEmoji from .partial_emoji import PartialEmoji
from .enums import InteractionType, MessageType, ChannelType, try_enum from .enums import InteractionType, MessageReferenceType, MessageType, ChannelType, try_enum
from .errors import HTTPException from .errors import HTTPException
from .components import _component_factory from .components import _component_factory
from .embeds import Embed from .embeds import Embed
@ -72,10 +73,14 @@ if TYPE_CHECKING:
Message as MessagePayload, Message as MessagePayload,
Attachment as AttachmentPayload, Attachment as AttachmentPayload,
MessageReference as MessageReferencePayload, MessageReference as MessageReferencePayload,
MessageSnapshot as MessageSnapshotPayload,
MessageApplication as MessageApplicationPayload, MessageApplication as MessageApplicationPayload,
MessageActivity as MessageActivityPayload, MessageActivity as MessageActivityPayload,
RoleSubscriptionData as RoleSubscriptionDataPayload, RoleSubscriptionData as RoleSubscriptionDataPayload,
MessageInteractionMetadata as MessageInteractionMetadataPayload, MessageInteractionMetadata as MessageInteractionMetadataPayload,
CallMessage as CallMessagePayload,
PurchaseNotificationResponse as PurchaseNotificationResponsePayload,
GuildProductPurchase as GuildProductPurchasePayload,
) )
from .types.interactions import MessageInteraction as MessageInteractionPayload from .types.interactions import MessageInteraction as MessageInteractionPayload
@ -108,10 +113,14 @@ __all__ = (
'PartialMessage', 'PartialMessage',
'MessageInteraction', 'MessageInteraction',
'MessageReference', 'MessageReference',
'MessageSnapshot',
'DeletedReferencedMessage', 'DeletedReferencedMessage',
'MessageApplication', 'MessageApplication',
'RoleSubscriptionInfo', 'RoleSubscriptionInfo',
'MessageInteractionMetadata', 'MessageInteractionMetadata',
'CallMessage',
'GuildProductPurchase',
'PurchaseNotification',
) )
@ -458,6 +467,133 @@ class DeletedReferencedMessage:
return self._parent.guild_id return self._parent.guild_id
class MessageSnapshot:
"""Represents a message snapshot attached to a forwarded message.
.. versionadded:: 2.5
Attributes
-----------
type: :class:`MessageType`
The type of the forwarded message.
content: :class:`str`
The actual contents of the forwarded message.
embeds: List[:class:`Embed`]
A list of embeds the forwarded message has.
attachments: List[:class:`Attachment`]
A list of attachments given to the forwarded message.
created_at: :class:`datetime.datetime`
The forwarded message's time of creation.
flags: :class:`MessageFlags`
Extra features of the the message snapshot.
stickers: List[:class:`StickerItem`]
A list of sticker items given to the message.
components: List[Union[:class:`ActionRow`, :class:`Button`, :class:`SelectMenu`]]
A list of components in the message.
"""
__slots__ = (
'_cs_raw_channel_mentions',
'_cs_cached_message',
'_cs_raw_mentions',
'_cs_raw_role_mentions',
'_edited_timestamp',
'attachments',
'content',
'embeds',
'flags',
'created_at',
'type',
'stickers',
'components',
'_state',
)
@classmethod
def _from_value(
cls,
state: ConnectionState,
message_snapshots: Optional[List[Dict[Literal['message'], MessageSnapshotPayload]]],
) -> List[Self]:
if not message_snapshots:
return []
return [cls(state, snapshot['message']) for snapshot in message_snapshots]
def __init__(self, state: ConnectionState, data: MessageSnapshotPayload):
self.type: MessageType = try_enum(MessageType, data['type'])
self.content: str = data['content']
self.embeds: List[Embed] = [Embed.from_dict(a) for a in data['embeds']]
self.attachments: List[Attachment] = [Attachment(data=a, state=state) for a in data['attachments']]
self.created_at: datetime.datetime = utils.parse_time(data['timestamp'])
self._edited_timestamp: Optional[datetime.datetime] = utils.parse_time(data['edited_timestamp'])
self.flags: MessageFlags = MessageFlags._from_value(data.get('flags', 0))
self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])]
self.components: List[MessageComponentType] = []
for component_data in data.get('components', []):
component = _component_factory(component_data)
if component is not None:
self.components.append(component)
self._state: ConnectionState = state
def __repr__(self) -> str:
name = self.__class__.__name__
return f'<{name} type={self.type!r} created_at={self.created_at!r} flags={self.flags!r}>'
@utils.cached_slot_property('_cs_raw_mentions')
def raw_mentions(self) -> List[int]:
"""List[:class:`int`]: A property that returns an array of user IDs matched with
the syntax of ``<@user_id>`` in the message content.
This allows you to receive the user IDs of mentioned users
even in a private message context.
"""
return [int(x) for x in re.findall(r'<@!?([0-9]{15,20})>', self.content)]
@utils.cached_slot_property('_cs_raw_channel_mentions')
def raw_channel_mentions(self) -> List[int]:
"""List[:class:`int`]: A property that returns an array of channel IDs matched with
the syntax of ``<#channel_id>`` in the message content.
"""
return [int(x) for x in re.findall(r'<#([0-9]{15,20})>', self.content)]
@utils.cached_slot_property('_cs_raw_role_mentions')
def raw_role_mentions(self) -> List[int]:
"""List[:class:`int`]: A property that returns an array of role IDs matched with
the syntax of ``<@&role_id>`` in the message content.
"""
return [int(x) for x in re.findall(r'<@&([0-9]{15,20})>', self.content)]
@utils.cached_slot_property('_cs_cached_message')
def cached_message(self) -> Optional[Message]:
"""Optional[:class:`Message`]: Returns the cached message this snapshot points to, if any."""
state = self._state
return (
utils.find(
lambda m: (
m.created_at == self.created_at
and m.edited_at == self.edited_at
and m.content == self.content
and m.embeds == self.embeds
and m.components == self.components
and m.stickers == self.stickers
and m.attachments == self.attachments
and m.flags == self.flags
),
reversed(state._messages),
)
if state._messages
else None
)
@property
def edited_at(self) -> Optional[datetime.datetime]:
"""Optional[:class:`datetime.datetime`]: An aware UTC datetime object containing the edited time of the forwarded message."""
return self._edited_timestamp
class MessageReference: class MessageReference:
"""Represents a reference to a :class:`~discord.Message`. """Represents a reference to a :class:`~discord.Message`.
@ -468,6 +604,10 @@ class MessageReference:
Attributes Attributes
----------- -----------
type: :class:`MessageReferenceType`
The type of message reference.
.. versionadded:: 2.5
message_id: Optional[:class:`int`] message_id: Optional[:class:`int`]
The id of the message referenced. The id of the message referenced.
channel_id: :class:`int` channel_id: :class:`int`
@ -475,7 +615,7 @@ class MessageReference:
guild_id: Optional[:class:`int`] guild_id: Optional[:class:`int`]
The guild id of the message referenced. The guild id of the message referenced.
fail_if_not_exists: :class:`bool` fail_if_not_exists: :class:`bool`
Whether replying to the referenced message should raise :class:`HTTPException` Whether the referenced message should raise :class:`HTTPException`
if the message no longer exists or Discord could not fetch the message. if the message no longer exists or Discord could not fetch the message.
.. versionadded:: 1.7 .. versionadded:: 1.7
@ -487,15 +627,22 @@ class MessageReference:
If the message was resolved at a prior point but has since been deleted then If the message was resolved at a prior point but has since been deleted then
this will be of type :class:`DeletedReferencedMessage`. this will be of type :class:`DeletedReferencedMessage`.
Currently, this is mainly the replied to message when a user replies to a message.
.. versionadded:: 1.6 .. versionadded:: 1.6
""" """
__slots__ = ('message_id', 'channel_id', 'guild_id', 'fail_if_not_exists', 'resolved', '_state') __slots__ = ('type', 'message_id', 'channel_id', 'guild_id', 'fail_if_not_exists', 'resolved', '_state')
def __init__(self, *, message_id: int, channel_id: int, guild_id: Optional[int] = None, fail_if_not_exists: bool = True): def __init__(
self,
*,
message_id: int,
channel_id: int,
guild_id: Optional[int] = None,
fail_if_not_exists: bool = True,
type: MessageReferenceType = MessageReferenceType.reply,
):
self._state: Optional[ConnectionState] = None self._state: Optional[ConnectionState] = None
self.type: MessageReferenceType = type
self.resolved: Optional[Union[Message, DeletedReferencedMessage]] = None self.resolved: Optional[Union[Message, DeletedReferencedMessage]] = None
self.message_id: Optional[int] = message_id self.message_id: Optional[int] = message_id
self.channel_id: int = channel_id self.channel_id: int = channel_id
@ -505,6 +652,7 @@ class MessageReference:
@classmethod @classmethod
def with_state(cls, state: ConnectionState, data: MessageReferencePayload) -> Self: def with_state(cls, state: ConnectionState, data: MessageReferencePayload) -> Self:
self = cls.__new__(cls) self = cls.__new__(cls)
self.type = try_enum(MessageReferenceType, data.get('type', 0))
self.message_id = utils._get_as_snowflake(data, 'message_id') self.message_id = utils._get_as_snowflake(data, 'message_id')
self.channel_id = int(data['channel_id']) self.channel_id = int(data['channel_id'])
self.guild_id = utils._get_as_snowflake(data, 'guild_id') self.guild_id = utils._get_as_snowflake(data, 'guild_id')
@ -514,7 +662,13 @@ class MessageReference:
return self return self
@classmethod @classmethod
def from_message(cls, message: PartialMessage, *, fail_if_not_exists: bool = True) -> Self: def from_message(
cls,
message: PartialMessage,
*,
fail_if_not_exists: bool = True,
type: MessageReferenceType = MessageReferenceType.reply,
) -> Self:
"""Creates a :class:`MessageReference` from an existing :class:`~discord.Message`. """Creates a :class:`MessageReference` from an existing :class:`~discord.Message`.
.. versionadded:: 1.6 .. versionadded:: 1.6
@ -524,10 +678,14 @@ class MessageReference:
message: :class:`~discord.Message` message: :class:`~discord.Message`
The message to be converted into a reference. The message to be converted into a reference.
fail_if_not_exists: :class:`bool` fail_if_not_exists: :class:`bool`
Whether replying to the referenced message should raise :class:`HTTPException` Whether the referenced message should raise :class:`HTTPException`
if the message no longer exists or Discord could not fetch the message. if the message no longer exists or Discord could not fetch the message.
.. versionadded:: 1.7 .. versionadded:: 1.7
type: :class:`~discord.MessageReferenceType`
The type of message reference this is.
.. versionadded:: 2.5
Returns Returns
------- -------
@ -539,6 +697,7 @@ class MessageReference:
channel_id=message.channel.id, channel_id=message.channel.id,
guild_id=getattr(message.guild, 'id', None), guild_id=getattr(message.guild, 'id', None),
fail_if_not_exists=fail_if_not_exists, fail_if_not_exists=fail_if_not_exists,
type=type,
) )
self._state = message._state self._state = message._state
return self return self
@ -561,7 +720,9 @@ class MessageReference:
return f'<MessageReference message_id={self.message_id!r} channel_id={self.channel_id!r} guild_id={self.guild_id!r}>' return f'<MessageReference message_id={self.message_id!r} channel_id={self.channel_id!r} guild_id={self.guild_id!r}>'
def to_dict(self) -> MessageReferencePayload: def to_dict(self) -> MessageReferencePayload:
result: Dict[str, Any] = {'message_id': self.message_id} if self.message_id is not None else {} result: Dict[str, Any] = (
{'type': self.type.value, 'message_id': self.message_id} if self.message_id is not None else {}
)
result['channel_id'] = self.channel_id result['channel_id'] = self.channel_id
if self.guild_id is not None: if self.guild_id is not None:
result['guild_id'] = self.guild_id result['guild_id'] = self.guild_id
@ -667,6 +828,14 @@ class MessageInteractionMetadata(Hashable):
The ID of the message that containes the interactive components, if applicable. The ID of the message that containes the interactive components, if applicable.
modal_interaction: Optional[:class:`.MessageInteractionMetadata`] modal_interaction: Optional[:class:`.MessageInteractionMetadata`]
The metadata of the modal submit interaction that triggered this interaction, if applicable. The metadata of the modal submit interaction that triggered this interaction, if applicable.
target_user: Optional[:class:`User`]
The user the command was run on, only applicable to user context menus.
.. versionadded:: 2.5
target_message_id: Optional[:class:`int`]
The ID of the message the command was run on, only applicable to message context menus.
.. versionadded:: 2.5
""" """
__slots__: Tuple[str, ...] = ( __slots__: Tuple[str, ...] = (
@ -676,6 +845,8 @@ class MessageInteractionMetadata(Hashable):
'original_response_message_id', 'original_response_message_id',
'interacted_message_id', 'interacted_message_id',
'modal_interaction', 'modal_interaction',
'target_user',
'target_message_id',
'_integration_owners', '_integration_owners',
'_state', '_state',
'_guild', '_guild',
@ -687,31 +858,43 @@ class MessageInteractionMetadata(Hashable):
self.id: int = int(data['id']) self.id: int = int(data['id'])
self.type: InteractionType = try_enum(InteractionType, data['type']) self.type: InteractionType = try_enum(InteractionType, data['type'])
self.user = state.create_user(data['user']) self.user: User = state.create_user(data['user'])
self._integration_owners: Dict[int, int] = { self._integration_owners: Dict[int, int] = {
int(key): int(value) for key, value in data.get('authorizing_integration_owners', {}).items() int(key): int(value) for key, value in data.get('authorizing_integration_owners', {}).items()
} }
self.original_response_message_id: Optional[int] = None self.original_response_message_id: Optional[int] = None
try: try:
self.original_response_message_id = int(data['original_response_message_id']) self.original_response_message_id = int(data['original_response_message_id']) # type: ignore # EAFP
except KeyError: except KeyError:
pass pass
self.interacted_message_id: Optional[int] = None self.interacted_message_id: Optional[int] = None
try: try:
self.interacted_message_id = int(data['interacted_message_id']) self.interacted_message_id = int(data['interacted_message_id']) # type: ignore # EAFP
except KeyError: except KeyError:
pass pass
self.modal_interaction: Optional[MessageInteractionMetadata] = None self.modal_interaction: Optional[MessageInteractionMetadata] = None
try: try:
self.modal_interaction = MessageInteractionMetadata( self.modal_interaction = MessageInteractionMetadata(
state=state, guild=guild, data=data['triggering_interaction_metadata'] state=state, guild=guild, data=data['triggering_interaction_metadata'] # type: ignore # EAFP
) )
except KeyError: except KeyError:
pass pass
self.target_user: Optional[User] = None
try:
self.target_user = state.create_user(data['target_user']) # type: ignore # EAFP
except KeyError:
pass
self.target_message_id: Optional[int] = None
try:
self.target_message_id = int(data['target_message_id']) # type: ignore # EAFP
except KeyError:
pass
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<MessageInteraction id={self.id} type={self.type!r} user={self.user!r}>' return f'<MessageInteraction id={self.id} type={self.type!r} user={self.user!r}>'
@ -738,6 +921,16 @@ class MessageInteractionMetadata(Hashable):
return self._state._get_message(self.interacted_message_id) return self._state._get_message(self.interacted_message_id)
return None return None
@property
def target_message(self) -> Optional[Message]:
"""Optional[:class:`~discord.Message`]: The target message, if applicable and is found in cache.
.. versionadded:: 2.5
"""
if self.target_message_id:
return self._state._get_message(self.target_message_id)
return None
def is_guild_integration(self) -> bool: def is_guild_integration(self) -> bool:
""":class:`bool`: Returns ``True`` if the interaction is a guild integration.""" """:class:`bool`: Returns ``True`` if the interaction is a guild integration."""
if self._guild: if self._guild:
@ -810,6 +1003,51 @@ class MessageApplication:
return None return None
class CallMessage:
"""Represents a message's call data in a private channel from a :class:`~discord.Message`.
.. versionadded:: 2.5
Attributes
-----------
ended_timestamp: Optional[:class:`datetime.datetime`]
The timestamp the call has ended.
participants: List[:class:`User`]
A list of users that participated in the call.
"""
__slots__ = ('_message', 'ended_timestamp', 'participants')
def __repr__(self) -> str:
return f'<CallMessage participants={self.participants!r}>'
def __init__(self, *, state: ConnectionState, message: Message, data: CallMessagePayload):
self._message: Message = message
self.ended_timestamp: Optional[datetime.datetime] = utils.parse_time(data.get('ended_timestamp'))
self.participants: List[User] = []
for user_id in data['participants']:
user_id = int(user_id)
if user_id == self._message.author.id:
self.participants.append(self._message.author) # type: ignore # can't be a Member here
else:
user = state.get_user(user_id)
if user is not None:
self.participants.append(user)
@property
def duration(self) -> datetime.timedelta:
""":class:`datetime.timedelta`: The duration the call has lasted or is already ongoing."""
if self.ended_timestamp is None:
return utils.utcnow() - self._message.created_at
else:
return self.ended_timestamp - self._message.created_at
def is_ended(self) -> bool:
""":class:`bool`: Whether the call is ended or not."""
return self.ended_timestamp is not None
class RoleSubscriptionInfo: class RoleSubscriptionInfo:
"""Represents a message's role subscription information. """Represents a message's role subscription information.
@ -843,6 +1081,59 @@ class RoleSubscriptionInfo:
self.is_renewal: bool = data['is_renewal'] self.is_renewal: bool = data['is_renewal']
class GuildProductPurchase:
"""Represents a message's guild product that the user has purchased.
.. versionadded:: 2.5
Attributes
-----------
listing_id: :class:`int`
The ID of the listing that the user has purchased.
product_name: :class:`str`
The name of the product that the user has purchased.
"""
__slots__ = ('listing_id', 'product_name')
def __init__(self, data: GuildProductPurchasePayload) -> None:
self.listing_id: int = int(data['listing_id'])
self.product_name: str = data['product_name']
def __hash__(self) -> int:
return self.listing_id >> 22
def __eq__(self, other: object) -> bool:
return isinstance(other, GuildProductPurchase) and other.listing_id == self.listing_id
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
class PurchaseNotification:
"""Represents a message's purchase notification data.
This is currently only attached to messages of type :attr:`MessageType.purchase_notification`.
.. versionadded:: 2.5
Attributes
-----------
guild_product_purchase: Optional[:class:`GuildProductPurchase`]
The guild product purchase that prompted the message.
"""
__slots__ = ('_type', 'guild_product_purchase')
def __init__(self, data: PurchaseNotificationResponsePayload) -> None:
self._type: int = data['type']
self.guild_product_purchase: Optional[GuildProductPurchase] = None
guild_product_purchase = data.get('guild_product_purchase')
if guild_product_purchase is not None:
self.guild_product_purchase = GuildProductPurchase(guild_product_purchase)
class PartialMessage(Hashable): class PartialMessage(Hashable):
"""Represents a partial message to aid with working messages when only """Represents a partial message to aid with working messages when only
a message and channel ID are present. a message and channel ID are present.
@ -1102,6 +1393,8 @@ class PartialMessage(Hashable):
Forbidden Forbidden
Tried to suppress a message without permissions or Tried to suppress a message without permissions or
edited a message's content or embed that isn't yours. edited a message's content or embed that isn't yours.
NotFound
This message does not exist.
TypeError TypeError
You specified both ``embed`` and ``embeds`` You specified both ``embed`` and ``embeds``
@ -1593,7 +1886,12 @@ class PartialMessage(Hashable):
return Message(state=self._state, channel=self.channel, data=data) return Message(state=self._state, channel=self.channel, data=data)
def to_reference(self, *, fail_if_not_exists: bool = True) -> MessageReference: def to_reference(
self,
*,
fail_if_not_exists: bool = True,
type: MessageReferenceType = MessageReferenceType.reply,
) -> MessageReference:
"""Creates a :class:`~discord.MessageReference` from the current message. """Creates a :class:`~discord.MessageReference` from the current message.
.. versionadded:: 1.6 .. versionadded:: 1.6
@ -1601,10 +1899,14 @@ class PartialMessage(Hashable):
Parameters Parameters
---------- ----------
fail_if_not_exists: :class:`bool` fail_if_not_exists: :class:`bool`
Whether replying using the message reference should raise :class:`HTTPException` Whether the referenced message should raise :class:`HTTPException`
if the message no longer exists or Discord could not fetch the message. if the message no longer exists or Discord could not fetch the message.
.. versionadded:: 1.7 .. versionadded:: 1.7
type: :class:`MessageReferenceType`
The type of message reference.
.. versionadded:: 2.5
Returns Returns
--------- ---------
@ -1612,7 +1914,44 @@ class PartialMessage(Hashable):
The reference to this message. The reference to this message.
""" """
return MessageReference.from_message(self, fail_if_not_exists=fail_if_not_exists) return MessageReference.from_message(self, fail_if_not_exists=fail_if_not_exists, type=type)
async def forward(
self,
destination: MessageableChannel,
*,
fail_if_not_exists: bool = True,
) -> Message:
"""|coro|
Forwards this message to a channel.
.. versionadded:: 2.5
Parameters
----------
destination: :class:`~discord.abc.Messageable`
The channel to forward this message to.
fail_if_not_exists: :class:`bool`
Whether replying using the message reference should raise :class:`HTTPException`
if the message no longer exists or Discord could not fetch the message.
Raises
------
~discord.HTTPException
Forwarding the message failed.
Returns
-------
:class:`.Message`
The message sent to the channel.
"""
reference = self.to_reference(
fail_if_not_exists=fail_if_not_exists,
type=MessageReferenceType.forward,
)
ret = await destination.send(reference=reference)
return ret
def to_message_reference_dict(self) -> MessageReferencePayload: def to_message_reference_dict(self) -> MessageReferencePayload:
data: MessageReferencePayload = { data: MessageReferencePayload = {
@ -1768,6 +2107,18 @@ class Message(PartialMessage, Hashable):
The poll attached to this message. The poll attached to this message.
.. versionadded:: 2.4 .. versionadded:: 2.4
call: Optional[:class:`CallMessage`]
The call associated with this message.
.. versionadded:: 2.5
purchase_notification: Optional[:class:`PurchaseNotification`]
The data of the purchase notification that prompted this :attr:`MessageType.purchase_notification` message.
.. versionadded:: 2.5
message_snapshots: List[:class:`MessageSnapshot`]
The message snapshots attached to this message.
.. versionadded:: 2.5
""" """
__slots__ = ( __slots__ = (
@ -1804,6 +2155,9 @@ class Message(PartialMessage, Hashable):
'position', 'position',
'interaction_metadata', 'interaction_metadata',
'poll', 'poll',
'call',
'purchase_notification',
'message_snapshots',
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -1842,6 +2196,7 @@ class Message(PartialMessage, Hashable):
self.position: Optional[int] = data.get('position') self.position: Optional[int] = data.get('position')
self.application_id: Optional[int] = utils._get_as_snowflake(data, 'application_id') self.application_id: Optional[int] = utils._get_as_snowflake(data, 'application_id')
self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])] self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])]
self.message_snapshots: List[MessageSnapshot] = MessageSnapshot._from_value(state, data.get('message_snapshots'))
self.poll: Optional[Poll] = None self.poll: Optional[Poll] = None
try: try:
@ -1913,6 +2268,13 @@ class Message(PartialMessage, Hashable):
# the channel will be the correct type here # the channel will be the correct type here
ref.resolved = self.__class__(channel=chan, data=resolved, state=state) # type: ignore ref.resolved = self.__class__(channel=chan, data=resolved, state=state) # type: ignore
if self.type is MessageType.poll_result:
if isinstance(self.reference.resolved, self.__class__):
self._state._update_poll_results(self, self.reference.resolved)
else:
if self.reference.message_id:
self._state._update_poll_results(self, self.reference.message_id)
self.application: Optional[MessageApplication] = None self.application: Optional[MessageApplication] = None
try: try:
application = data['application'] application = data['application']
@ -1929,7 +2291,15 @@ class Message(PartialMessage, Hashable):
else: else:
self.role_subscription = RoleSubscriptionInfo(role_subscription) self.role_subscription = RoleSubscriptionInfo(role_subscription)
for handler in ('author', 'member', 'mentions', 'mention_roles', 'components'): self.purchase_notification: Optional[PurchaseNotification] = None
try:
purchase_notification = data['purchase_notification']
except KeyError:
pass
else:
self.purchase_notification = PurchaseNotification(purchase_notification)
for handler in ('author', 'member', 'mentions', 'mention_roles', 'components', 'call'):
try: try:
getattr(self, f'_handle_{handler}')(data[handler]) getattr(self, f'_handle_{handler}')(data[handler])
except KeyError: except KeyError:
@ -2115,6 +2485,13 @@ class Message(PartialMessage, Hashable):
def _handle_interaction_metadata(self, data: MessageInteractionMetadataPayload): def _handle_interaction_metadata(self, data: MessageInteractionMetadataPayload):
self.interaction_metadata = MessageInteractionMetadata(state=self._state, guild=self.guild, data=data) self.interaction_metadata = MessageInteractionMetadata(state=self._state, guild=self.guild, data=data)
def _handle_call(self, data: CallMessagePayload):
self.call: Optional[CallMessage]
if data is not None:
self.call = CallMessage(state=self._state, message=self, data=data)
else:
self.call = None
def _rebind_cached_references( def _rebind_cached_references(
self, self,
new_guild: Guild, new_guild: Guild,
@ -2264,6 +2641,7 @@ class Message(PartialMessage, Hashable):
MessageType.chat_input_command, MessageType.chat_input_command,
MessageType.context_menu_command, MessageType.context_menu_command,
MessageType.thread_starter_message, MessageType.thread_starter_message,
MessageType.poll_result,
) )
@utils.cached_slot_property('_cs_system_content') @utils.cached_slot_property('_cs_system_content')
@ -2385,10 +2763,10 @@ class Message(PartialMessage, Hashable):
return 'Wondering who to invite?\nStart by inviting anyone who can help you build the server!' return 'Wondering who to invite?\nStart by inviting anyone who can help you build the server!'
if self.type is MessageType.role_subscription_purchase and self.role_subscription is not None: if self.type is MessageType.role_subscription_purchase and self.role_subscription is not None:
# TODO: figure out how the message looks like for is_renewal: true
total_months = self.role_subscription.total_months_subscribed total_months = self.role_subscription.total_months_subscribed
months = '1 month' if total_months == 1 else f'{total_months} months' months = '1 month' if total_months == 1 else f'{total_months} months'
return f'{self.author.name} joined {self.role_subscription.tier_name} and has been a subscriber of {self.guild} for {months}!' action = 'renewed' if self.role_subscription.is_renewal else 'joined'
return f'{self.author.name} {action} **{self.role_subscription.tier_name}** and has been a subscriber of {self.guild} for {months}!'
if self.type is MessageType.stage_start: if self.type is MessageType.stage_start:
return f'{self.author.name} started **{self.content}**.' return f'{self.author.name} started **{self.content}**.'
@ -2419,6 +2797,35 @@ class Message(PartialMessage, Hashable):
if self.type is MessageType.guild_incident_report_false_alarm: if self.type is MessageType.guild_incident_report_false_alarm:
return f'{self.author.name} reported a false alarm in {self.guild}.' return f'{self.author.name} reported a false alarm in {self.guild}.'
if self.type is MessageType.call:
call_ended = self.call.ended_timestamp is not None # type: ignore # call can't be None here
missed = self._state.user not in self.call.participants # type: ignore # call can't be None here
if call_ended:
duration = utils._format_call_duration(self.call.duration) # type: ignore # call can't be None here
if missed:
return 'You missed a call from {0.author.name} that lasted {1}.'.format(self, duration)
else:
return '{0.author.name} started a call that lasted {1}.'.format(self, duration)
else:
if missed:
return '{0.author.name} started a call. \N{EM DASH} Join the call'.format(self)
else:
return '{0.author.name} started a call.'.format(self)
if self.type is MessageType.purchase_notification and self.purchase_notification is not None:
guild_product_purchase = self.purchase_notification.guild_product_purchase
if guild_product_purchase is not None:
return f'{self.author.name} has purchased {guild_product_purchase.product_name}!'
if self.type is MessageType.poll_result:
embed = self.embeds[0] # Will always have 1 embed
poll_title = utils.get(
embed.fields,
name='poll_question_text',
)
return f'{self.author.display_name}\'s poll {poll_title.value} has closed.' # type: ignore
# Fallback for unknown message types # Fallback for unknown message types
return '' return ''
@ -2529,6 +2936,8 @@ class Message(PartialMessage, Hashable):
Forbidden Forbidden
Tried to suppress a message without permissions or Tried to suppress a message without permissions or
edited a message's content or embed that isn't yours. edited a message's content or embed that isn't yours.
NotFound
This message does not exist.
TypeError TypeError
You specified both ``embed`` and ``embeds`` You specified both ``embed`` and ``embeds``

12
discord/player.py

@ -588,21 +588,25 @@ class FFmpegOpusAudio(FFmpegAudio):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
try: try:
codec, bitrate = await loop.run_in_executor(None, lambda: probefunc(source, executable)) codec, bitrate = await loop.run_in_executor(None, lambda: probefunc(source, executable))
except Exception: except (KeyboardInterrupt, SystemExit):
raise
except BaseException:
if not fallback: if not fallback:
_log.exception("Probe '%s' using '%s' failed", method, executable) _log.exception("Probe '%s' using '%s' failed", method, executable)
return # type: ignore return None, None
_log.exception("Probe '%s' using '%s' failed, trying fallback", method, executable) _log.exception("Probe '%s' using '%s' failed, trying fallback", method, executable)
try: try:
codec, bitrate = await loop.run_in_executor(None, lambda: fallback(source, executable)) codec, bitrate = await loop.run_in_executor(None, lambda: fallback(source, executable))
except Exception: except (KeyboardInterrupt, SystemExit):
raise
except BaseException:
_log.exception("Fallback probe using '%s' failed", executable) _log.exception("Fallback probe using '%s' failed", executable)
else: else:
_log.debug("Fallback probe found codec=%s, bitrate=%s", codec, bitrate) _log.debug("Fallback probe found codec=%s, bitrate=%s", codec, bitrate)
else: else:
_log.debug("Probe found codec=%s, bitrate=%s", codec, bitrate) _log.debug("Probe found codec=%s, bitrate=%s", codec, bitrate)
finally:
return codec, bitrate return codec, bitrate
@staticmethod @staticmethod

100
discord/poll.py

@ -29,7 +29,7 @@ from typing import Optional, List, TYPE_CHECKING, Union, AsyncIterator, Dict
import datetime import datetime
from .enums import PollLayoutType, try_enum from .enums import PollLayoutType, try_enum, MessageType
from . import utils from . import utils
from .emoji import PartialEmoji, Emoji from .emoji import PartialEmoji, Emoji
from .user import User from .user import User
@ -125,7 +125,16 @@ class PollAnswer:
Whether the current user has voted to this answer or not. Whether the current user has voted to this answer or not.
""" """
__slots__ = ('media', 'id', '_state', '_message', '_vote_count', 'self_voted', '_poll') __slots__ = (
'media',
'id',
'_state',
'_message',
'_vote_count',
'self_voted',
'_poll',
'_victor',
)
def __init__( def __init__(
self, self,
@ -141,6 +150,7 @@ class PollAnswer:
self._vote_count: int = 0 self._vote_count: int = 0
self.self_voted: bool = False self.self_voted: bool = False
self._poll: Poll = poll self._poll: Poll = poll
self._victor: bool = False
def _handle_vote_event(self, added: bool, self_voted: bool) -> None: def _handle_vote_event(self, added: bool, self_voted: bool) -> None:
if added: if added:
@ -210,6 +220,19 @@ class PollAnswer:
'poll_media': self.media.to_dict(), 'poll_media': self.media.to_dict(),
} }
@property
def victor(self) -> bool:
""":class:`bool`: Whether the answer is the one that had the most
votes when the poll ended.
.. versionadded:: 2.5
.. note::
If the poll has not ended, this will always return ``False``.
"""
return self._victor
async def voters( async def voters(
self, *, limit: Optional[int] = None, after: Optional[Snowflake] = None self, *, limit: Optional[int] = None, after: Optional[Snowflake] = None
) -> AsyncIterator[Union[User, Member]]: ) -> AsyncIterator[Union[User, Member]]:
@ -325,6 +348,8 @@ class Poll:
'_expiry', '_expiry',
'_finalized', '_finalized',
'_state', '_state',
'_total_votes',
'_victor_answer_id',
) )
def __init__( def __init__(
@ -348,6 +373,8 @@ class Poll:
self._state: Optional[ConnectionState] = None self._state: Optional[ConnectionState] = None
self._finalized: bool = False self._finalized: bool = False
self._expiry: Optional[datetime.datetime] = None self._expiry: Optional[datetime.datetime] = None
self._total_votes: Optional[int] = None
self._victor_answer_id: Optional[int] = None
def _update(self, message: Message) -> None: def _update(self, message: Message) -> None:
self._state = message._state self._state = message._state
@ -359,6 +386,34 @@ class Poll:
# The message's poll contains the more up to date data. # The message's poll contains the more up to date data.
self._expiry = message.poll.expires_at self._expiry = message.poll.expires_at
self._finalized = message.poll._finalized self._finalized = message.poll._finalized
self._answers = message.poll._answers
self._update_results_from_message(message)
def _update_results_from_message(self, message: Message) -> None:
if message.type != MessageType.poll_result or not message.embeds:
return
result_embed = message.embeds[0] # Will always have 1 embed
fields: Dict[str, str] = {field.name: field.value for field in result_embed.fields} # type: ignore
total_votes = fields.get('total_votes')
if total_votes is not None:
self._total_votes = int(total_votes)
victor_answer = fields.get('victor_answer_id')
if victor_answer is None:
return # Can't do anything else without the victor answer
self._victor_answer_id = int(victor_answer)
victor_answer_votes = fields['victor_answer_votes']
answer = self._answers[self._victor_answer_id]
answer._victor = True
answer._vote_count = int(victor_answer_votes)
self._answers[answer.id] = answer # Ensure update
def _update_results(self, data: PollResultPayload) -> None: def _update_results(self, data: PollResultPayload) -> None:
self._finalized = data['is_finalized'] self._finalized = data['is_finalized']
@ -431,6 +486,32 @@ class Poll:
"""List[:class:`PollAnswer`]: Returns a read-only copy of the answers.""" """List[:class:`PollAnswer`]: Returns a read-only copy of the answers."""
return list(self._answers.values()) return list(self._answers.values())
@property
def victor_answer_id(self) -> Optional[int]:
"""Optional[:class:`int`]: The victor answer ID.
.. versionadded:: 2.5
.. note::
This will **always** be ``None`` for polls that have not yet finished.
"""
return self._victor_answer_id
@property
def victor_answer(self) -> Optional[PollAnswer]:
"""Optional[:class:`PollAnswer`]: The victor answer.
.. versionadded:: 2.5
.. note::
This will **always** be ``None`` for polls that have not yet finished.
"""
if self.victor_answer_id is None:
return None
return self.get_answer(self.victor_answer_id)
@property @property
def expires_at(self) -> Optional[datetime.datetime]: def expires_at(self) -> Optional[datetime.datetime]:
"""Optional[:class:`datetime.datetime`]: A datetime object representing the poll expiry. """Optional[:class:`datetime.datetime`]: A datetime object representing the poll expiry.
@ -456,12 +537,20 @@ class Poll:
@property @property
def message(self) -> Optional[Message]: def message(self) -> Optional[Message]:
""":class:`Message`: The message this poll is from.""" """Optional[:class:`Message`]: The message this poll is from."""
return self._message return self._message
@property @property
def total_votes(self) -> int: def total_votes(self) -> int:
""":class:`int`: Returns the sum of all the answer votes.""" """:class:`int`: Returns the sum of all the answer votes.
If the poll has not yet finished, this is an approximate vote count.
.. versionchanged:: 2.5
This now returns an exact vote count when updated from its poll results message.
"""
if self._total_votes is not None:
return self._total_votes
return sum([answer.vote_count for answer in self.answers]) return sum([answer.vote_count for answer in self.answers])
def is_finalised(self) -> bool: def is_finalised(self) -> bool:
@ -568,6 +657,7 @@ class Poll:
if not self._message or not self._state: # Make type checker happy if not self._message or not self._state: # Make type checker happy
raise ClientException('This poll has no attached message.') raise ClientException('This poll has no attached message.')
self._message = await self._message.end_poll() message = await self._message.end_poll()
self._update(message)
return self return self

18
discord/raw_models.py

@ -166,20 +166,22 @@ class RawMessageUpdateEvent(_RawReprMixin):
cached_message: Optional[:class:`Message`] cached_message: Optional[:class:`Message`]
The cached message, if found in the internal message cache. Represents the message before The cached message, if found in the internal message cache. Represents the message before
it is modified by the data in :attr:`RawMessageUpdateEvent.data`. it is modified by the data in :attr:`RawMessageUpdateEvent.data`.
message: :class:`Message`
The updated message.
.. versionadded:: 2.5
""" """
__slots__ = ('message_id', 'channel_id', 'guild_id', 'data', 'cached_message') __slots__ = ('message_id', 'channel_id', 'guild_id', 'data', 'cached_message', 'message')
def __init__(self, data: MessageUpdateEvent) -> None: def __init__(self, data: MessageUpdateEvent, message: Message) -> None:
self.message_id: int = int(data['id']) self.message_id: int = message.id
self.channel_id: int = int(data['channel_id']) self.channel_id: int = message.channel.id
self.data: MessageUpdateEvent = data self.data: MessageUpdateEvent = data
self.message: Message = message
self.cached_message: Optional[Message] = None self.cached_message: Optional[Message] = None
try: self.guild_id: Optional[int] = message.guild.id if message.guild else None
self.guild_id: Optional[int] = int(data['guild_id'])
except KeyError:
self.guild_id: Optional[int] = None
class RawReactionActionEvent(_RawReprMixin): class RawReactionActionEvent(_RawReprMixin):

58
discord/shard.py

@ -47,13 +47,16 @@ from .enums import Status
from typing import TYPE_CHECKING, Any, Callable, Tuple, Type, Optional, List, Dict from typing import TYPE_CHECKING, Any, Callable, Tuple, Type, Optional, List, Dict
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Unpack
from .gateway import DiscordWebSocket from .gateway import DiscordWebSocket
from .activity import BaseActivity from .activity import BaseActivity
from .flags import Intents from .flags import Intents
from .types.gateway import SessionStartLimit
__all__ = ( __all__ = (
'AutoShardedClient', 'AutoShardedClient',
'ShardInfo', 'ShardInfo',
'SessionStartLimits',
) )
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@ -293,6 +296,32 @@ class ShardInfo:
return self._parent.ws.is_ratelimited() return self._parent.ws.is_ratelimited()
class SessionStartLimits:
"""A class that holds info about session start limits
.. versionadded:: 2.5
Attributes
----------
total: :class:`int`
The total number of session starts the current user is allowed
remaining: :class:`int`
Remaining remaining number of session starts the current user is allowed
reset_after: :class:`int`
The number of milliseconds until the limit resets
max_concurrency: :class:`int`
The number of identify requests allowed per 5 seconds
"""
__slots__ = ("total", "remaining", "reset_after", "max_concurrency")
def __init__(self, **kwargs: Unpack[SessionStartLimit]):
self.total: int = kwargs['total']
self.remaining: int = kwargs['remaining']
self.reset_after: int = kwargs['reset_after']
self.max_concurrency: int = kwargs['max_concurrency']
class AutoShardedClient(Client): class AutoShardedClient(Client):
"""A client similar to :class:`Client` except it handles the complications """A client similar to :class:`Client` except it handles the complications
of sharding for the user into a more manageable and transparent single of sharding for the user into a more manageable and transparent single
@ -415,6 +444,33 @@ class AutoShardedClient(Client):
"""Mapping[int, :class:`ShardInfo`]: Returns a mapping of shard IDs to their respective info object.""" """Mapping[int, :class:`ShardInfo`]: Returns a mapping of shard IDs to their respective info object."""
return {shard_id: ShardInfo(parent, self.shard_count) for shard_id, parent in self.__shards.items()} return {shard_id: ShardInfo(parent, self.shard_count) for shard_id, parent in self.__shards.items()}
async def fetch_session_start_limits(self) -> SessionStartLimits:
"""|coro|
Get the session start limits.
This is not typically needed, and will be handled for you by default.
At the point where you are launching multiple instances
with manual shard ranges and are considered required to use large bot
sharding by Discord, this function when used along IPC and a
before_identity_hook can speed up session start.
.. versionadded:: 2.5
Returns
-------
:class:`SessionStartLimits`
A class containing the session start limits
Raises
------
GatewayNotFound
The gateway was unreachable
"""
_, _, limits = await self.http.get_bot_gateway()
return SessionStartLimits(**limits)
async def launch_shard(self, gateway: yarl.URL, shard_id: int, *, initial: bool = False) -> None: async def launch_shard(self, gateway: yarl.URL, shard_id: int, *, initial: bool = False) -> None:
try: try:
coro = DiscordWebSocket.from_client(self, initial=initial, gateway=gateway, shard_id=shard_id) coro = DiscordWebSocket.from_client(self, initial=initial, gateway=gateway, shard_id=shard_id)
@ -434,7 +490,7 @@ class AutoShardedClient(Client):
if self.shard_count is None: if self.shard_count is None:
self.shard_count: int self.shard_count: int
self.shard_count, gateway_url = await self.http.get_bot_gateway() self.shard_count, gateway_url, _session_start_limit = await self.http.get_bot_gateway()
gateway = yarl.URL(gateway_url) gateway = yarl.URL(gateway_url)
else: else:
gateway = DiscordWebSocket.DEFAULT_GATEWAY gateway = DiscordWebSocket.DEFAULT_GATEWAY

163
discord/sku.py

@ -25,16 +25,18 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations from __future__ import annotations
from typing import Optional, TYPE_CHECKING from typing import AsyncIterator, Optional, TYPE_CHECKING
from datetime import datetime
from . import utils from . import utils
from .errors import MissingApplicationID
from .enums import try_enum, SKUType, EntitlementType from .enums import try_enum, SKUType, EntitlementType
from .flags import SKUFlags from .flags import SKUFlags
from .object import Object
from .subscription import Subscription
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime from .abc import SnowflakeTime, Snowflake
from .guild import Guild from .guild import Guild
from .state import ConnectionState from .state import ConnectionState
from .types.sku import ( from .types.sku import (
@ -100,6 +102,149 @@ class SKU:
""":class:`datetime.datetime`: Returns the sku's creation time in UTC.""" """:class:`datetime.datetime`: Returns the sku's creation time in UTC."""
return utils.snowflake_time(self.id) return utils.snowflake_time(self.id)
async def fetch_subscription(self, subscription_id: int, /) -> Subscription:
"""|coro|
Retrieves a :class:`.Subscription` with the specified ID.
.. versionadded:: 2.5
Parameters
-----------
subscription_id: :class:`int`
The subscription's ID to fetch from.
Raises
-------
NotFound
An subscription with this ID does not exist.
HTTPException
Fetching the subscription failed.
Returns
--------
:class:`.Subscription`
The subscription you requested.
"""
data = await self._state.http.get_sku_subscription(self.id, subscription_id)
return Subscription(data=data, state=self._state)
async def subscriptions(
self,
*,
limit: Optional[int] = 50,
before: Optional[SnowflakeTime] = None,
after: Optional[SnowflakeTime] = None,
user: Snowflake,
) -> AsyncIterator[Subscription]:
"""Retrieves an :term:`asynchronous iterator` of the :class:`.Subscription` that SKU has.
.. versionadded:: 2.5
Examples
---------
Usage ::
async for subscription in sku.subscriptions(limit=100, user=user):
print(subscription.user_id, subscription.current_period_end)
Flattening into a list ::
subscriptions = [subscription async for subscription in sku.subscriptions(limit=100, user=user)]
# subscriptions is now a list of Subscription...
All parameters are optional.
Parameters
-----------
limit: Optional[:class:`int`]
The number of subscriptions to retrieve. If ``None``, it retrieves every subscription for this SKU.
Note, however, that this would make it a slow operation. Defaults to ``100``.
before: Optional[Union[:class:`~discord.abc.Snowflake`, :class:`datetime.datetime`]]
Retrieve subscriptions before this date or entitlement.
If a datetime is provided, it is recommended to use a UTC aware datetime.
If the datetime is naive, it is assumed to be local time.
after: Optional[Union[:class:`~discord.abc.Snowflake`, :class:`datetime.datetime`]]
Retrieve subscriptions after this date or entitlement.
If a datetime is provided, it is recommended to use a UTC aware datetime.
If the datetime is naive, it is assumed to be local time.
user: :class:`~discord.abc.Snowflake`
The user to filter by.
Raises
-------
HTTPException
Fetching the subscriptions failed.
TypeError
Both ``after`` and ``before`` were provided, as Discord does not
support this type of pagination.
Yields
--------
:class:`.Subscription`
The subscription with the SKU.
"""
if before is not None and after is not None:
raise TypeError('subscriptions pagination does not support both before and after')
# This endpoint paginates in ascending order.
endpoint = self._state.http.list_sku_subscriptions
async def _before_strategy(retrieve: int, before: Optional[Snowflake], limit: Optional[int]):
before_id = before.id if before else None
data = await endpoint(self.id, before=before_id, limit=retrieve, user_id=user.id)
if data:
if limit is not None:
limit -= len(data)
before = Object(id=int(data[0]['id']))
return data, before, limit
async def _after_strategy(retrieve: int, after: Optional[Snowflake], limit: Optional[int]):
after_id = after.id if after else None
data = await endpoint(
self.id,
after=after_id,
limit=retrieve,
user_id=user.id,
)
if data:
if limit is not None:
limit -= len(data)
after = Object(id=int(data[-1]['id']))
return data, after, limit
if isinstance(before, datetime):
before = Object(id=utils.time_snowflake(before, high=False))
if isinstance(after, datetime):
after = Object(id=utils.time_snowflake(after, high=True))
if before:
strategy, state = _before_strategy, before
else:
strategy, state = _after_strategy, after
while True:
retrieve = 100 if limit is None else min(limit, 100)
if retrieve < 1:
return
data, state, limit = await strategy(retrieve, state, limit)
# Terminate loop on next iteration; there's no data left after this
if len(data) < 100:
limit = 0
for e in data:
yield Subscription(data=e, state=self._state)
class Entitlement: class Entitlement:
"""Represents an entitlement from user or guild which has been granted access to a premium offering. """Represents an entitlement from user or guild which has been granted access to a premium offering.
@ -190,17 +335,12 @@ class Entitlement:
Raises Raises
------- -------
MissingApplicationID
The application ID could not be found.
NotFound NotFound
The entitlement could not be found. The entitlement could not be found.
HTTPException HTTPException
Consuming the entitlement failed. Consuming the entitlement failed.
""" """
if self.application_id is None:
raise MissingApplicationID
await self._state.http.consume_entitlement(self.application_id, self.id) await self._state.http.consume_entitlement(self.application_id, self.id)
async def delete(self) -> None: async def delete(self) -> None:
@ -210,15 +350,10 @@ class Entitlement:
Raises Raises
------- -------
MissingApplicationID
The application ID could not be found.
NotFound NotFound
The entitlement could not be found. The entitlement could not be found.
HTTPException HTTPException
Deleting the entitlement failed. Deleting the entitlement failed.
""" """
if self.application_id is None:
raise MissingApplicationID
await self._state.http.delete_entitlement(self.application_id, self.id) await self._state.http.delete_entitlement(self.application_id, self.id)

325
discord/soundboard.py

@ -0,0 +1,325 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from . import utils
from .mixins import Hashable
from .partial_emoji import PartialEmoji, _EmojiTag
from .user import User
from .utils import MISSING
from .asset import Asset, AssetMixin
if TYPE_CHECKING:
import datetime
from typing import Dict, Any
from .types.soundboard import (
BaseSoundboardSound as BaseSoundboardSoundPayload,
SoundboardDefaultSound as SoundboardDefaultSoundPayload,
SoundboardSound as SoundboardSoundPayload,
)
from .state import ConnectionState
from .guild import Guild
from .message import EmojiInputType
__all__ = ('BaseSoundboardSound', 'SoundboardDefaultSound', 'SoundboardSound')
class BaseSoundboardSound(Hashable, AssetMixin):
"""Represents a generic Discord soundboard sound.
.. versionadded:: 2.5
.. container:: operations
.. describe:: x == y
Checks if two sounds are equal.
.. describe:: x != y
Checks if two sounds are not equal.
.. describe:: hash(x)
Returns the sound's hash.
Attributes
------------
id: :class:`int`
The ID of the sound.
volume: :class:`float`
The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%).
"""
__slots__ = ('_state', 'id', 'volume')
def __init__(self, *, state: ConnectionState, data: BaseSoundboardSoundPayload):
self._state: ConnectionState = state
self.id: int = int(data['sound_id'])
self._update(data)
def __eq__(self, other: object) -> bool:
if isinstance(other, self.__class__):
return self.id == other.id
return NotImplemented
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
def _update(self, data: BaseSoundboardSoundPayload):
self.volume: float = data['volume']
@property
def url(self) -> str:
""":class:`str`: Returns the URL of the sound."""
return f'{Asset.BASE}/soundboard-sounds/{self.id}'
class SoundboardDefaultSound(BaseSoundboardSound):
"""Represents a Discord soundboard default sound.
.. versionadded:: 2.5
.. container:: operations
.. describe:: x == y
Checks if two sounds are equal.
.. describe:: x != y
Checks if two sounds are not equal.
.. describe:: hash(x)
Returns the sound's hash.
Attributes
------------
id: :class:`int`
The ID of the sound.
volume: :class:`float`
The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%).
name: :class:`str`
The name of the sound.
emoji: :class:`PartialEmoji`
The emoji of the sound.
"""
__slots__ = ('name', 'emoji')
def __init__(self, *, state: ConnectionState, data: SoundboardDefaultSoundPayload):
self.name: str = data['name']
self.emoji: PartialEmoji = PartialEmoji(name=data['emoji_name'])
super().__init__(state=state, data=data)
def __repr__(self) -> str:
attrs = [
('id', self.id),
('name', self.name),
('volume', self.volume),
('emoji', self.emoji),
]
inner = ' '.join('%s=%r' % t for t in attrs)
return f"<{self.__class__.__name__} {inner}>"
class SoundboardSound(BaseSoundboardSound):
"""Represents a Discord soundboard sound.
.. versionadded:: 2.5
.. container:: operations
.. describe:: x == y
Checks if two sounds are equal.
.. describe:: x != y
Checks if two sounds are not equal.
.. describe:: hash(x)
Returns the sound's hash.
Attributes
------------
id: :class:`int`
The ID of the sound.
volume: :class:`float`
The volume of the sound as floating point percentage (e.g. ``1.0`` for 100%).
name: :class:`str`
The name of the sound.
emoji: Optional[:class:`PartialEmoji`]
The emoji of the sound. ``None`` if no emoji is set.
guild: :class:`Guild`
The guild in which the sound is uploaded.
available: :class:`bool`
Whether this sound is available for use.
"""
__slots__ = ('_state', 'name', 'emoji', '_user', 'available', '_user_id', 'guild')
def __init__(self, *, guild: Guild, state: ConnectionState, data: SoundboardSoundPayload):
super().__init__(state=state, data=data)
self.guild = guild
self._user_id = utils._get_as_snowflake(data, 'user_id')
self._user = data.get('user')
self._update(data)
def __repr__(self) -> str:
attrs = [
('id', self.id),
('name', self.name),
('volume', self.volume),
('emoji', self.emoji),
('user', self.user),
]
inner = ' '.join('%s=%r' % t for t in attrs)
return f"<{self.__class__.__name__} {inner}>"
def _update(self, data: SoundboardSoundPayload):
super()._update(data)
self.name: str = data['name']
self.emoji: Optional[PartialEmoji] = None
emoji_id = utils._get_as_snowflake(data, 'emoji_id')
emoji_name = data['emoji_name']
if emoji_id is not None or emoji_name is not None:
self.emoji = PartialEmoji(id=emoji_id, name=emoji_name) # type: ignore # emoji_name cannot be None here
self.available: bool = data['available']
@property
def created_at(self) -> datetime.datetime:
""":class:`datetime.datetime`: Returns the snowflake's creation time in UTC."""
return utils.snowflake_time(self.id)
@property
def user(self) -> Optional[User]:
"""Optional[:class:`User`]: The user who uploaded the sound."""
if self._user is None:
if self._user_id is None:
return None
return self._state.get_user(self._user_id)
return User(state=self._state, data=self._user)
async def edit(
self,
*,
name: str = MISSING,
volume: Optional[float] = MISSING,
emoji: Optional[EmojiInputType] = MISSING,
reason: Optional[str] = None,
):
"""|coro|
Edits the soundboard sound.
You must have :attr:`~Permissions.manage_expressions` to edit the sound.
If the sound was created by the client, you must have either :attr:`~Permissions.manage_expressions`
or :attr:`~Permissions.create_expressions`.
Parameters
----------
name: :class:`str`
The new name of the sound. Must be between 2 and 32 characters.
volume: Optional[:class:`float`]
The new volume of the sound. Must be between 0 and 1.
emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]]
The new emoji of the sound.
reason: Optional[:class:`str`]
The reason for editing this sound. Shows up on the audit log.
Raises
-------
Forbidden
You do not have permissions to edit the soundboard sound.
HTTPException
Editing the soundboard sound failed.
Returns
-------
:class:`SoundboardSound`
The newly updated soundboard sound.
"""
payload: Dict[str, Any] = {}
if name is not MISSING:
payload['name'] = name
if volume is not MISSING:
payload['volume'] = volume
if emoji is not MISSING:
if emoji is None:
payload['emoji_id'] = None
payload['emoji_name'] = None
else:
if isinstance(emoji, _EmojiTag):
partial_emoji = emoji._to_partial()
elif isinstance(emoji, str):
partial_emoji = PartialEmoji.from_str(emoji)
else:
partial_emoji = None
if partial_emoji is not None:
if partial_emoji.id is None:
payload['emoji_name'] = partial_emoji.name
else:
payload['emoji_id'] = partial_emoji.id
data = await self._state.http.edit_soundboard_sound(self.guild.id, self.id, reason=reason, **payload)
return SoundboardSound(guild=self.guild, state=self._state, data=data)
async def delete(self, *, reason: Optional[str] = None) -> None:
"""|coro|
Deletes the soundboard sound.
You must have :attr:`~Permissions.manage_expressions` to delete the sound.
If the sound was created by the client, you must have either :attr:`~Permissions.manage_expressions`
or :attr:`~Permissions.create_expressions`.
Parameters
-----------
reason: Optional[:class:`str`]
The reason for deleting this sound. Shows up on the audit log.
Raises
-------
Forbidden
You do not have permissions to delete the soundboard sound.
HTTPException
Deleting the soundboard sound failed.
"""
await self._state.http.delete_soundboard_sound(self.guild.id, self.id, reason=reason)

136
discord/state.py

@ -78,6 +78,9 @@ from .sticker import GuildSticker
from .automod import AutoModRule, AutoModAction from .automod import AutoModRule, AutoModAction
from .audit_logs import AuditLogEntry from .audit_logs import AuditLogEntry
from ._types import ClientT from ._types import ClientT
from .soundboard import SoundboardSound
from .subscription import Subscription
if TYPE_CHECKING: if TYPE_CHECKING:
from .abc import PrivateChannel from .abc import PrivateChannel
@ -455,6 +458,14 @@ class ConnectionState(Generic[ClientT]):
def stickers(self) -> Sequence[GuildSticker]: def stickers(self) -> Sequence[GuildSticker]:
return utils.SequenceProxy(self._stickers.values()) return utils.SequenceProxy(self._stickers.values())
@property
def soundboard_sounds(self) -> List[SoundboardSound]:
all_sounds = []
for guild in self.guilds:
all_sounds.extend(guild.soundboard_sounds)
return all_sounds
def get_emoji(self, emoji_id: Optional[int]) -> Optional[Emoji]: def get_emoji(self, emoji_id: Optional[int]) -> Optional[Emoji]:
# the keys of self._emojis are ints # the keys of self._emojis are ints
return self._emojis.get(emoji_id) # type: ignore return self._emojis.get(emoji_id) # type: ignore
@ -541,6 +552,27 @@ class ConnectionState(Generic[ClientT]):
poll._handle_vote(answer_id, added, self_voted) poll._handle_vote(answer_id, added, self_voted)
return poll return poll
def _update_poll_results(self, from_: Message, to: Union[Message, int]) -> None:
if isinstance(to, Message):
cached = self._get_message(to.id)
elif isinstance(to, int):
cached = self._get_message(to)
if cached is None:
return
to = cached
else:
return
if to.poll is None:
return
to.poll._update_results_from_message(from_)
if cached is not None and cached.poll:
cached.poll._update_results_from_message(from_)
async def chunker( async def chunker(
self, guild_id: int, query: str = '', limit: int = 0, presences: bool = False, *, nonce: Optional[str] = None self, guild_id: int, query: str = '', limit: int = 0, presences: bool = False, *, nonce: Optional[str] = None
) -> None: ) -> None:
@ -679,17 +711,21 @@ class ConnectionState(Generic[ClientT]):
self._messages.remove(msg) # type: ignore self._messages.remove(msg) # type: ignore
def parse_message_update(self, data: gw.MessageUpdateEvent) -> None: def parse_message_update(self, data: gw.MessageUpdateEvent) -> None:
raw = RawMessageUpdateEvent(data) channel, _ = self._get_guild_channel(data)
message = self._get_message(raw.message_id) # channel would be the correct type here
if message is not None: updated_message = Message(channel=channel, data=data, state=self) # type: ignore
older_message = copy.copy(message)
raw = RawMessageUpdateEvent(data=data, message=updated_message)
cached_message = self._get_message(updated_message.id)
if cached_message is not None:
older_message = copy.copy(cached_message)
raw.cached_message = older_message raw.cached_message = older_message
self.dispatch('raw_message_edit', raw) self.dispatch('raw_message_edit', raw)
message._update(data) cached_message._update(data)
# Coerce the `after` parameter to take the new updated Member # Coerce the `after` parameter to take the new updated Member
# ref: #5999 # ref: #5999
older_message.author = message.author older_message.author = updated_message.author
self.dispatch('message_edit', older_message, message) self.dispatch('message_edit', older_message, updated_message)
else: else:
self.dispatch('raw_message_edit', raw) self.dispatch('raw_message_edit', raw)
@ -1555,6 +1591,63 @@ class ConnectionState(Generic[ClientT]):
else: else:
_log.debug('SCHEDULED_EVENT_USER_REMOVE referencing unknown guild ID: %s. Discarding.', data['guild_id']) _log.debug('SCHEDULED_EVENT_USER_REMOVE referencing unknown guild ID: %s. Discarding.', data['guild_id'])
def parse_guild_soundboard_sound_create(self, data: gw.GuildSoundBoardSoundCreateEvent) -> None:
guild_id = int(data['guild_id']) # type: ignore # can't be None here
guild = self._get_guild(guild_id)
if guild is not None:
sound = SoundboardSound(guild=guild, state=self, data=data)
guild._add_soundboard_sound(sound)
self.dispatch('soundboard_sound_create', sound)
else:
_log.debug('GUILD_SOUNDBOARD_SOUND_CREATE referencing unknown guild ID: %s. Discarding.', guild_id)
def _update_and_dispatch_sound_update(self, sound: SoundboardSound, data: gw.GuildSoundBoardSoundUpdateEvent):
old_sound = copy.copy(sound)
sound._update(data)
self.dispatch('soundboard_sound_update', old_sound, sound)
def parse_guild_soundboard_sound_update(self, data: gw.GuildSoundBoardSoundUpdateEvent) -> None:
guild_id = int(data['guild_id']) # type: ignore # can't be None here
guild = self._get_guild(guild_id)
if guild is not None:
sound_id = int(data['sound_id'])
sound = guild.get_soundboard_sound(sound_id)
if sound is not None:
self._update_and_dispatch_sound_update(sound, data)
else:
_log.warning('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown sound ID: %s. Discarding.', sound_id)
else:
_log.debug('GUILD_SOUNDBOARD_SOUND_UPDATE referencing unknown guild ID: %s. Discarding.', guild_id)
def parse_guild_soundboard_sound_delete(self, data: gw.GuildSoundBoardSoundDeleteEvent) -> None:
guild_id = int(data['guild_id'])
guild = self._get_guild(guild_id)
if guild is not None:
sound_id = int(data['sound_id'])
sound = guild.get_soundboard_sound(sound_id)
if sound is not None:
guild._remove_soundboard_sound(sound)
self.dispatch('soundboard_sound_delete', sound)
else:
_log.warning('GUILD_SOUNDBOARD_SOUND_DELETE referencing unknown sound ID: %s. Discarding.', sound_id)
else:
_log.debug('GUILD_SOUNDBOARD_SOUND_DELETE referencing unknown guild ID: %s. Discarding.', guild_id)
def parse_guild_soundboard_sounds_update(self, data: gw.GuildSoundBoardSoundsUpdateEvent) -> None:
guild_id = int(data['guild_id'])
guild = self._get_guild(guild_id)
if guild is None:
_log.debug('GUILD_SOUNDBOARD_SOUNDS_UPDATE referencing unknown guild ID: %s. Discarding.', guild_id)
return
for raw_sound in data['soundboard_sounds']:
sound_id = int(raw_sound['sound_id'])
sound = guild.get_soundboard_sound(sound_id)
if sound is not None:
self._update_and_dispatch_sound_update(sound, raw_sound)
else:
_log.warning('GUILD_SOUNDBOARD_SOUNDS_UPDATE referencing unknown sound ID: %s. Discarding.', sound_id)
def parse_application_command_permissions_update(self, data: GuildApplicationCommandPermissionsPayload): def parse_application_command_permissions_update(self, data: GuildApplicationCommandPermissionsPayload):
raw = RawAppCommandPermissionsUpdateEvent(data=data, state=self) raw = RawAppCommandPermissionsUpdateEvent(data=data, state=self)
self.dispatch('raw_app_command_permissions_update', raw) self.dispatch('raw_app_command_permissions_update', raw)
@ -1585,6 +1678,14 @@ class ConnectionState(Generic[ClientT]):
else: else:
_log.debug('VOICE_STATE_UPDATE referencing an unknown member ID: %s. Discarding.', data['user_id']) _log.debug('VOICE_STATE_UPDATE referencing an unknown member ID: %s. Discarding.', data['user_id'])
def parse_voice_channel_effect_send(self, data: gw.VoiceChannelEffectSendEvent):
guild = self._get_guild(int(data['guild_id']))
if guild is not None:
effect = VoiceChannelEffect(state=self, data=data, guild=guild)
self.dispatch('voice_channel_effect', effect)
else:
_log.debug('VOICE_CHANNEL_EFFECT_SEND referencing an unknown guild ID: %s. Discarding.', data['guild_id'])
def parse_voice_server_update(self, data: gw.VoiceServerUpdateEvent) -> None: def parse_voice_server_update(self, data: gw.VoiceServerUpdateEvent) -> None:
key_id = int(data['guild_id']) key_id = int(data['guild_id'])
@ -1663,6 +1764,18 @@ class ConnectionState(Generic[ClientT]):
if poll: if poll:
self.dispatch('poll_vote_remove', user, poll.get_answer(raw.answer_id)) self.dispatch('poll_vote_remove', user, poll.get_answer(raw.answer_id))
def parse_subscription_create(self, data: gw.SubscriptionCreateEvent) -> None:
subscription = Subscription(data=data, state=self)
self.dispatch('subscription_create', subscription)
def parse_subscription_update(self, data: gw.SubscriptionUpdateEvent) -> None:
subscription = Subscription(data=data, state=self)
self.dispatch('subscription_update', subscription)
def parse_subscription_delete(self, data: gw.SubscriptionDeleteEvent) -> None:
subscription = Subscription(data=data, state=self)
self.dispatch('subscription_delete', subscription)
def _get_reaction_user(self, channel: MessageableChannel, user_id: int) -> Optional[Union[User, Member]]: def _get_reaction_user(self, channel: MessageableChannel, user_id: int) -> Optional[Union[User, Member]]:
if isinstance(channel, (TextChannel, Thread, VoiceChannel)): if isinstance(channel, (TextChannel, Thread, VoiceChannel)):
return channel.guild.get_member(user_id) return channel.guild.get_member(user_id)
@ -1707,6 +1820,15 @@ class ConnectionState(Generic[ClientT]):
def create_message(self, *, channel: MessageableChannel, data: MessagePayload) -> Message: def create_message(self, *, channel: MessageableChannel, data: MessagePayload) -> Message:
return Message(state=self, channel=channel, data=data) return Message(state=self, channel=channel, data=data)
def get_soundboard_sound(self, id: Optional[int]) -> Optional[SoundboardSound]:
if id is None:
return
for guild in self.guilds:
sound = guild._resolve_soundboard_sound(id)
if sound is not None:
return sound
class AutoShardedConnectionState(ConnectionState[ClientT]): class AutoShardedConnectionState(ConnectionState[ClientT]):
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:

107
discord/subscription.py

@ -0,0 +1,107 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import datetime
from typing import List, Optional, TYPE_CHECKING
from . import utils
from .mixins import Hashable
from .enums import try_enum, SubscriptionStatus
if TYPE_CHECKING:
from .state import ConnectionState
from .types.subscription import Subscription as SubscriptionPayload
from .user import User
__all__ = ('Subscription',)
class Subscription(Hashable):
"""Represents a Discord subscription.
.. versionadded:: 2.5
Attributes
-----------
id: :class:`int`
The subscription's ID.
user_id: :class:`int`
The ID of the user that is subscribed.
sku_ids: List[:class:`int`]
The IDs of the SKUs that the user subscribed to.
entitlement_ids: List[:class:`int`]
The IDs of the entitlements granted for this subscription.
current_period_start: :class:`datetime.datetime`
When the current billing period started.
current_period_end: :class:`datetime.datetime`
When the current billing period ends.
status: :class:`SubscriptionStatus`
The status of the subscription.
canceled_at: Optional[:class:`datetime.datetime`]
When the subscription was canceled.
This is only available for subscriptions with a :attr:`status` of :attr:`SubscriptionStatus.inactive`.
renewal_sku_ids: List[:class:`int`]
The IDs of the SKUs that the user is going to be subscribed to when renewing.
"""
__slots__ = (
'_state',
'id',
'user_id',
'sku_ids',
'entitlement_ids',
'current_period_start',
'current_period_end',
'status',
'canceled_at',
'renewal_sku_ids',
)
def __init__(self, *, state: ConnectionState, data: SubscriptionPayload):
self._state = state
self.id: int = int(data['id'])
self.user_id: int = int(data['user_id'])
self.sku_ids: List[int] = list(map(int, data['sku_ids']))
self.entitlement_ids: List[int] = list(map(int, data['entitlement_ids']))
self.current_period_start: datetime.datetime = utils.parse_time(data['current_period_start'])
self.current_period_end: datetime.datetime = utils.parse_time(data['current_period_end'])
self.status: SubscriptionStatus = try_enum(SubscriptionStatus, data['status'])
self.canceled_at: Optional[datetime.datetime] = utils.parse_time(data['canceled_at'])
self.renewal_sku_ids: List[int] = list(map(int, data['renewal_sku_ids'] or []))
def __repr__(self) -> str:
return f'<Subscription id={self.id} user_id={self.user_id} status={self.status!r}>'
@property
def created_at(self) -> datetime.datetime:
""":class:`datetime.datetime`: Returns the subscription's creation time in UTC."""
return utils.snowflake_time(self.id)
@property
def user(self) -> Optional[User]:
"""Optional[:class:`User`]: The user that is subscribed."""
return self._state.get_user(self.user_id)

13
discord/types/audit_log.py

@ -88,6 +88,9 @@ AuditLogEvent = Literal[
111, 111,
112, 112,
121, 121,
130,
131,
132,
140, 140,
141, 141,
142, 142,
@ -112,6 +115,7 @@ class _AuditLogChange_Str(TypedDict):
'permissions', 'permissions',
'tags', 'tags',
'unicode_emoji', 'unicode_emoji',
'emoji_name',
] ]
new_value: str new_value: str
old_value: str old_value: str
@ -136,6 +140,8 @@ class _AuditLogChange_Snowflake(TypedDict):
'channel_id', 'channel_id',
'inviter_id', 'inviter_id',
'guild_id', 'guild_id',
'user_id',
'sound_id',
] ]
new_value: Snowflake new_value: Snowflake
old_value: Snowflake old_value: Snowflake
@ -183,6 +189,12 @@ class _AuditLogChange_Int(TypedDict):
old_value: int old_value: int
class _AuditLogChange_Float(TypedDict):
key: Literal['volume']
new_value: float
old_value: float
class _AuditLogChange_ListRole(TypedDict): class _AuditLogChange_ListRole(TypedDict):
key: Literal['$add', '$remove'] key: Literal['$add', '$remove']
new_value: List[Role] new_value: List[Role]
@ -290,6 +302,7 @@ AuditLogChange = Union[
_AuditLogChange_AssetHash, _AuditLogChange_AssetHash,
_AuditLogChange_Snowflake, _AuditLogChange_Snowflake,
_AuditLogChange_Int, _AuditLogChange_Int,
_AuditLogChange_Float,
_AuditLogChange_Bool, _AuditLogChange_Bool,
_AuditLogChange_ListRole, _AuditLogChange_ListRole,
_AuditLogChange_MFALevel, _AuditLogChange_MFALevel,

15
discord/types/channel.py

@ -28,6 +28,7 @@ from typing_extensions import NotRequired
from .user import PartialUser from .user import PartialUser
from .snowflake import Snowflake from .snowflake import Snowflake
from .threads import ThreadMetadata, ThreadMember, ThreadArchiveDuration, ThreadType from .threads import ThreadMetadata, ThreadMember, ThreadArchiveDuration, ThreadType
from .emoji import PartialEmoji
OverwriteType = Literal[0, 1] OverwriteType = Literal[0, 1]
@ -89,6 +90,20 @@ class VoiceChannel(_BaseTextChannel):
video_quality_mode: NotRequired[VideoQualityMode] video_quality_mode: NotRequired[VideoQualityMode]
VoiceChannelEffectAnimationType = Literal[0, 1]
class VoiceChannelEffect(TypedDict):
guild_id: Snowflake
channel_id: Snowflake
user_id: Snowflake
emoji: NotRequired[Optional[PartialEmoji]]
animation_type: NotRequired[VoiceChannelEffectAnimationType]
animation_id: NotRequired[int]
sound_id: NotRequired[Union[int, str]]
sound_volume: NotRequired[float]
class CategoryChannel(_BaseGuildChannel): class CategoryChannel(_BaseGuildChannel):
type: Literal[4] type: Literal[4]

2
discord/types/embed.py

@ -71,7 +71,7 @@ class EmbedAuthor(TypedDict, total=False):
proxy_icon_url: str proxy_icon_url: str
EmbedType = Literal['rich', 'image', 'video', 'gifv', 'article', 'link'] EmbedType = Literal['rich', 'image', 'video', 'gifv', 'article', 'link', 'poll_result']
class Embed(TypedDict, total=False): class Embed(TypedDict, total=False):

2
discord/types/emoji.py

@ -23,6 +23,7 @@ DEALINGS IN THE SOFTWARE.
""" """
from typing import Optional, TypedDict from typing import Optional, TypedDict
from typing_extensions import NotRequired
from .snowflake import Snowflake, SnowflakeList from .snowflake import Snowflake, SnowflakeList
from .user import User from .user import User
@ -30,6 +31,7 @@ from .user import User
class PartialEmoji(TypedDict): class PartialEmoji(TypedDict):
id: Optional[Snowflake] id: Optional[Snowflake]
name: Optional[str] name: Optional[str]
animated: NotRequired[bool]
class Emoji(PartialEmoji, total=False): class Emoji(PartialEmoji, total=False):

23
discord/types/gateway.py

@ -31,7 +31,7 @@ from .sku import Entitlement
from .voice import GuildVoiceState from .voice import GuildVoiceState
from .integration import BaseIntegration, IntegrationApplication from .integration import BaseIntegration, IntegrationApplication
from .role import Role from .role import Role
from .channel import ChannelType, StageInstance from .channel import ChannelType, StageInstance, VoiceChannelEffect
from .interactions import Interaction from .interactions import Interaction
from .invite import InviteTargetType from .invite import InviteTargetType
from .emoji import Emoji, PartialEmoji from .emoji import Emoji, PartialEmoji
@ -45,6 +45,8 @@ from .user import User, AvatarDecorationData
from .threads import Thread, ThreadMember from .threads import Thread, ThreadMember
from .scheduled_event import GuildScheduledEvent from .scheduled_event import GuildScheduledEvent
from .audit_log import AuditLogEntry from .audit_log import AuditLogEntry
from .soundboard import SoundboardSound
from .subscription import Subscription
class SessionStartLimit(TypedDict): class SessionStartLimit(TypedDict):
@ -90,8 +92,7 @@ class MessageDeleteBulkEvent(TypedDict):
guild_id: NotRequired[Snowflake] guild_id: NotRequired[Snowflake]
class MessageUpdateEvent(Message): MessageUpdateEvent = MessageCreateEvent
channel_id: Snowflake
class MessageReactionAddEvent(TypedDict): class MessageReactionAddEvent(TypedDict):
@ -319,6 +320,19 @@ class _GuildScheduledEventUsersEvent(TypedDict):
GuildScheduledEventUserAdd = GuildScheduledEventUserRemove = _GuildScheduledEventUsersEvent GuildScheduledEventUserAdd = GuildScheduledEventUserRemove = _GuildScheduledEventUsersEvent
VoiceStateUpdateEvent = GuildVoiceState VoiceStateUpdateEvent = GuildVoiceState
VoiceChannelEffectSendEvent = VoiceChannelEffect
GuildSoundBoardSoundCreateEvent = GuildSoundBoardSoundUpdateEvent = SoundboardSound
class GuildSoundBoardSoundsUpdateEvent(TypedDict):
guild_id: Snowflake
soundboard_sounds: List[SoundboardSound]
class GuildSoundBoardSoundDeleteEvent(TypedDict):
sound_id: Snowflake
guild_id: Snowflake
class VoiceServerUpdateEvent(TypedDict): class VoiceServerUpdateEvent(TypedDict):
@ -362,3 +376,6 @@ class PollVoteActionEvent(TypedDict):
message_id: Snowflake message_id: Snowflake
guild_id: NotRequired[Snowflake] guild_id: NotRequired[Snowflake]
answer_id: int answer_id: int
SubscriptionCreateEvent = SubscriptionUpdateEvent = SubscriptionDeleteEvent = Subscription

4
discord/types/guild.py

@ -37,6 +37,7 @@ from .member import Member
from .emoji import Emoji from .emoji import Emoji
from .user import User from .user import User
from .threads import Thread from .threads import Thread
from .soundboard import SoundboardSound
class Ban(TypedDict): class Ban(TypedDict):
@ -90,6 +91,8 @@ GuildFeature = Literal[
'VIP_REGIONS', 'VIP_REGIONS',
'WELCOME_SCREEN_ENABLED', 'WELCOME_SCREEN_ENABLED',
'RAID_ALERTS_DISABLED', 'RAID_ALERTS_DISABLED',
'SOUNDBOARD',
'MORE_SOUNDBOARD',
] ]
@ -154,6 +157,7 @@ class Guild(_BaseGuildPreview):
max_members: NotRequired[int] max_members: NotRequired[int]
premium_subscription_count: NotRequired[int] premium_subscription_count: NotRequired[int]
max_video_channel_users: NotRequired[int] max_video_channel_users: NotRequired[int]
soundboard_sounds: NotRequired[List[SoundboardSound]]
class InviteGuild(Guild, total=False): class InviteGuild(Guild, total=False):

46
discord/types/interactions.py

@ -265,14 +265,52 @@ class MessageInteraction(TypedDict):
member: NotRequired[Member] member: NotRequired[Member]
class MessageInteractionMetadata(TypedDict): class _MessageInteractionMetadata(TypedDict):
id: Snowflake id: Snowflake
type: InteractionType
user: User user: User
authorizing_integration_owners: Dict[Literal['0', '1'], Snowflake] authorizing_integration_owners: Dict[Literal['0', '1'], Snowflake]
original_response_message_id: NotRequired[Snowflake] original_response_message_id: NotRequired[Snowflake]
interacted_message_id: NotRequired[Snowflake]
triggering_interaction_metadata: NotRequired[MessageInteractionMetadata]
class _ApplicationCommandMessageInteractionMetadata(_MessageInteractionMetadata):
type: Literal[2]
# command_type: Literal[1, 2, 3, 4]
class UserApplicationCommandMessageInteractionMetadata(_ApplicationCommandMessageInteractionMetadata):
# command_type: Literal[2]
target_user: User
class MessageApplicationCommandMessageInteractionMetadata(_ApplicationCommandMessageInteractionMetadata):
# command_type: Literal[3]
target_message_id: Snowflake
ApplicationCommandMessageInteractionMetadata = Union[
_ApplicationCommandMessageInteractionMetadata,
UserApplicationCommandMessageInteractionMetadata,
MessageApplicationCommandMessageInteractionMetadata,
]
class MessageComponentMessageInteractionMetadata(_MessageInteractionMetadata):
type: Literal[3]
interacted_message_id: Snowflake
class ModalSubmitMessageInteractionMetadata(_MessageInteractionMetadata):
type: Literal[5]
triggering_interaction_metadata: Union[
ApplicationCommandMessageInteractionMetadata, MessageComponentMessageInteractionMetadata
]
MessageInteractionMetadata = Union[
ApplicationCommandMessageInteractionMetadata,
MessageComponentMessageInteractionMetadata,
ModalSubmitMessageInteractionMetadata,
]
class InteractionCallbackResponse(TypedDict): class InteractionCallbackResponse(TypedDict):

40
discord/types/message.py

@ -102,7 +102,11 @@ class MessageApplication(TypedDict):
cover_image: NotRequired[str] cover_image: NotRequired[str]
MessageReferenceType = Literal[0, 1]
class MessageReference(TypedDict, total=False): class MessageReference(TypedDict, total=False):
type: MessageReferenceType
message_id: Snowflake message_id: Snowflake
channel_id: Required[Snowflake] channel_id: Required[Snowflake]
guild_id: Snowflake guild_id: Snowflake
@ -116,6 +120,24 @@ class RoleSubscriptionData(TypedDict):
is_renewal: bool is_renewal: bool
PurchaseNotificationResponseType = Literal[0]
class GuildProductPurchase(TypedDict):
listing_id: Snowflake
product_name: str
class PurchaseNotificationResponse(TypedDict):
type: PurchaseNotificationResponseType
guild_product_purchase: Optional[GuildProductPurchase]
class CallMessage(TypedDict):
participants: SnowflakeList
ended_timestamp: NotRequired[Optional[str]]
MessageType = Literal[ MessageType = Literal[
0, 0,
1, 1,
@ -151,9 +173,25 @@ MessageType = Literal[
37, 37,
38, 38,
39, 39,
44,
46,
] ]
class MessageSnapshot(TypedDict):
type: MessageType
content: str
embeds: List[Embed]
attachments: List[Attachment]
timestamp: str
edited_timestamp: Optional[str]
flags: NotRequired[int]
mentions: List[UserWithMember]
mention_roles: SnowflakeList
sticker_items: NotRequired[List[StickerItem]]
components: NotRequired[List[Component]]
class Message(PartialMessage): class Message(PartialMessage):
id: Snowflake id: Snowflake
author: User author: User
@ -187,6 +225,8 @@ class Message(PartialMessage):
position: NotRequired[int] position: NotRequired[int]
role_subscription_data: NotRequired[RoleSubscriptionData] role_subscription_data: NotRequired[RoleSubscriptionData]
thread: NotRequired[Thread] thread: NotRequired[Thread]
call: NotRequired[CallMessage]
purchase_notification: NotRequired[PurchaseNotificationResponse]
AllowedMentionType = Literal['roles', 'users', 'everyone'] AllowedMentionType = Literal['roles', 'users', 'everyone']

49
discord/types/soundboard.py

@ -0,0 +1,49 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from typing import TypedDict, Optional, Union
from typing_extensions import NotRequired
from .snowflake import Snowflake
from .user import User
class BaseSoundboardSound(TypedDict):
sound_id: Union[Snowflake, str] # basic string number when it's a default sound
volume: float
class SoundboardSound(BaseSoundboardSound):
name: str
emoji_name: Optional[str]
emoji_id: Optional[Snowflake]
user_id: NotRequired[Snowflake]
available: bool
guild_id: NotRequired[Snowflake]
user: NotRequired[User]
class SoundboardDefaultSound(BaseSoundboardSound):
name: str
emoji_name: str

43
discord/types/subscription.py

@ -0,0 +1,43 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import List, Literal, Optional, TypedDict
from .snowflake import Snowflake
SubscriptionStatus = Literal[0, 1, 2]
class Subscription(TypedDict):
id: Snowflake
user_id: Snowflake
sku_ids: List[Snowflake]
entitlement_ids: List[Snowflake]
current_period_start: str
current_period_end: str
status: SubscriptionStatus
canceled_at: Optional[str]
renewal_sku_ids: Optional[List[Snowflake]]

7
discord/types/voice.py

@ -29,7 +29,12 @@ from .snowflake import Snowflake
from .member import MemberWithUser from .member import MemberWithUser
SupportedModes = Literal['xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305'] SupportedModes = Literal[
'aead_xchacha20_poly1305_rtpsize',
'xsalsa20_poly1305_lite',
'xsalsa20_poly1305_suffix',
'xsalsa20_poly1305',
]
class _VoiceState(TypedDict): class _VoiceState(TypedDict):

136
discord/utils.py

@ -21,6 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
""" """
from __future__ import annotations from __future__ import annotations
import array import array
@ -41,7 +42,6 @@ from typing import (
Iterator, Iterator,
List, List,
Literal, Literal,
Mapping,
NamedTuple, NamedTuple,
Optional, Optional,
Protocol, Protocol,
@ -71,6 +71,7 @@ import types
import typing import typing
import warnings import warnings
import logging import logging
import zlib
import yarl import yarl
@ -81,6 +82,12 @@ except ModuleNotFoundError:
else: else:
HAS_ORJSON = True HAS_ORJSON = True
try:
import zstandard # type: ignore
except ImportError:
_HAS_ZSTD = False
else:
_HAS_ZSTD = True
__all__ = ( __all__ = (
'oauth_url', 'oauth_url',
@ -148,8 +155,11 @@ if TYPE_CHECKING:
from .invite import Invite from .invite import Invite
from .template import Template from .template import Template
class _RequestLike(Protocol): class _DecompressionContext(Protocol):
headers: Mapping[str, Any] COMPRESSION_TYPE: str
def decompress(self, data: bytes, /) -> str | None:
...
P = ParamSpec('P') P = ParamSpec('P')
@ -317,7 +327,7 @@ def oauth_url(
permissions: Permissions = MISSING, permissions: Permissions = MISSING,
guild: Snowflake = MISSING, guild: Snowflake = MISSING,
redirect_uri: str = MISSING, redirect_uri: str = MISSING,
scopes: Iterable[str] = MISSING, scopes: Optional[Iterable[str]] = MISSING,
disable_guild_select: bool = False, disable_guild_select: bool = False,
state: str = MISSING, state: str = MISSING,
) -> str: ) -> str:
@ -359,6 +369,7 @@ def oauth_url(
The OAuth2 URL for inviting the bot into guilds. The OAuth2 URL for inviting the bot into guilds.
""" """
url = f'https://discord.com/oauth2/authorize?client_id={client_id}' url = f'https://discord.com/oauth2/authorize?client_id={client_id}'
if scopes is not None:
url += '&scope=' + '+'.join(scopes or ('bot', 'applications.commands')) url += '&scope=' + '+'.join(scopes or ('bot', 'applications.commands'))
if permissions is not MISSING: if permissions is not MISSING:
url += f'&permissions={permissions.value}' url += f'&permissions={permissions.value}'
@ -623,8 +634,18 @@ def _get_mime_type_for_image(data: bytes):
raise ValueError('Unsupported image type given') raise ValueError('Unsupported image type given')
def _bytes_to_base64_data(data: bytes) -> str: def _get_mime_type_for_audio(data: bytes):
if data.startswith(b'\x49\x44\x33') or data.startswith(b'\xff\xfb'):
return 'audio/mpeg'
else:
raise ValueError('Unsupported audio type given')
def _bytes_to_base64_data(data: bytes, *, audio: bool = False) -> str:
fmt = 'data:{mime};base64,{data}' fmt = 'data:{mime};base64,{data}'
if audio:
mime = _get_mime_type_for_audio(data)
else:
mime = _get_mime_type_for_image(data) mime = _get_mime_type_for_image(data)
b64 = b64encode(data).decode('ascii') b64 = b64encode(data).decode('ascii')
return fmt.format(mime=mime, data=b64) return fmt.format(mime=mime, data=b64)
@ -848,6 +869,12 @@ def resolve_invite(invite: Union[Invite, str]) -> ResolvedInvite:
invite: Union[:class:`~discord.Invite`, :class:`str`] invite: Union[:class:`~discord.Invite`, :class:`str`]
The invite. The invite.
Raises
-------
ValueError
The invite is not a valid Discord invite, e.g. is not a URL
or does not contain alphanumeric characters.
Returns Returns
-------- --------
:class:`.ResolvedInvite` :class:`.ResolvedInvite`
@ -867,6 +894,11 @@ def resolve_invite(invite: Union[Invite, str]) -> ResolvedInvite:
event_id = url.query.get('event') event_id = url.query.get('event')
return ResolvedInvite(code, int(event_id) if event_id else None) return ResolvedInvite(code, int(event_id) if event_id else None)
allowed_characters = r'[a-zA-Z0-9\-_]+'
if not re.fullmatch(allowed_characters, invite):
raise ValueError('Invite contains characters that are not allowed')
return ResolvedInvite(invite, None) return ResolvedInvite(invite, None)
@ -1406,3 +1438,97 @@ def _human_join(seq: Sequence[str], /, *, delimiter: str = ', ', final: str = 'o
return f'{seq[0]} {final} {seq[1]}' return f'{seq[0]} {final} {seq[1]}'
return delimiter.join(seq[:-1]) + f' {final} {seq[-1]}' return delimiter.join(seq[:-1]) + f' {final} {seq[-1]}'
if _HAS_ZSTD:
class _ZstdDecompressionContext:
__slots__ = ('context',)
COMPRESSION_TYPE: str = 'zstd-stream'
def __init__(self) -> None:
decompressor = zstandard.ZstdDecompressor()
self.context = decompressor.decompressobj()
def decompress(self, data: bytes, /) -> str | None:
# Each WS message is a complete gateway message
return self.context.decompress(data).decode('utf-8')
_ActiveDecompressionContext: Type[_DecompressionContext] = _ZstdDecompressionContext
else:
class _ZlibDecompressionContext:
__slots__ = ('context', 'buffer')
COMPRESSION_TYPE: str = 'zlib-stream'
def __init__(self) -> None:
self.buffer: bytearray = bytearray()
self.context = zlib.decompressobj()
def decompress(self, data: bytes, /) -> str | None:
self.buffer.extend(data)
# Check whether ending is Z_SYNC_FLUSH
if len(data) < 4 or data[-4:] != b'\x00\x00\xff\xff':
return
msg = self.context.decompress(self.buffer)
self.buffer = bytearray()
return msg.decode('utf-8')
_ActiveDecompressionContext: Type[_DecompressionContext] = _ZlibDecompressionContext
def _format_call_duration(duration: datetime.timedelta) -> str:
seconds = duration.total_seconds()
minutes_s = 60
hours_s = minutes_s * 60
days_s = hours_s * 24
# Discord uses approx. 1/12 of 365.25 days (avg. days per year)
months_s = days_s * 30.4375
years_s = months_s * 12
threshold_s = 45
threshold_m = 45
threshold_h = 21.5
threshold_d = 25.5
threshold_M = 10.5
if seconds < threshold_s:
formatted = "a few seconds"
elif seconds < (threshold_m * minutes_s):
minutes = round(seconds / minutes_s)
if minutes == 1:
formatted = "a minute"
else:
formatted = f"{minutes} minutes"
elif seconds < (threshold_h * hours_s):
hours = round(seconds / hours_s)
if hours == 1:
formatted = "an hour"
else:
formatted = f"{hours} hours"
elif seconds < (threshold_d * days_s):
days = round(seconds / days_s)
if days == 1:
formatted = "a day"
else:
formatted = f"{days} days"
elif seconds < (threshold_M * months_s):
months = round(seconds / months_s)
if months == 1:
formatted = "a month"
else:
formatted = f"{months} months"
else:
years = round(seconds / years_s)
if years == 1:
formatted = "a year"
else:
formatted = f"{years} years"
return formatted

25
discord/voice_client.py

@ -230,12 +230,13 @@ class VoiceClient(VoiceProtocol):
self.timestamp: int = 0 self.timestamp: int = 0
self._player: Optional[AudioPlayer] = None self._player: Optional[AudioPlayer] = None
self.encoder: Encoder = MISSING self.encoder: Encoder = MISSING
self._lite_nonce: int = 0 self._incr_nonce: int = 0
self._connection: VoiceConnectionState = self.create_connection_state() self._connection: VoiceConnectionState = self.create_connection_state()
warn_nacl: bool = not has_nacl warn_nacl: bool = not has_nacl
supported_modes: Tuple[SupportedModes, ...] = ( supported_modes: Tuple[SupportedModes, ...] = (
'aead_xchacha20_poly1305_rtpsize',
'xsalsa20_poly1305_lite', 'xsalsa20_poly1305_lite',
'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305_suffix',
'xsalsa20_poly1305', 'xsalsa20_poly1305',
@ -380,7 +381,21 @@ class VoiceClient(VoiceProtocol):
encrypt_packet = getattr(self, '_encrypt_' + self.mode) encrypt_packet = getattr(self, '_encrypt_' + self.mode)
return encrypt_packet(header, data) return encrypt_packet(header, data)
def _encrypt_aead_xchacha20_poly1305_rtpsize(self, header: bytes, data) -> bytes:
# Esentially the same as _lite
# Uses an incrementing 32-bit integer which is appended to the payload
# The only other difference is we require AEAD with Additional Authenticated Data (the header)
box = nacl.secret.Aead(bytes(self.secret_key))
nonce = bytearray(24)
nonce[:4] = struct.pack('>I', self._incr_nonce)
self.checked_add('_incr_nonce', 1, 4294967295)
return header + box.encrypt(bytes(data), bytes(header), bytes(nonce)).ciphertext + nonce[:4]
def _encrypt_xsalsa20_poly1305(self, header: bytes, data) -> bytes: def _encrypt_xsalsa20_poly1305(self, header: bytes, data) -> bytes:
# Deprecated. Removal: 18th Nov 2024. See:
# https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes
box = nacl.secret.SecretBox(bytes(self.secret_key)) box = nacl.secret.SecretBox(bytes(self.secret_key))
nonce = bytearray(24) nonce = bytearray(24)
nonce[:12] = header nonce[:12] = header
@ -388,17 +403,21 @@ class VoiceClient(VoiceProtocol):
return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext
def _encrypt_xsalsa20_poly1305_suffix(self, header: bytes, data) -> bytes: def _encrypt_xsalsa20_poly1305_suffix(self, header: bytes, data) -> bytes:
# Deprecated. Removal: 18th Nov 2024. See:
# https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes
box = nacl.secret.SecretBox(bytes(self.secret_key)) box = nacl.secret.SecretBox(bytes(self.secret_key))
nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE) nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE)
return header + box.encrypt(bytes(data), nonce).ciphertext + nonce return header + box.encrypt(bytes(data), nonce).ciphertext + nonce
def _encrypt_xsalsa20_poly1305_lite(self, header: bytes, data) -> bytes: def _encrypt_xsalsa20_poly1305_lite(self, header: bytes, data) -> bytes:
# Deprecated. Removal: 18th Nov 2024. See:
# https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes
box = nacl.secret.SecretBox(bytes(self.secret_key)) box = nacl.secret.SecretBox(bytes(self.secret_key))
nonce = bytearray(24) nonce = bytearray(24)
nonce[:4] = struct.pack('>I', self._lite_nonce) nonce[:4] = struct.pack('>I', self._incr_nonce)
self.checked_add('_lite_nonce', 1, 4294967295) self.checked_add('_incr_nonce', 1, 4294967295)
return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext + nonce[:4] return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext + nonce[:4]

305
docs/api.rst

@ -1298,6 +1298,35 @@ Scheduled Events
:type user: :class:`User` :type user: :class:`User`
Soundboard
~~~~~~~~~~~
.. function:: on_soundboard_sound_create(sound)
on_soundboard_sound_delete(sound)
Called when a :class:`SoundboardSound` is created or deleted.
.. versionadded:: 2.5
:param sound: The soundboard sound that was created or deleted.
:type sound: :class:`SoundboardSound`
.. function:: on_soundboard_sound_update(before, after)
Called when a :class:`SoundboardSound` is updated.
The following examples illustrate when this event is called:
- The name is changed.
- The emoji is changed.
- The volume is changed.
.. versionadded:: 2.5
:param sound: The soundboard sound that was updated.
:type sound: :class:`SoundboardSound`
Stages Stages
~~~~~~~ ~~~~~~~
@ -1327,6 +1356,37 @@ Stages
:param after: The stage instance after the update. :param after: The stage instance after the update.
:type after: :class:`StageInstance` :type after: :class:`StageInstance`
Subscriptions
~~~~~~~~~~~~~
.. function:: on_subscription_create(subscription)
Called when a subscription is created.
.. versionadded:: 2.5
:param subscription: The subscription that was created.
:type subscription: :class:`Subscription`
.. function:: on_subscription_update(subscription)
Called when a subscription is updated.
.. versionadded:: 2.5
:param subscription: The subscription that was updated.
:type subscription: :class:`Subscription`
.. function:: on_subscription_delete(subscription)
Called when a subscription is deleted.
.. versionadded:: 2.5
:param subscription: The subscription that was deleted.
:type subscription: :class:`Subscription`
Threads Threads
~~~~~~~~ ~~~~~~~~
@ -1483,6 +1543,17 @@ Voice
:param after: The voice state after the changes. :param after: The voice state after the changes.
:type after: :class:`VoiceState` :type after: :class:`VoiceState`
.. function:: on_voice_channel_effect(effect)
Called when a :class:`Member` sends a :class:`VoiceChannelEffect` in a voice channel the bot is in.
This requires :attr:`Intents.voice_states` to be enabled.
.. versionadded:: 2.5
:param effect: The effect that is sent.
:type effect: :class:`VoiceChannelEffect`
.. _discord-api-utils: .. _discord-api-utils:
Utility Functions Utility Functions
@ -1810,6 +1881,16 @@ of :class:`enum.Enum`.
.. versionadded:: 2.4 .. versionadded:: 2.4
.. attribute:: purchase_notification
The system message sent when a purchase is made in the guild.
.. versionadded:: 2.5
.. attribute:: poll_result
The system message sent when a poll has closed.
.. class:: UserFlags .. class:: UserFlags
Represents Discord User flags. Represents Discord User flags.
@ -2945,6 +3026,42 @@ of :class:`enum.Enum`.
.. versionadded:: 2.4 .. versionadded:: 2.4
.. attribute:: soundboard_sound_create
A soundboard sound was created.
Possible attributes for :class:`AuditLogDiff`:
- :attr:`~AuditLogDiff.name`
- :attr:`~AuditLogDiff.emoji`
- :attr:`~AuditLogDiff.volume`
.. versionadded:: 2.5
.. attribute:: soundboard_sound_update
A soundboard sound was updated.
Possible attributes for :class:`AuditLogDiff`:
- :attr:`~AuditLogDiff.name`
- :attr:`~AuditLogDiff.emoji`
- :attr:`~AuditLogDiff.volume`
.. versionadded:: 2.5
.. attribute:: soundboard_sound_delete
A soundboard sound was deleted.
Possible attributes for :class:`AuditLogDiff`:
- :attr:`~AuditLogDiff.name`
- :attr:`~AuditLogDiff.emoji`
- :attr:`~AuditLogDiff.volume`
.. versionadded:: 2.5
.. class:: AuditLogActionCategory .. class:: AuditLogActionCategory
Represents the category that the :class:`AuditLogAction` belongs to. Represents the category that the :class:`AuditLogAction` belongs to.
@ -3663,6 +3780,58 @@ of :class:`enum.Enum`.
A burst reaction, also known as a "super reaction". A burst reaction, also known as a "super reaction".
.. class:: VoiceChannelEffectAnimationType
Represents the animation type of a voice channel effect.
.. versionadded:: 2.5
.. attribute:: premium
A fun animation, sent by a Nitro subscriber.
.. attribute:: basic
The standard animation.
.. class:: SubscriptionStatus
Represents the status of an subscription.
.. versionadded:: 2.5
.. attribute:: active
The subscription is active.
.. attribute:: ending
The subscription is active but will not renew.
.. attribute:: inactive
The subscription is inactive and not being charged.
.. class:: MessageReferenceType
Represents the type of a message reference.
.. versionadded:: 2.5
.. attribute:: reply
A message reply.
.. attribute:: forward
A forwarded message.
.. attribute:: default
An alias for :attr:`.reply`.
.. _discord-api-audit-logs: .. _discord-api-audit-logs:
Audit Log Data Audit Log Data
@ -4128,11 +4297,12 @@ AuditLogDiff
.. attribute:: emoji .. attribute:: emoji
The name of the emoji that represents a sticker being changed. The emoji which represents one of the following:
See also :attr:`GuildSticker.emoji`. * :attr:`GuildSticker.emoji`
* :attr:`SoundboardSound.emoji`
:type: :class:`str` :type: Union[:class:`str`, :class:`PartialEmoji`]
.. attribute:: unicode_emoji .. attribute:: unicode_emoji
@ -4153,9 +4323,10 @@ AuditLogDiff
.. attribute:: available .. attribute:: available
The availability of a sticker being changed. The availability of one of the following being changed:
See also :attr:`GuildSticker.available` * :attr:`GuildSticker.available`
* :attr:`SoundboardSound.available`
:type: :class:`bool` :type: :class:`bool`
@ -4378,6 +4549,22 @@ AuditLogDiff
:type: Optional[:class:`PartialEmoji`] :type: Optional[:class:`PartialEmoji`]
.. attribute:: user
The user that represents the uploader of a soundboard sound.
See also :attr:`SoundboardSound.user`
:type: Union[:class:`Member`, :class:`User`]
.. attribute:: volume
The volume of a soundboard sound.
See also :attr:`SoundboardSound.volume`
:type: :class:`float`
.. this is currently missing the following keys: reason and application_id .. this is currently missing the following keys: reason and application_id
I'm not sure how to port these I'm not sure how to port these
@ -4632,6 +4819,13 @@ Guild
:type: List[:class:`Object`] :type: List[:class:`Object`]
GuildPreview
~~~~~~~~~~~~
.. attributetable:: GuildPreview
.. autoclass:: GuildPreview
:members:
ScheduledEvent ScheduledEvent
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
@ -4799,6 +4993,35 @@ VoiceChannel
:members: :members:
:inherited-members: :inherited-members:
.. attributetable:: VoiceChannelEffect
.. autoclass:: VoiceChannelEffect()
:members:
:inherited-members:
.. class:: VoiceChannelEffectAnimation
A namedtuple which represents a voice channel effect animation.
.. versionadded:: 2.5
.. attribute:: id
The ID of the animation.
:type: :class:`int`
.. attribute:: type
The type of the animation.
:type: :class:`VoiceChannelEffectAnimationType`
.. attributetable:: VoiceChannelSoundEffect
.. autoclass:: VoiceChannelSoundEffect()
:members:
:inherited-members:
StageChannel StageChannel
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
@ -4965,6 +5188,30 @@ GuildSticker
.. autoclass:: GuildSticker() .. autoclass:: GuildSticker()
:members: :members:
BaseSoundboardSound
~~~~~~~~~~~~~~~~~~~~~~~
.. attributetable:: BaseSoundboardSound
.. autoclass:: BaseSoundboardSound()
:members:
SoundboardDefaultSound
~~~~~~~~~~~~~~~~~~~~~~~
.. attributetable:: SoundboardDefaultSound
.. autoclass:: SoundboardDefaultSound()
:members:
SoundboardSound
~~~~~~~~~~~~~~~~~~~~~~~
.. attributetable:: SoundboardSound
.. autoclass:: SoundboardSound()
:members:
ShardInfo ShardInfo
~~~~~~~~~~~ ~~~~~~~~~~~
@ -4973,6 +5220,14 @@ ShardInfo
.. autoclass:: ShardInfo() .. autoclass:: ShardInfo()
:members: :members:
SessionStartLimits
~~~~~~~~~~~~~~~~~~~~
.. attributetable:: SessionStartLimits
.. autoclass:: SessionStartLimits()
:members:
SKU SKU
~~~~~~~~~~~ ~~~~~~~~~~~
@ -4989,6 +5244,14 @@ Entitlement
.. autoclass:: Entitlement() .. autoclass:: Entitlement()
:members: :members:
Subscription
~~~~~~~~~~~~
.. attributetable:: Subscription
.. autoclass:: Subscription()
:members:
RawMessageDeleteEvent RawMessageDeleteEvent
~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~
@ -5127,6 +5390,14 @@ PollAnswer
.. _discord_api_data: .. _discord_api_data:
MessageSnapshot
~~~~~~~~~~~~~~~~~
.. attributetable:: MessageSnapshot
.. autoclass:: MessageSnapshot
:members:
Data Classes Data Classes
-------------- --------------
@ -5198,6 +5469,22 @@ RoleSubscriptionInfo
.. autoclass:: RoleSubscriptionInfo .. autoclass:: RoleSubscriptionInfo
:members: :members:
PurchaseNotification
~~~~~~~~~~~~~~~~~~~~~
.. attributetable:: PurchaseNotification
.. autoclass:: PurchaseNotification()
:members:
GuildProductPurchase
+++++++++++++++++++++
.. attributetable:: GuildProductPurchase
.. autoclass:: GuildProductPurchase()
:members:
Intents Intents
~~~~~~~~~~ ~~~~~~~~~~
@ -5406,6 +5693,14 @@ PollMedia
.. autoclass:: PollMedia .. autoclass:: PollMedia
:members: :members:
CallMessage
~~~~~~~~~~~~~~~~~~~
.. attributetable:: CallMessage
.. autoclass:: CallMessage()
:members:
Exceptions Exceptions
------------ ------------

4
docs/ext/commands/api.rst

@ -708,6 +708,9 @@ Exceptions
.. autoexception:: discord.ext.commands.ScheduledEventNotFound .. autoexception:: discord.ext.commands.ScheduledEventNotFound
:members: :members:
.. autoexception:: discord.ext.commands.SoundboardSoundNotFound
:members:
.. autoexception:: discord.ext.commands.BadBoolArgument .. autoexception:: discord.ext.commands.BadBoolArgument
:members: :members:
@ -800,6 +803,7 @@ Exception Hierarchy
- :exc:`~.commands.EmojiNotFound` - :exc:`~.commands.EmojiNotFound`
- :exc:`~.commands.GuildStickerNotFound` - :exc:`~.commands.GuildStickerNotFound`
- :exc:`~.commands.ScheduledEventNotFound` - :exc:`~.commands.ScheduledEventNotFound`
- :exc:`~.commands.SoundboardSoundNotFound`
- :exc:`~.commands.PartialEmojiConversionFailure` - :exc:`~.commands.PartialEmojiConversionFailure`
- :exc:`~.commands.BadBoolArgument` - :exc:`~.commands.BadBoolArgument`
- :exc:`~.commands.RangeError` - :exc:`~.commands.RangeError`

7
pyproject.toml

@ -50,12 +50,15 @@ docs = [
"sphinxcontrib-serializinghtml==1.1.5", "sphinxcontrib-serializinghtml==1.1.5",
"typing-extensions>=4.3,<5", "typing-extensions>=4.3,<5",
"sphinx-inline-tabs==2023.4.21", "sphinx-inline-tabs==2023.4.21",
# TODO: Remove this when moving to Sphinx >= 6.6
"imghdr-lts==1.0.0; python_version>='3.13'",
] ]
speed = [ speed = [
"orjson>=3.5.4", "orjson>=3.5.4",
"aiodns>=1.1; sys_platform != 'win32'", "aiodns>=1.1; sys_platform != 'win32'",
"Brotli", "Brotli",
"cchardet==2.1.7; python_version < '3.10'", "cchardet==2.1.7; python_version < '3.10'",
"zstandard>=0.23.0"
] ]
test = [ test = [
"coverage[toml]", "coverage[toml]",
@ -66,6 +69,10 @@ test = [
"typing-extensions>=4.3,<5", "typing-extensions>=4.3,<5",
"tzdata; sys_platform == 'win32'", "tzdata; sys_platform == 'win32'",
] ]
dev = [
"black==22.6",
"typing_extensions>=4.3,<5",
]
[tool.setuptools] [tool.setuptools]
packages = [ packages = [

66
tests/test_colour.py

@ -44,6 +44,7 @@ import pytest
('rgb(20%, 24%, 56%)', 0x333D8F), ('rgb(20%, 24%, 56%)', 0x333D8F),
('rgb(20%, 23.9%, 56.1%)', 0x333D8F), ('rgb(20%, 23.9%, 56.1%)', 0x333D8F),
('rgb(51, 61, 143)', 0x333D8F), ('rgb(51, 61, 143)', 0x333D8F),
('0x#333D8F', 0x333D8F),
], ],
) )
def test_from_str(value, expected): def test_from_str(value, expected):
@ -53,6 +54,7 @@ def test_from_str(value, expected):
@pytest.mark.parametrize( @pytest.mark.parametrize(
('value'), ('value'),
[ [
None,
'not valid', 'not valid',
'0xYEAH', '0xYEAH',
'#YEAH', '#YEAH',
@ -62,8 +64,72 @@ def test_from_str(value, expected):
'rgb(30, -1, 60)', 'rgb(30, -1, 60)',
'invalid(a, b, c)', 'invalid(a, b, c)',
'rgb(', 'rgb(',
'#1000000',
'#FFFFFFF',
"rgb(101%, 50%, 50%)",
"rgb(50%, -10%, 50%)",
"rgb(50%, 50%, 150%)",
"rgb(256, 100, 100)",
], ],
) )
def test_from_str_failures(value): def test_from_str_failures(value):
with pytest.raises(ValueError): with pytest.raises(ValueError):
discord.Colour.from_str(value) discord.Colour.from_str(value)
@pytest.mark.parametrize(
('value', 'expected'),
[
(discord.Colour.default(), 0x000000),
(discord.Colour.teal(), 0x1ABC9C),
(discord.Colour.dark_teal(), 0x11806A),
(discord.Colour.brand_green(), 0x57F287),
(discord.Colour.green(), 0x2ECC71),
(discord.Colour.dark_green(), 0x1F8B4C),
(discord.Colour.blue(), 0x3498DB),
(discord.Colour.dark_blue(), 0x206694),
(discord.Colour.purple(), 0x9B59B6),
(discord.Colour.dark_purple(), 0x71368A),
(discord.Colour.magenta(), 0xE91E63),
(discord.Colour.dark_magenta(), 0xAD1457),
(discord.Colour.gold(), 0xF1C40F),
(discord.Colour.dark_gold(), 0xC27C0E),
(discord.Colour.orange(), 0xE67E22),
(discord.Colour.dark_orange(), 0xA84300),
(discord.Colour.brand_red(), 0xED4245),
(discord.Colour.red(), 0xE74C3C),
(discord.Colour.dark_red(), 0x992D22),
(discord.Colour.lighter_grey(), 0x95A5A6),
(discord.Colour.dark_grey(), 0x607D8B),
(discord.Colour.light_grey(), 0x979C9F),
(discord.Colour.darker_grey(), 0x546E7A),
(discord.Colour.og_blurple(), 0x7289DA),
(discord.Colour.blurple(), 0x5865F2),
(discord.Colour.greyple(), 0x99AAB5),
(discord.Colour.dark_theme(), 0x313338),
(discord.Colour.fuchsia(), 0xEB459E),
(discord.Colour.yellow(), 0xFEE75C),
(discord.Colour.dark_embed(), 0x2B2D31),
(discord.Colour.light_embed(), 0xEEEFF1),
(discord.Colour.pink(), 0xEB459F),
],
)
def test_static_colours(value, expected):
assert value.value == expected
@pytest.mark.parametrize(
('value', 'property', 'expected'),
[
(discord.Colour(0x000000), 'r', 0),
(discord.Colour(0xFFFFFF), 'g', 255),
(discord.Colour(0xABCDEF), 'b', 239),
(discord.Colour(0x44243B), 'r', 68),
(discord.Colour(0x333D8F), 'g', 61),
(discord.Colour(0xDBFF00), 'b', 0),
],
)
def test_colour_properties(value, property, expected):
assert getattr(value, property) == expected

269
tests/test_embed.py

@ -0,0 +1,269 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import datetime
import discord
import pytest
@pytest.mark.parametrize(
('title', 'description', 'colour', 'url'),
[
('title', 'description', 0xABCDEF, 'https://example.com'),
('title', 'description', 0xFF1294, None),
('title', 'description', discord.Colour(0x333D8F), 'https://example.com'),
('title', 'description', discord.Colour(0x44243B), None),
],
)
def test_embed_initialization(title, description, colour, url):
embed = discord.Embed(title=title, description=description, colour=colour, url=url)
assert embed.title == title
assert embed.description == description
assert embed.colour == colour or embed.colour == discord.Colour(colour)
assert embed.url == url
@pytest.mark.parametrize(
('text', 'icon_url'),
[
('Hello discord.py', 'https://example.com'),
('text', None),
(None, 'https://example.com'),
(None, None),
],
)
def test_embed_set_footer(text, icon_url):
embed = discord.Embed()
embed.set_footer(text=text, icon_url=icon_url)
assert embed.footer.text == text
assert embed.footer.icon_url == icon_url
def test_embed_remove_footer():
embed = discord.Embed()
embed.set_footer(text='Hello discord.py', icon_url='https://example.com')
embed.remove_footer()
assert embed.footer.text is None
assert embed.footer.icon_url is None
@pytest.mark.parametrize(
('name', 'url', 'icon_url'),
[
('Rapptz', 'http://example.com', 'http://example.com/icon.png'),
('NCPlayz', None, 'http://example.com/icon.png'),
('Jackenmen', 'http://example.com', None),
],
)
def test_embed_set_author(name, url, icon_url):
embed = discord.Embed()
embed.set_author(name=name, url=url, icon_url=icon_url)
assert embed.author.name == name
assert embed.author.url == url
assert embed.author.icon_url == icon_url
def test_embed_remove_author():
embed = discord.Embed()
embed.set_author(name='Rapptz', url='http://example.com', icon_url='http://example.com/icon.png')
embed.remove_author()
assert embed.author.name is None
assert embed.author.url is None
assert embed.author.icon_url is None
@pytest.mark.parametrize(
('thumbnail'),
[
('http://example.com'),
(None),
],
)
def test_embed_set_thumbnail(thumbnail):
embed = discord.Embed()
embed.set_thumbnail(url=thumbnail)
assert embed.thumbnail.url == thumbnail
@pytest.mark.parametrize(
('image'),
[
('http://example.com'),
(None),
],
)
def test_embed_set_image(image):
embed = discord.Embed()
embed.set_image(url=image)
assert embed.image.url == image
@pytest.mark.parametrize(
('name', 'value', 'inline'),
[
('music', 'music value', True),
('sport', 'sport value', False),
],
)
def test_embed_add_field(name, value, inline):
embed = discord.Embed()
embed.add_field(name=name, value=value, inline=inline)
assert len(embed.fields) == 1
assert embed.fields[0].name == name
assert embed.fields[0].value == value
assert embed.fields[0].inline == inline
def test_embed_insert_field():
embed = discord.Embed()
embed.add_field(name='name', value='value', inline=True)
embed.insert_field_at(0, name='name 2', value='value 2', inline=False)
assert embed.fields[0].name == 'name 2'
assert embed.fields[0].value == 'value 2'
assert embed.fields[0].inline is False
def test_embed_set_field_at():
embed = discord.Embed()
embed.add_field(name='name', value='value', inline=True)
embed.set_field_at(0, name='name 2', value='value 2', inline=False)
assert embed.fields[0].name == 'name 2'
assert embed.fields[0].value == 'value 2'
assert embed.fields[0].inline is False
def test_embed_set_field_at_failure():
embed = discord.Embed()
with pytest.raises(IndexError):
embed.set_field_at(0, name='name', value='value', inline=True)
def test_embed_clear_fields():
embed = discord.Embed()
embed.add_field(name="field 1", value="value 1", inline=False)
embed.add_field(name="field 2", value="value 2", inline=False)
embed.add_field(name="field 3", value="value 3", inline=False)
embed.clear_fields()
assert len(embed.fields) == 0
def test_embed_remove_field():
embed = discord.Embed()
embed.add_field(name='name', value='value', inline=True)
embed.remove_field(0)
assert len(embed.fields) == 0
@pytest.mark.parametrize(
('title', 'description', 'url'),
[
('title 1', 'description 1', 'https://example.com'),
('title 2', 'description 2', None),
],
)
def test_embed_copy(title, description, url):
embed = discord.Embed(title=title, description=description, url=url)
embed_copy = embed.copy()
assert embed == embed_copy
assert embed.title == embed_copy.title
assert embed.description == embed_copy.description
assert embed.url == embed_copy.url
@pytest.mark.parametrize(
('title', 'description'),
[
('title 1', 'description 1'),
('title 2', 'description 2'),
],
)
def test_embed_len(title, description):
embed = discord.Embed(title=title, description=description)
assert len(embed) == len(title) + len(description)
@pytest.mark.parametrize(
('title', 'description', 'fields', 'footer', 'author'),
[
(
'title 1',
'description 1',
[('field name 1', 'field value 1'), ('field name 2', 'field value 2')],
'footer 1',
'author 1',
),
('title 2', 'description 2', [('field name 3', 'field value 3')], 'footer 2', 'author 2'),
],
)
def test_embed_len_with_options(title, description, fields, footer, author):
embed = discord.Embed(title=title, description=description)
for name, value in fields:
embed.add_field(name=name, value=value)
embed.set_footer(text=footer)
embed.set_author(name=author)
assert len(embed) == len(title) + len(description) + len("".join([name + value for name, value in fields])) + len(
footer
) + len(author)
def test_embed_to_dict():
timestamp = datetime.datetime.now(datetime.timezone.utc)
embed = discord.Embed(title="Test Title", description="Test Description", timestamp=timestamp)
data = embed.to_dict()
assert data['title'] == "Test Title"
assert data['description'] == "Test Description"
assert data['timestamp'] == timestamp.isoformat()
def test_embed_from_dict():
data = {
'title': 'Test Title',
'description': 'Test Description',
'url': 'http://example.com',
'color': 0x00FF00,
'timestamp': '2024-07-03T12:34:56+00:00',
}
embed = discord.Embed.from_dict(data)
assert embed.title == 'Test Title'
assert embed.description == 'Test Description'
assert embed.url == 'http://example.com'
assert embed.colour is not None and embed.colour.value == 0x00FF00
assert embed.timestamp is not None and embed.timestamp.isoformat() == '2024-07-03T12:34:56+00:00'
@pytest.mark.parametrize(
('value'),
[
-0.5,
'#FFFFFF',
],
)
def test_embed_colour_setter_failure(value):
embed = discord.Embed()
with pytest.raises(TypeError):
embed.colour = value

56
tests/test_files.py

@ -27,6 +27,7 @@ from __future__ import annotations
from io import BytesIO from io import BytesIO
import discord import discord
import pytest
FILE = BytesIO() FILE = BytesIO()
@ -127,3 +128,58 @@ def test_file_not_spoiler_with_overriding_name_double_spoiler():
f.filename = 'SPOILER_SPOILER_.gitignore' f.filename = 'SPOILER_SPOILER_.gitignore'
assert f.filename == 'SPOILER_.gitignore' assert f.filename == 'SPOILER_.gitignore'
assert f.spoiler == True assert f.spoiler == True
def test_file_reset():
f = discord.File('.gitignore')
f.reset(seek=True)
assert f.fp.tell() == 0
f.reset(seek=False)
assert f.fp.tell() == 0
def test_io_reset():
f = discord.File(FILE)
f.reset(seek=True)
assert f.fp.tell() == 0
f.reset(seek=False)
assert f.fp.tell() == 0
def test_io_failure():
class NonSeekableReadable(BytesIO):
def seekable(self):
return False
def readable(self):
return False
f = NonSeekableReadable()
with pytest.raises(ValueError) as excinfo:
discord.File(f)
assert str(excinfo.value) == f"File buffer {f!r} must be seekable and readable"
def test_io_to_dict():
buffer = BytesIO(b"test content")
file = discord.File(buffer, filename="test.txt", description="test description")
data = file.to_dict(0)
assert data["id"] == 0
assert data["filename"] == "test.txt"
assert data["description"] == "test description"
def test_file_to_dict():
f = discord.File('.gitignore', description="test description")
data = f.to_dict(0)
assert data["id"] == 0
assert data["filename"] == ".gitignore"
assert data["description"] == "test description"

167
tests/test_ui_buttons.py

@ -0,0 +1,167 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import discord
import pytest
def test_button_init():
button = discord.ui.Button(
label="Click me!",
)
assert button.label == "Click me!"
assert button.style == discord.ButtonStyle.secondary
assert button.disabled == False
assert button.url == None
assert button.emoji == None
assert button.sku_id == None
def test_button_with_sku_id():
button = discord.ui.Button(
label="Click me!",
sku_id=1234567890,
)
assert button.label == "Click me!"
assert button.style == discord.ButtonStyle.premium
assert button.sku_id == 1234567890
def test_button_with_url():
button = discord.ui.Button(
label="Click me!",
url="https://example.com",
)
assert button.label == "Click me!"
assert button.style == discord.ButtonStyle.link
assert button.url == "https://example.com"
def test_mix_both_custom_id_and_url():
with pytest.raises(TypeError):
discord.ui.Button(
label="Click me!",
url="https://example.com",
custom_id="test",
)
def test_mix_both_custom_id_and_sku_id():
with pytest.raises(TypeError):
discord.ui.Button(
label="Click me!",
sku_id=1234567890,
custom_id="test",
)
def test_mix_both_url_and_sku_id():
with pytest.raises(TypeError):
discord.ui.Button(
label="Click me!",
url="https://example.com",
sku_id=1234567890,
)
def test_invalid_url():
button = discord.ui.Button(
label="Click me!",
)
with pytest.raises(TypeError):
button.url = 1234567890 # type: ignore
def test_invalid_custom_id():
with pytest.raises(TypeError):
discord.ui.Button(
label="Click me!",
custom_id=1234567890, # type: ignore
)
button = discord.ui.Button(
label="Click me!",
)
with pytest.raises(TypeError):
button.custom_id = 1234567890 # type: ignore
def test_button_with_partial_emoji():
button = discord.ui.Button(
label="Click me!",
emoji="👍",
)
assert button.label == "Click me!"
assert button.emoji is not None and button.emoji.name == "👍"
def test_button_with_str_emoji():
emoji = discord.PartialEmoji(name="👍")
button = discord.ui.Button(
label="Click me!",
emoji=emoji,
)
assert button.label == "Click me!"
assert button.emoji == emoji
def test_button_with_invalid_emoji():
with pytest.raises(TypeError):
discord.ui.Button(
label="Click me!",
emoji=-0.53, # type: ignore
)
button = discord.ui.Button(
label="Click me!",
)
with pytest.raises(TypeError):
button.emoji = -0.53 # type: ignore
def test_button_setter():
button = discord.ui.Button()
button.label = "Click me!"
assert button.label == "Click me!"
button.style = discord.ButtonStyle.primary
assert button.style == discord.ButtonStyle.primary
button.disabled = True
assert button.disabled == True
button.url = "https://example.com"
assert button.url == "https://example.com"
button.emoji = "👍"
assert button.emoji is not None and button.emoji.name == "👍" # type: ignore
button.custom_id = "test"
assert button.custom_id == "test"
button.sku_id = 1234567890
assert button.sku_id == 1234567890

102
tests/test_ui_modals.py

@ -0,0 +1,102 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import discord
import pytest
@pytest.mark.asyncio
async def test_modal_init():
modal = discord.ui.Modal(
title="Temp Title",
)
assert modal.title == "Temp Title"
assert modal.timeout == None
@pytest.mark.asyncio
async def test_no_title():
with pytest.raises(ValueError) as excinfo:
discord.ui.Modal()
assert str(excinfo.value) == "Modal must have a title"
@pytest.mark.asyncio
async def test_to_dict():
modal = discord.ui.Modal(
title="Temp Title",
)
data = modal.to_dict()
assert data["custom_id"] is not None
assert data["title"] == "Temp Title"
assert data["components"] == []
@pytest.mark.asyncio
async def test_add_item():
modal = discord.ui.Modal(
title="Temp Title",
)
item = discord.ui.TextInput(label="Test")
modal.add_item(item)
assert modal.children == [item]
@pytest.mark.asyncio
async def test_add_item_invalid():
modal = discord.ui.Modal(
title="Temp Title",
)
with pytest.raises(TypeError):
modal.add_item("Not an item") # type: ignore
@pytest.mark.asyncio
async def test_maximum_items():
modal = discord.ui.Modal(
title="Temp Title",
)
max_item_limit = 5
for i in range(max_item_limit):
modal.add_item(discord.ui.TextInput(label=f"Test {i}"))
with pytest.raises(ValueError):
modal.add_item(discord.ui.TextInput(label="Test"))
@pytest.mark.asyncio
async def test_modal_setters():
modal = discord.ui.Modal(
title="Temp Title",
)
modal.title = "New Title"
assert modal.title == "New Title"
modal.timeout = 120
assert modal.timeout == 120
Loading…
Cancel
Save