Browse Source

Merge branch 'Rapptz:master' into voice-messages

pull/10230/head
blord0 2 months ago
committed by GitHub
parent
commit
f852a36e5c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .github/CONTRIBUTING.md
  2. 2
      .github/workflows/lint.yml
  3. 2
      .gitignore
  4. 4
      discord/__init__.py
  5. 8
      discord/__main__.py
  6. 5
      discord/abc.py
  7. 133
      discord/app_commands/commands.py
  8. 6
      discord/app_commands/models.py
  9. 41
      discord/app_commands/transformers.py
  10. 27
      discord/app_commands/tree.py
  11. 26
      discord/channel.py
  12. 78
      discord/client.py
  13. 433
      discord/components.py
  14. 4
      discord/emoji.py
  15. 17
      discord/enums.py
  16. 10
      discord/errors.py
  17. 13
      discord/ext/commands/bot.py
  18. 37
      discord/ext/commands/cog.py
  19. 5
      discord/ext/commands/context.py
  20. 24
      discord/ext/commands/converter.py
  21. 24
      discord/ext/commands/core.py
  22. 21
      discord/ext/commands/errors.py
  23. 21
      discord/ext/commands/flags.py
  24. 10
      discord/ext/commands/help.py
  25. 12
      discord/ext/commands/hybrid.py
  26. 51
      discord/ext/tasks/__init__.py
  27. 137
      discord/gateway.py
  28. 62
      discord/guild.py
  29. 25
      discord/http.py
  30. 16
      discord/integrations.py
  31. 83
      discord/interactions.py
  32. 65
      discord/member.py
  33. 43
      discord/message.py
  34. 16
      discord/partial_emoji.py
  35. 24
      discord/permissions.py
  36. 79
      discord/player.py
  37. 5
      discord/shard.py
  38. 9
      discord/state.py
  39. 3
      discord/threads.py
  40. 70
      discord/types/components.py
  41. 76
      discord/types/interactions.py
  42. 3
      discord/ui/__init__.py
  43. 45
      discord/ui/action_row.py
  44. 9
      discord/ui/button.py
  45. 391
      discord/ui/checkbox.py
  46. 31
      discord/ui/container.py
  47. 10
      discord/ui/file.py
  48. 199
      discord/ui/file_upload.py
  49. 22
      discord/ui/item.py
  50. 12
      discord/ui/label.py
  51. 55
      discord/ui/modal.py
  52. 246
      discord/ui/radio.py
  53. 3
      discord/ui/section.py
  54. 72
      discord/ui/select.py
  55. 9
      discord/ui/separator.py
  56. 3
      discord/ui/text_display.py
  57. 12
      discord/ui/text_input.py
  58. 172
      discord/ui/view.py
  59. 109
      discord/utils.py
  60. 25
      discord/voice_client.py
  61. 70
      discord/voice_state.py
  62. 38
      discord/webhook/async_.py
  63. 37
      discord/webhook/sync.py
  64. 4
      docs/_static/style.css
  65. 16
      docs/api.rst
  66. 5
      docs/ext/commands/api.rst
  67. 26
      docs/ext/commands/commands.rst
  68. 128
      docs/interactions/api.rst
  69. 105
      docs/whats_new.rst
  70. 143
      examples/modals/report.py
  71. 21
      pyproject.toml

4
.github/CONTRIBUTING.md

@ -34,6 +34,10 @@ If the bug report is missing this information then it'll take us longer to fix t
Submitting a pull request is fairly simple, just make sure it focuses on a single aspect and doesn't manage to have scope creep and it's probably good to go. It would be incredibly lovely if the style is consistent to that found in the project. This project follows PEP-8 guidelines (mostly) with a column limit of 125.
### AI Contributions
This repository does not accept any AI contributions at all. Using tools like Claude Code, Copilot, Gemini, ChatGPT, OpenAI Codex, etc. are simply blanket banned. AI contributions are typically nonsensical and just take up very valuable review time and thus are banned. Pull requests that are made with AI tools will be instantly closed without review, no matter how small the changeset is.
### Git Commit Guidelines
- Use present tense (e.g. "Add feature" not "Added feature")

2
.github/workflows/lint.yml

@ -45,4 +45,4 @@ jobs:
- name: Run ruff
if: ${{ always() && steps.install-deps.outcome == 'success' }}
run: |
ruff format --check discord examples
ruff format --check

2
.gitignore

@ -16,3 +16,5 @@ docs/crowdin.py
*.mo
/.coverage
build/*
uv.lock*
pylock*.toml

4
discord/__init__.py

@ -13,7 +13,7 @@ __title__ = 'discord'
__author__ = 'Rapptz'
__license__ = 'MIT'
__copyright__ = 'Copyright 2015-present Rapptz'
__version__ = '2.7.0a'
__version__ = '2.8.0a'
__path__ = __import__('pkgutil').extend_path(__path__, __name__)
@ -86,7 +86,7 @@ class VersionInfo(NamedTuple):
serial: int
version_info: VersionInfo = VersionInfo(major=2, minor=7, micro=0, releaselevel='alpha', serial=0)
version_info: VersionInfo = VersionInfo(major=2, minor=8, micro=0, releaselevel='alpha', serial=0)
logging.getLogger(__name__).addHandler(logging.NullHandler())

8
discord/__main__.py

@ -48,6 +48,14 @@ def show_version() -> None:
entries.append(f' - discord.py metadata: v{version}')
entries.append(f'- aiohttp v{aiohttp.__version__}')
try:
import davey # type: ignore
except ImportError:
entries.append('- davey not found')
else:
entries.append(f'- davey v{davey.__version__}')
uname = platform.uname()
entries.append('- system info: {0.system} {0.release} {0.version}'.format(uname))
print('\n'.join(entries))

5
discord/abc.py

@ -195,6 +195,9 @@ async def _purge_helper(
count = 0
await asyncio.sleep(1)
if not message.type.is_deletable():
continue
if not check(message):
continue
@ -818,7 +821,7 @@ class GuildChannel:
if obj.is_default():
return base
overwrite = utils.get(self._overwrites, type=_Overwrites.ROLE, id=obj.id)
overwrite = utils.find(lambda ow: ow.type == _Overwrites.ROLE and ow.id == obj.id, self._overwrites)
if overwrite is not None:
base.handle_overwrite(overwrite.allow, overwrite.deny)

133
discord/app_commands/commands.py

@ -58,7 +58,16 @@ from ..message import Message
from ..user import User
from ..member import Member
from ..permissions import Permissions
from ..utils import resolve_annotation, MISSING, is_inside_class, maybe_coroutine, async_all, _shorten, _to_kebab_case
from ..utils import (
resolve_annotation,
MISSING,
is_inside_class,
maybe_coroutine,
async_all,
_iscoroutinefunction,
_shorten,
_to_kebab_case,
)
if TYPE_CHECKING:
from typing_extensions import ParamSpec, Concatenate, Unpack
@ -346,7 +355,7 @@ def _populate_autocomplete(params: Dict[str, CommandParameter], autocomplete: Di
if callback is MISSING:
continue
if not inspect.iscoroutinefunction(callback):
if not _iscoroutinefunction(callback):
raise TypeError('autocomplete callback must be a coroutine function')
if param.type not in (AppCommandOptionType.string, AppCommandOptionType.number, AppCommandOptionType.integer):
@ -1037,7 +1046,7 @@ class Command(Generic[GroupT, P, T]):
The coroutine passed is not actually a coroutine.
"""
if not inspect.iscoroutinefunction(coro):
if not _iscoroutinefunction(coro):
raise TypeError('The error handler must be a coroutine.')
self.on_error = coro
@ -1098,7 +1107,7 @@ class Command(Generic[GroupT, P, T]):
"""
def decorator(coro: AutocompleteCallback[GroupT, ChoiceT]) -> AutocompleteCallback[GroupT, ChoiceT]:
if not inspect.iscoroutinefunction(coro):
if not _iscoroutinefunction(coro):
raise TypeError('The autocomplete callback must be a coroutine function.')
try:
@ -1347,7 +1356,7 @@ class ContextMenu:
The coroutine passed is not actually a coroutine.
"""
if not inspect.iscoroutinefunction(coro):
if not _iscoroutinefunction(coro):
raise TypeError('The error handler must be a coroutine.')
self.on_error = coro
@ -1802,7 +1811,7 @@ class Group:
yield from command.walk_commands()
@mark_overrideable
async def on_error(self, interaction: Interaction, error: AppCommandError, /) -> None:
async def on_error(self, interaction: Interaction[ClientT], error: AppCommandError, /) -> None:
"""|coro|
A callback that is called when a child's command raises an :exc:`AppCommandError`.
@ -1840,7 +1849,7 @@ class Group:
The coroutine passed is not actually a coroutine, or is an invalid coroutine.
"""
if not inspect.iscoroutinefunction(coro):
if not _iscoroutinefunction(coro):
raise TypeError('The error handler must be a coroutine.')
params = inspect.signature(coro).parameters
@ -1850,7 +1859,7 @@ class Group:
self.on_error = coro # type: ignore
return coro
async def interaction_check(self, interaction: Interaction, /) -> bool:
async def interaction_check(self, interaction: Interaction[ClientT], /) -> bool:
"""|coro|
A callback that is called when an interaction happens within the group
@ -1990,7 +1999,7 @@ class Group:
"""
def decorator(func: CommandCallback[GroupT, P, T]) -> Command[GroupT, P, T]:
if not inspect.iscoroutinefunction(func):
if not _iscoroutinefunction(func):
raise TypeError('command function must be a coroutine function')
if description is MISSING:
@ -2051,7 +2060,7 @@ def command(
"""
def decorator(func: CommandCallback[GroupT, P, T]) -> Command[GroupT, P, T]:
if not inspect.iscoroutinefunction(func):
if not _iscoroutinefunction(func):
raise TypeError('command function must be a coroutine function')
if description is MISSING:
@ -2123,7 +2132,7 @@ def context_menu(
"""
def decorator(func: ContextMenuCallback) -> ContextMenu:
if not inspect.iscoroutinefunction(func):
if not _iscoroutinefunction(func):
raise TypeError('context menu function must be a coroutine function')
actual_name = func.__name__.title() if name is MISSING else name
@ -2180,8 +2189,9 @@ def describe(**parameters: Union[str, locale_str]) -> Callable[[T], T]:
'''
def decorator(inner: T) -> T:
if isinstance(inner, Command):
_populate_descriptions(inner._params, parameters)
unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner
if isinstance(unwrapped, Command):
_populate_descriptions(unwrapped._params, parameters)
else:
try:
inner.__discord_app_commands_param_description__.update(parameters) # type: ignore # Runtime attribute access
@ -2223,8 +2233,9 @@ def rename(**parameters: Union[str, locale_str]) -> Callable[[T], T]:
"""
def decorator(inner: T) -> T:
if isinstance(inner, Command):
_populate_renames(inner._params, parameters)
unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner
if isinstance(unwrapped, Command):
_populate_renames(unwrapped._params, parameters)
else:
try:
inner.__discord_app_commands_param_rename__.update(parameters) # type: ignore # Runtime attribute access
@ -2292,8 +2303,9 @@ def choices(**parameters: List[Choice[ChoiceT]]) -> Callable[[T], T]:
"""
def decorator(inner: T) -> T:
if isinstance(inner, Command):
_populate_choices(inner._params, parameters)
unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner
if isinstance(unwrapped, Command):
_populate_choices(unwrapped._params, parameters)
else:
try:
inner.__discord_app_commands_param_choices__.update(parameters) # type: ignore # Runtime attribute access
@ -2351,8 +2363,9 @@ def autocomplete(**parameters: AutocompleteCallback[GroupT, ChoiceT]) -> Callabl
"""
def decorator(inner: T) -> T:
if isinstance(inner, Command):
_populate_autocomplete(inner._params, parameters)
unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner
if isinstance(unwrapped, Command):
_populate_autocomplete(unwrapped._params, parameters)
else:
try:
inner.__discord_app_commands_param_autocomplete__.update(parameters) # type: ignore # Runtime attribute access
@ -2408,13 +2421,14 @@ def guilds(*guild_ids: Union[Snowflake, int]) -> Callable[[T], T]:
defaults: List[int] = [g if isinstance(g, int) else g.id for g in guild_ids]
def decorator(inner: T) -> T:
if isinstance(inner, (Group, ContextMenu)):
inner._guild_ids = defaults
elif isinstance(inner, Command):
if inner.parent is not None:
unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner
if isinstance(unwrapped, (Group, ContextMenu)):
unwrapped._guild_ids = defaults
elif isinstance(unwrapped, Command):
if unwrapped.parent is not None:
raise ValueError('child commands of a group cannot have default guilds set')
inner._guild_ids = defaults
unwrapped._guild_ids = defaults
else:
# Runtime attribute assignment
inner.__discord_app_commands_default_guilds__ = defaults # type: ignore
@ -2470,13 +2484,14 @@ def check(predicate: Check) -> Callable[[T], T]:
"""
def decorator(func: CheckInputParameter) -> CheckInputParameter:
if isinstance(func, (Command, ContextMenu)):
func.checks.append(predicate)
unwrapped = getattr(func, '__discord_app_commands_unwrap__', func) or func
if isinstance(unwrapped, (Command, ContextMenu)):
unwrapped.checks.append(predicate)
else:
if not hasattr(func, '__discord_app_commands_checks__'):
func.__discord_app_commands_checks__ = []
func.__discord_app_commands_checks__ = [] # type: ignore # Runtime attribute assignment
func.__discord_app_commands_checks__.append(predicate)
func.__discord_app_commands_checks__.append(predicate) # type: ignore # Runtime attribute access
return func
@ -2513,10 +2528,11 @@ def guild_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]:
"""
def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)):
f.guild_only = True
allowed_contexts = f.allowed_contexts or AppCommandContext()
f.allowed_contexts = allowed_contexts
unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
if isinstance(unwrapped, (Command, Group, ContextMenu)):
unwrapped.guild_only = True
allowed_contexts = unwrapped.allowed_contexts or AppCommandContext()
unwrapped.allowed_contexts = allowed_contexts
else:
f.__discord_app_commands_guild_only__ = True # type: ignore # Runtime attribute assignment
@ -2567,10 +2583,11 @@ def private_channel_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]
"""
def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)):
f.guild_only = False
allowed_contexts = f.allowed_contexts or AppCommandContext()
f.allowed_contexts = allowed_contexts
unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
if isinstance(unwrapped, (Command, Group, ContextMenu)):
unwrapped.guild_only = False
allowed_contexts = unwrapped.allowed_contexts or AppCommandContext()
unwrapped.allowed_contexts = allowed_contexts
else:
allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext()
f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment
@ -2617,10 +2634,11 @@ def dm_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]:
"""
def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)):
f.guild_only = False
allowed_contexts = f.allowed_contexts or AppCommandContext()
f.allowed_contexts = allowed_contexts
unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
if isinstance(unwrapped, (Command, Group, ContextMenu)):
unwrapped.guild_only = False
allowed_contexts = unwrapped.allowed_contexts or AppCommandContext()
unwrapped.allowed_contexts = allowed_contexts
else:
allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext()
f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment
@ -2658,10 +2676,11 @@ def allowed_contexts(guilds: bool = MISSING, dms: bool = MISSING, private_channe
"""
def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)):
f.guild_only = False
allowed_contexts = f.allowed_contexts or AppCommandContext()
f.allowed_contexts = allowed_contexts
unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
if isinstance(unwrapped, (Command, Group, ContextMenu)):
unwrapped.guild_only = False
allowed_contexts = unwrapped.allowed_contexts or AppCommandContext()
unwrapped.allowed_contexts = allowed_contexts
else:
allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext()
f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment
@ -2709,9 +2728,10 @@ def guild_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]:
"""
def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)):
allowed_installs = f.allowed_installs or AppInstallationType()
f.allowed_installs = allowed_installs
unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
if isinstance(unwrapped, (Command, Group, ContextMenu)):
allowed_installs = unwrapped.allowed_installs or AppInstallationType()
unwrapped.allowed_installs = allowed_installs
else:
allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType()
f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment
@ -2757,9 +2777,10 @@ def user_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]:
"""
def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)):
allowed_installs = f.allowed_installs or AppInstallationType()
f.allowed_installs = allowed_installs
unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
if isinstance(unwrapped, (Command, Group, ContextMenu)):
allowed_installs = unwrapped.allowed_installs or AppInstallationType()
unwrapped.allowed_installs = allowed_installs
else:
allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType()
f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment
@ -2801,9 +2822,10 @@ def allowed_installs(
"""
def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)):
allowed_installs = f.allowed_installs or AppInstallationType()
f.allowed_installs = allowed_installs
unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
if isinstance(unwrapped, (Command, Group, ContextMenu)):
allowed_installs = unwrapped.allowed_installs or AppInstallationType()
unwrapped.allowed_installs = allowed_installs
else:
allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType()
f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment
@ -2874,8 +2896,9 @@ def default_permissions(perms_obj: Optional[Permissions] = None, /, **perms: Unp
permissions = Permissions(**perms)
def decorator(func: T) -> T:
if isinstance(func, (Command, Group, ContextMenu)):
func.default_permissions = permissions
unwrapped = getattr(func, '__discord_app_commands_unwrap__', func) or func
if isinstance(unwrapped, (Command, Group, ContextMenu)):
unwrapped.default_permissions = permissions
else:
func.__discord_app_commands_default_permissions__ = permissions # type: ignore # Runtime attribute assignment

6
discord/app_commands/models.py

@ -597,8 +597,7 @@ class AppCommandChannel(Hashable):
slowmode_delay: :class:`int`
The number of seconds a member must wait between sending messages
in this channel. A value of ``0`` denotes that it is disabled.
Bots and users with :attr:`~discord.Permissions.manage_channels` or
:attr:`~discord.Permissions.manage_messages` bypass slowmode.
Bots and users with :attr:`~discord.Permissions.bypass_slowmode` bypass slowmode.
.. versionadded:: 2.6
nsfw: :class:`bool`
@ -779,8 +778,7 @@ class AppCommandThread(Hashable):
slowmode_delay: :class:`int`
The number of seconds a member must wait between sending messages
in this thread. A value of ``0`` denotes that it is disabled.
Bots and users with :attr:`~discord.Permissions.manage_channels` or
:attr:`~discord.Permissions.manage_messages` bypass slowmode.
Bots and users with :attr:`~discord.Permissions.bypass_slowmode` bypass slowmode.
.. versionadded:: 2.6
message_count: :class:`int`

41
discord/app_commands/transformers.py

@ -23,6 +23,7 @@ DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import datetime
import inspect
from dataclasses import dataclass
@ -52,7 +53,7 @@ from ..channel import StageChannel, VoiceChannel, TextChannel, CategoryChannel,
from ..abc import GuildChannel
from ..threads import Thread
from ..enums import Enum as InternalEnum, AppCommandOptionType, ChannelType, Locale
from ..utils import MISSING, maybe_coroutine, _human_join
from ..utils import MISSING, maybe_coroutine, _human_join, _iscoroutinefunction, TIMESTAMP_PATTERN
from ..user import User
from ..role import Role
from ..member import Member
@ -62,6 +63,7 @@ from .._types import ClientT
__all__ = (
'Transformer',
'Transform',
'Timestamp',
'Range',
)
@ -681,6 +683,41 @@ class UnionChannelTransformer(BaseChannelTransformer[ClientT]):
return resolved
if TYPE_CHECKING:
Timestamp = datetime.datetime
else:
class Timestamp(Transformer[ClientT]):
"""A type annotation that can be applied to a parameter for transforming a :ddocs:`Discord style timestamp <reference#message-formatting>` input to a
:class:`datetime.datetime`.
.. versionadded:: 2.7
.. warning::
Due to a Discord limitation, no timezone is provided with the input. The UTC timezone has been supplanted instead.
Examples
---------
.. code-block:: python3
@app_commands.command()
async def datetime(interaction: discord.Interaction, value: app_commands.Timestamp):
await interaction.response.send_message(value.isoformat())
"""
@property
def type(self) -> AppCommandOptionType:
return AppCommandOptionType.string
async def transform(self, interaction: Interaction[ClientT], value: Any, /):
match = TIMESTAMP_PATTERN.match(value)
if not match:
raise TransformerError(value, AppCommandOptionType.string, self)
return datetime.datetime.fromtimestamp(int(match[1]), tz=datetime.timezone.utc)
CHANNEL_TO_TYPES: Dict[Any, List[ChannelType]] = {
AppCommandChannel: [
ChannelType.stage_voice,
@ -777,7 +814,7 @@ def get_supported_annotation(
params = inspect.signature(transform_classmethod.__func__).parameters
if len(params) != 3:
raise TypeError('Inline transformer with transform classmethod requires 3 parameters')
if not inspect.iscoroutinefunction(transform_classmethod.__func__):
if not _iscoroutinefunction(transform_classmethod.__func__):
raise TypeError('Inline transformer with transform classmethod must be a coroutine')
return (InlineTransformer(annotation), MISSING, False)

27
discord/app_commands/tree.py

@ -62,7 +62,7 @@ from .installs import AppCommandContext, AppInstallationType
from .translator import Translator, locale_str
from ..errors import ClientException, HTTPException
from ..enums import AppCommandType, InteractionType
from ..utils import MISSING, _get_as_snowflake, _is_submodule, _shorten
from ..utils import MISSING, _get_as_snowflake, _iscoroutinefunction, _is_submodule, _shorten
from .._types import ClientT
@ -257,7 +257,7 @@ class CommandTree(Generic[ClientT]):
--------
CommandLimitReached
The maximum number of commands was reached for that guild.
This is currently 100 for slash commands and 5 for context menu commands.
This is currently 100 for slash commands and 15 for context menu commands.
"""
try:
@ -277,9 +277,9 @@ class CommandTree(Generic[ClientT]):
counter = Counter(cmd_type for _, _, cmd_type in ctx_menu)
for cmd_type, count in counter.items():
if count > 5:
if count > 15:
as_enum = AppCommandType(cmd_type)
raise CommandLimitReached(guild_id=guild.id, limit=5, type=as_enum)
raise CommandLimitReached(guild_id=guild.id, limit=15, type=as_enum)
self._context_menus.update(ctx_menu)
self._guild_commands[guild.id] = mapping
@ -338,7 +338,7 @@ class CommandTree(Generic[ClientT]):
Or, ``guild`` and ``guilds`` were both given.
CommandLimitReached
The maximum number of commands was reached globally or for that guild.
This is currently 100 for slash commands and 5 for context menu commands.
This is currently 100 for slash commands and 15 for context menu commands.
"""
guild_ids = _retrieve_guild_ids(command, guild, guilds)
@ -361,8 +361,8 @@ class CommandTree(Generic[ClientT]):
# read as `0 if override and found else 1` if confusing
to_add = not (override and found)
total = sum(1 for _, g, t in self._context_menus if g == guild_id and t == type)
if total + to_add > 5:
raise CommandLimitReached(guild_id=guild_id, limit=5, type=AppCommandType(type))
if total + to_add > 15:
raise CommandLimitReached(guild_id=guild_id, limit=15, type=AppCommandType(type))
data[key] = command
if guild_ids is None:
@ -839,7 +839,7 @@ class CommandTree(Generic[ClientT]):
not match the signature.
"""
if not inspect.iscoroutinefunction(coro):
if not _iscoroutinefunction(coro):
raise TypeError('The error handler must be a coroutine.')
params = inspect.signature(coro).parameters
@ -908,7 +908,7 @@ class CommandTree(Generic[ClientT]):
"""
def decorator(func: CommandCallback[Group, P, T]) -> Command[Group, P, T]:
if not inspect.iscoroutinefunction(func):
if not _iscoroutinefunction(func):
raise TypeError('command function must be a coroutine function')
if description is MISSING:
@ -1005,7 +1005,7 @@ class CommandTree(Generic[ClientT]):
"""
def decorator(func: ContextMenuCallback) -> ContextMenu:
if not inspect.iscoroutinefunction(func):
if not _iscoroutinefunction(func):
raise TypeError('context menu function must be a coroutine function')
actual_name = func.__name__.title() if name is MISSING else name
@ -1289,7 +1289,12 @@ class CommandTree(Generic[ClientT]):
await command._invoke_autocomplete(interaction, focused, namespace)
except Exception:
# Suppress exception since it can't be handled anyway.
_log.exception('Ignoring exception in autocomplete for %r', command.qualified_name)
_log.exception(
'Ignoring exception in autocomplete for %r (Guild: %s, User: %s)',
command.qualified_name,
interaction.guild_id,
interaction.user.id,
)
return

26
discord/channel.py

@ -322,8 +322,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable):
slowmode_delay: :class:`int`
The number of seconds a member must wait between sending messages
in this channel. A value of ``0`` denotes that it is disabled.
Bots and users with :attr:`~Permissions.manage_channels` or
:attr:`~Permissions.manage_messages` bypass slowmode.
Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode.
nsfw: :class:`bool`
If the channel is marked as "not safe for work" or "age restricted".
default_auto_archive_duration: :class:`int`
@ -1516,8 +1515,7 @@ class VoiceChannel(VocalGuildChannel):
slowmode_delay: :class:`int`
The number of seconds a member must wait between sending messages
in this channel. A value of ``0`` denotes that it is disabled.
Bots and users with :attr:`~Permissions.manage_channels` or
:attr:`~Permissions.manage_messages` bypass slowmode.
Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode.
.. versionadded:: 2.2
"""
@ -1744,8 +1742,7 @@ class StageChannel(VocalGuildChannel):
slowmode_delay: :class:`int`
The number of seconds a member must wait between sending messages
in this channel. A value of ``0`` denotes that it is disabled.
Bots and users with :attr:`~Permissions.manage_channels` or
:attr:`~Permissions.manage_messages` bypass slowmode.
Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode.
.. versionadded:: 2.2
"""
@ -2409,8 +2406,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
slowmode_delay: :class:`int`
The number of seconds a member must wait between creating threads
in this forum. A value of ``0`` denotes that it is disabled.
Bots and users with :attr:`~Permissions.manage_channels` or
:attr:`~Permissions.manage_messages` bypass slowmode.
Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode.
nsfw: :class:`bool`
If the forum is marked as "not safe for work" or "age restricted".
default_auto_archive_duration: :class:`int`
@ -2879,6 +2875,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
applied_tags: Sequence[ForumTag] = ...,
view: LayoutView,
suppress_embeds: bool = ...,
silent: bool = ...,
reason: Optional[str] = ...,
) -> ThreadWithMessage: ...
@ -2901,6 +2898,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
applied_tags: Sequence[ForumTag] = ...,
view: View = ...,
suppress_embeds: bool = ...,
silent: bool = ...,
reason: Optional[str] = ...,
) -> ThreadWithMessage: ...
@ -2922,6 +2920,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
applied_tags: Sequence[ForumTag] = MISSING,
view: BaseView = MISSING,
suppress_embeds: bool = False,
silent: bool = False,
reason: Optional[str] = None,
) -> ThreadWithMessage:
"""|coro|
@ -2976,6 +2975,11 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
A list of stickers to upload. Must be a maximum of 3.
suppress_embeds: :class:`bool`
Whether to suppress embeds for the message. This sends the message without any embeds if set to ``True``.
silent: :class:`bool`
Whether to suppress push and desktop notifications for the message. This will increment the mention counter
in the UI, but will not actually send a notification.
.. versionadded:: 2.7
reason: :class:`str`
The reason for creating a new thread. Shows up on the audit log.
@ -3008,8 +3012,10 @@ class ForumChannel(discord.abc.GuildChannel, Hashable):
if view and not hasattr(view, '__discord_ui_view__'):
raise TypeError(f'view parameter must be View not {view.__class__.__name__}')
if suppress_embeds:
flags = MessageFlags._from_value(4)
if suppress_embeds or silent:
flags = MessageFlags._from_value(0)
flags.suppress_embeds = suppress_embeds
flags.suppress_notifications = silent
else:
flags = MISSING

78
discord/client.py

@ -68,7 +68,7 @@ from .voice_client import VoiceClient
from .http import HTTPClient
from .state import ConnectionState
from . import utils
from .utils import MISSING, time_snowflake, deprecated
from .utils import MISSING, time_snowflake, deprecated, _iscoroutinefunction
from .object import Object
from .backoff import ExponentialBackoff
from .webhook import Webhook
@ -88,7 +88,7 @@ if TYPE_CHECKING:
from .abc import Messageable, PrivateChannel, Snowflake, SnowflakeTime
from .app_commands import Command, ContextMenu
from .automod import AutoModAction, AutoModRule
from .channel import DMChannel, GroupChannel
from .channel import DMChannel, GroupChannel, VoiceChannelEffect
from .ext.commands import AutoShardedBot, Bot, Context, CommandError
from .guild import GuildChannel
from .integrations import Integration
@ -124,25 +124,25 @@ if TYPE_CHECKING:
from .flags import MemberCacheFlags
class _ClientOptions(TypedDict, total=False):
max_messages: int
proxy: str
proxy_auth: aiohttp.BasicAuth
shard_id: int
shard_count: int
max_messages: Optional[int]
proxy: Optional[str]
proxy_auth: Optional[aiohttp.BasicAuth]
shard_id: Optional[int]
shard_count: Optional[int]
application_id: int
member_cache_flags: MemberCacheFlags
chunk_guilds_at_startup: bool
status: Status
activity: BaseActivity
allowed_mentions: AllowedMentions
status: Optional[Status]
activity: Optional[BaseActivity]
allowed_mentions: Optional[AllowedMentions]
heartbeat_timeout: float
guild_ready_timeout: float
assume_unsync_clock: bool
enable_debug_events: bool
enable_raw_presences: bool
http_trace: aiohttp.TraceConfig
max_ratelimit_timeout: float
connector: aiohttp.BaseConnector
max_ratelimit_timeout: Optional[float]
connector: Optional[aiohttp.BaseConnector]
# fmt: off
@ -340,6 +340,10 @@ class Client:
VoiceClient.warn_nacl = False
_log.warning('PyNaCl is not installed, voice will NOT be supported')
if VoiceClient.warn_dave:
VoiceClient.warn_dave = False
_log.warning('davey is not installed, voice will NOT be supported')
async def __aenter__(self) -> Self:
await self._async_setup_hook()
return self
@ -1753,6 +1757,38 @@ class Client:
timeout: Optional[float] = ...,
) -> Tuple[ScheduledEvent, User]: ...
@overload
async def wait_for(
self,
event: Literal['scheduled_event_update'],
/,
*,
check: Optional[Callable[[ScheduledEvent, ScheduledEvent], bool]] = ...,
timeout: Optional[float] = ...,
) -> Tuple[ScheduledEvent, ScheduledEvent]: ...
# Soundboard
@overload
async def wait_for(
self,
event: Literal['soundboard_sound_create', 'soundboard_sound_delete'],
/,
*,
check: Optional[Callable[[SoundboardSound], bool]] = ...,
timeout: Optional[float] = ...,
) -> SoundboardSound: ...
@overload
async def wait_for(
self,
event: Literal['soundboard_sound_update'],
/,
*,
check: Optional[Callable[[SoundboardSound, SoundboardSound], bool]] = ...,
timeout: Optional[float] = ...,
) -> Tuple[SoundboardSound, SoundboardSound]: ...
# Stages
@overload
@ -1859,6 +1895,16 @@ class Client:
timeout: Optional[float] = ...,
) -> Tuple[Member, VoiceState, VoiceState]: ...
@overload
async def wait_for(
self,
event: Literal['voice_channel_effect'],
/,
*,
check: Optional[Callable[[VoiceChannelEffect], bool]] = ...,
timeout: Optional[float] = ...,
) -> VoiceChannelEffect: ...
# Polls
@overload
@ -2052,7 +2098,7 @@ class Client:
The coroutine passed is not actually a coroutine.
"""
if not asyncio.iscoroutinefunction(coro):
if not _iscoroutinefunction(coro):
raise TypeError('event registered must be a coroutine function')
setattr(self, coro.__name__, coro)
@ -2511,7 +2557,7 @@ class Client:
)
return Invite.from_incomplete(state=self._connection, data=data)
async def delete_invite(self, invite: Union[Invite, str], /) -> Invite:
async def delete_invite(self, invite: Union[Invite, str], /, *, reason: Optional[str] = None) -> Invite:
"""|coro|
Revokes an :class:`.Invite`, URL, or ID to an invite.
@ -2527,6 +2573,8 @@ class Client:
----------
invite: Union[:class:`.Invite`, :class:`str`]
The invite to revoke.
reason: Optional[:class:`str`]
The reason for deleting the invite. Shows up on the audit log.
Raises
-------
@ -2539,7 +2587,7 @@ class Client:
"""
resolved = utils.resolve_invite(invite)
data = await self.http.delete_invite(resolved.code)
data = await self.http.delete_invite(resolved.code, reason=reason)
return Invite.from_incomplete(state=self._connection, data=data)
# Miscellaneous stuff

433
discord/components.py

@ -72,6 +72,12 @@ if TYPE_CHECKING:
ContainerComponent as ContainerComponentPayload,
UnfurledMediaItem as UnfurledMediaItemPayload,
LabelComponent as LabelComponentPayload,
FileUploadComponent as FileUploadComponentPayload,
RadioGroupComponent as RadioGroupComponentPayload,
RadioGroupOption as RadioGroupOptionPayload,
CheckboxGroupComponent as CheckboxGroupComponentPayload,
CheckboxGroupOption as CheckboxGroupOptionPayload,
CheckboxComponent as CheckboxComponentPayload,
)
from .emoji import Emoji
@ -91,6 +97,7 @@ if TYPE_CHECKING:
'SectionComponent',
'Component',
]
OptionPayload = Union[SelectOptionPayload, RadioGroupOptionPayload, CheckboxGroupOptionPayload]
__all__ = (
@ -112,6 +119,12 @@ __all__ = (
'TextDisplay',
'SeparatorComponent',
'LabelComponent',
'FileUploadComponent',
'RadioGroupComponent',
'CheckboxGroupComponent',
'CheckboxComponent',
'RadioGroupOption',
'CheckboxGroupOption',
)
@ -131,6 +144,8 @@ class Component:
- :class:`FileComponent`
- :class:`SeparatorComponent`
- :class:`Container`
- :class:`LabelComponent`
- :class:`FileUploadComponent`
This class is abstract and cannot be instantiated.
@ -166,6 +181,71 @@ class Component:
raise NotImplementedError
class BaseOption:
"""Represents a base option for components that have options.
This currently implements:
- :class:`SelectOption`
- :class:`RadioGroupOption`
- :class:`CheckboxGroupOption`
.. versionadded:: 2.7
"""
__slots__: Tuple[str, ...] = ('label', 'value', 'description', 'default')
__repr_info__: ClassVar[Tuple[str, ...]] = ('label', 'value', 'description', 'default')
def __init__(
self,
*,
label: str,
value: str = MISSING,
description: Optional[str] = None,
default: bool = False,
) -> None:
self.label: str = label
self.value: str = label if value is MISSING else value
self.description: Optional[str] = description
self.default: bool = default
def __repr__(self) -> str:
attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__repr_info__)
return f'<{self.__class__.__name__} {attrs}>'
def __str__(self) -> str:
base = self.label
if self.description:
return f'{base}\n{self.description}'
return base
@classmethod
def from_dict(cls, data: OptionPayload) -> Self:
return cls(
label=data['label'],
value=data['value'],
description=data.get('description'),
default=data.get('default', False),
)
def to_dict(self) -> OptionPayload:
payload: OptionPayload = {
'label': self.label,
'value': self.value,
'default': self.default,
}
if self.description:
payload['description'] = self.description
return payload
def copy(self) -> Self:
return self.__class__.from_dict(self.to_dict())
class ActionRow(Component):
"""Represents a Discord Bot UI Kit Action Row.
@ -412,7 +492,7 @@ class SelectMenu(Component):
return payload
class SelectOption:
class SelectOption(BaseOption):
"""Represents a select menu's option.
These can be created by users.
@ -450,13 +530,8 @@ class SelectOption:
Whether this option is selected by default.
"""
__slots__: Tuple[str, ...] = (
'label',
'value',
'description',
'_emoji',
'default',
)
__slots__: Tuple[str, ...] = BaseOption.__slots__ + ('_emoji',)
__repr_info__ = BaseOption.__repr_info__ + ('emoji',)
def __init__(
self,
@ -467,18 +542,9 @@ class SelectOption:
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
default: bool = False,
) -> None:
self.label: str = label
self.value: str = label if value is MISSING else value
self.description: Optional[str] = description
super().__init__(label=label, value=value, description=description, default=default)
self.emoji = emoji
self.default: bool = default
def __repr__(self) -> str:
return (
f'<SelectOption label={self.label!r} value={self.value!r} description={self.description!r} '
f'emoji={self.emoji!r} default={self.default!r}>'
)
def __str__(self) -> str:
if self.emoji:
@ -508,7 +574,7 @@ class SelectOption:
self._emoji = None
@classmethod
def from_dict(cls, data: SelectOptionPayload) -> SelectOption:
def from_dict(cls, data: SelectOptionPayload) -> Self:
try:
emoji = PartialEmoji.from_dict(data['emoji']) # pyright: ignore[reportTypedDictNotRequiredAccess]
except KeyError:
@ -518,28 +584,18 @@ class SelectOption:
label=data['label'],
value=data['value'],
description=data.get('description'),
emoji=emoji,
default=data.get('default', False),
emoji=emoji,
)
def to_dict(self) -> SelectOptionPayload:
payload: SelectOptionPayload = {
'label': self.label,
'value': self.value,
'default': self.default,
}
payload: SelectOptionPayload = super().to_dict() # type: ignore
if self.emoji:
payload['emoji'] = self.emoji.to_dict()
if self.description:
payload['description'] = self.description
return payload
def copy(self) -> SelectOption:
return self.__class__.from_dict(self.to_dict())
class TextInput(Component):
"""Represents a text input from the Discord Bot UI Kit.
@ -1384,6 +1440,313 @@ class LabelComponent(Component):
return payload
class FileUploadComponent(Component):
"""Represents a file upload component from the Discord Bot UI Kit.
This inherits from :class:`Component`.
.. note::
The user constructible and usable type for creating a file upload is
:class:`discord.ui.FileUpload` not this one.
.. versionadded:: 2.7
Attributes
------------
custom_id: Optional[:class:`str`]
The ID of the component that gets received during an interaction.
min_values: :class:`int`
The minimum number of files that must be uploaded for this component.
Defaults to 1 and must be between 0 and 10.
max_values: :class:`int`
The maximum number of files that must be uploaded for this component.
Defaults to 1 and must be between 1 and 10.
id: Optional[:class:`int`]
The ID of this component.
required: :class:`bool`
Whether the component is required.
Defaults to ``True``.
"""
__slots__: Tuple[str, ...] = (
'custom_id',
'min_values',
'max_values',
'required',
'id',
)
__repr_info__: ClassVar[Tuple[str, ...]] = __slots__
def __init__(self, data: FileUploadComponentPayload, /) -> None:
self.custom_id: str = data['custom_id']
self.min_values: int = data.get('min_values', 1)
self.max_values: int = data.get('max_values', 1)
self.required: bool = data.get('required', True)
self.id: Optional[int] = data.get('id')
@property
def type(self) -> Literal[ComponentType.file_upload]:
""":class:`ComponentType`: The type of component."""
return ComponentType.file_upload
def to_dict(self) -> FileUploadComponentPayload:
payload: FileUploadComponentPayload = {
'type': self.type.value,
'custom_id': self.custom_id,
'min_values': self.min_values,
'max_values': self.max_values,
'required': self.required,
}
if self.id is not None:
payload['id'] = self.id
return payload
class RadioGroupComponent(Component):
"""Represents a radio group component from the Discord Bot UI Kit.
This inherits from :class:`Component`.
.. note::
The user constructible and usable type for creating a radio group is
:class:`discord.ui.RadioGroup` not this one.
.. versionadded:: 2.7
Attributes
------------
custom_id: Optional[:class:`str`]
The ID of the component that gets received during an interaction.
id: Optional[:class:`int`]
The ID of this component.
required: :class:`bool`
Whether the component is required.
Defaults to ``True``.
options: List[:class:`RadioGroupOption`]
A list of options that can be selected in this group.
"""
__slots__: Tuple[str, ...] = ('custom_id', 'required', 'id', 'options')
__repr_info__: ClassVar[Tuple[str, ...]] = __slots__
def __init__(self, data: RadioGroupComponentPayload, /) -> None:
self.custom_id: str = data['custom_id']
self.required: bool = data.get('required', True)
self.id: Optional[int] = data.get('id')
self.options: List[RadioGroupOption] = [RadioGroupOption.from_dict(option) for option in data.get('options', [])]
@property
def type(self) -> Literal[ComponentType.radio_group]:
""":class:`ComponentType`: The type of component."""
return ComponentType.radio_group
def to_dict(self) -> RadioGroupComponentPayload:
payload: RadioGroupComponentPayload = {
'type': self.type.value,
'custom_id': self.custom_id,
'required': self.required,
}
if self.id is not None:
payload['id'] = self.id
if self.options:
payload['options'] = [option.to_dict() for option in self.options]
return payload
class RadioGroupOption(BaseOption):
"""Represents a radio group's option
These can be created by users.
.. versionadded:: 2.7
Parameters
-----------
label: :class:`str`
The label of the option. This is displayed to users.
Can only be up to 100 characters.
value: :class:`str`
The value of the option. This is not displayed to users.
If not provided when constructed then it defaults to the label.
Can only be up to 100 characters.
description: Optional[:class:`str`]
An additional description of the option, if any.
Can only be up to 100 characters.
default: :class:`bool`
Whether this option is selected by default.
Attributes
-----------
label: :class:`str`
The label of the option. This is displayed to users.
value: :class:`str`
The value of the option. This is not displayed to users.
If not provided when constructed then it defaults to the
label.
description: Optional[:class:`str`]
An additional description of the option, if any.
default: :class:`bool`
Whether this option is selected by default.
"""
class CheckboxGroupComponent(Component):
"""Represents a checkbox group component from the Discord Bot UI Kit.
This inherits from :class:`Component`.
.. note::
The user constructible and usable type for creating a checkbox group is
:class:`discord.ui.CheckboxGroup` not this one.
.. versionadded:: 2.7
Attributes
------------
custom_id: Optional[:class:`str`]
The ID of the component that gets received during an interaction.
id: Optional[:class:`int`]
The ID of this component.
required: :class:`bool`
Whether the component is required.
Defaults to ``True``.
min_values: :class:`int`
The minimum number of options that must be selected in this component.
Must be between 0 and 10. Defaults to 0.
max_values: :class:`int`
The maximum number of options that can be selected in this component.
Must be between 1 and 10. Defaults to 1.
options: List[:class:`CheckboxGroupOption`]
A list of options that can be selected in this group.
"""
__slots__: Tuple[str, ...] = ('custom_id', 'required', 'id', 'min_values', 'max_values', 'options')
__repr_info__: ClassVar[Tuple[str, ...]] = __slots__
def __init__(self, data: CheckboxGroupComponentPayload, /) -> None:
self.custom_id: str = data['custom_id']
self.required: bool = data.get('required', True)
self.id: Optional[int] = data.get('id')
self.min_values: int = data.get('min_values', 0)
self.max_values: int = data.get('max_values', 1)
self.options: List[CheckboxGroupOption] = [
CheckboxGroupOption.from_dict(option) for option in data.get('options', [])
]
@property
def type(self) -> Literal[ComponentType.checkbox_group]:
""":class:`ComponentType`: The type of component."""
return ComponentType.checkbox_group
def to_dict(self) -> CheckboxGroupComponentPayload:
payload: CheckboxGroupComponentPayload = {
'type': self.type.value,
'custom_id': self.custom_id,
'min_values': self.min_values,
'max_values': self.max_values,
'required': self.required,
}
if self.id is not None:
payload['id'] = self.id
if self.options:
payload['options'] = [option.to_dict() for option in self.options]
return payload
class CheckboxGroupOption(BaseOption):
"""Represents a checkbox group's option
These can be created by users.
.. versionadded:: 2.7
Parameters
-----------
label: :class:`str`
The label of the option. This is displayed to users.
Can only be up to 100 characters.
value: :class:`str`
The value of the option. This is not displayed to users.
If not provided when constructed then it defaults to the label.
Can only be up to 100 characters.
description: Optional[:class:`str`]
An additional description of the option, if any.
Can only be up to 100 characters.
default: :class:`bool`
Whether this option is selected by default.
Attributes
-----------
label: :class:`str`
The label of the option. This is displayed to users.
value: :class:`str`
The value of the option. This is not displayed to users.
If not provided when constructed then it defaults to the
label.
description: Optional[:class:`str`]
An additional description of the option, if any.
default: :class:`bool`
Whether this option is selected by default.
"""
class CheckboxComponent(Component):
"""Represents a checkbox component from the Discord Bot UI Kit.
This inherits from :class:`Component`.
.. note::
The user constructible and usable type for creating a checkbox is
:class:`discord.ui.Checkbox` not this one.
.. versionadded:: 2.7
Attributes
------------
custom_id: Optional[:class:`str`]
The ID of the component that gets received during an interaction.
id: Optional[:class:`int`]
The ID of this component.
default: :class:`bool`
Whether this checkbox is selected by default.
"""
__slots__: Tuple[str, ...] = ('custom_id', 'default', 'id')
__repr_info__: ClassVar[Tuple[str, ...]] = __slots__
def __init__(self, data: CheckboxComponentPayload, /) -> None:
self.custom_id: str = data['custom_id']
self.id: Optional[int] = data.get('id')
self.default: bool = data.get('default', False)
@property
def type(self) -> Literal[ComponentType.checkbox]:
""":class:`ComponentType`: The type of component."""
return ComponentType.checkbox
def to_dict(self) -> CheckboxComponentPayload:
payload: CheckboxComponentPayload = {
'type': self.type.value,
'custom_id': self.custom_id,
'default': self.default,
}
if self.id is not None:
payload['id'] = self.id
return payload
def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]:
if data['type'] == 1:
return ActionRow(data)
@ -1409,3 +1772,11 @@ def _component_factory(data: ComponentPayload, state: Optional[ConnectionState]
return Container(data, state)
elif data['type'] == 18:
return LabelComponent(data, state)
elif data['type'] == 19:
return FileUploadComponent(data)
elif data['type'] == 21:
return RadioGroupComponent(data)
elif data['type'] == 22:
return CheckboxGroupComponent(data)
elif data['type'] == 23:
return CheckboxComponent(data)

4
discord/emoji.py

@ -165,8 +165,8 @@ class Emoji(_EmojiTag, AssetMixin):
@property
def url(self) -> str:
""":class:`str`: Returns the URL of the emoji."""
fmt = 'gif' if self.animated else 'png'
return f'{Asset.BASE}/emojis/{self.id}.{fmt}'
end = 'webp?animated=true' if self.animated else 'png'
return f'{Asset.BASE}/emojis/{self.id}.{end}'
@property
def roles(self) -> List[Role]:

17
discord/enums.py

@ -75,6 +75,8 @@ __all__ = (
'EntitlementType',
'EntitlementOwnerType',
'PollLayoutType',
'InviteType',
'ReactionType',
'VoiceChannelEffectAnimationType',
'SubscriptionStatus',
'MessageReferenceType',
@ -277,6 +279,16 @@ class MessageType(Enum):
poll_result = 46
emoji_added = 63
def is_deletable(self) -> bool:
return self not in {
MessageType.recipient_add,
MessageType.recipient_remove,
MessageType.call,
MessageType.channel_name_change,
MessageType.channel_icon_change,
MessageType.thread_starter_message,
}
class SpeakingState(Enum):
none = 0
@ -681,6 +693,11 @@ class ComponentType(Enum):
separator = 14
container = 17
label = 18
file_upload = 19
# checkpoint = 20
radio_group = 21
checkbox_group = 22
checkbox = 23
def __int__(self) -> int:
return self.value

10
discord/errors.py

@ -48,6 +48,7 @@ __all__ = (
'PrivilegedIntentsRequired',
'InteractionResponded',
'MissingApplicationID',
'FFmpegProcessError',
)
APP_ID_NOT_FOUND = (
@ -74,6 +75,15 @@ class ClientException(DiscordException):
pass
class FFmpegProcessError(ClientException):
"""Exception that's raised when an FFmpeg process fails.
.. versionadded:: 2.7
"""
pass
class GatewayNotFound(DiscordException):
"""An exception that is raised when the gateway for Discord could not be found"""

13
discord/ext/commands/bot.py

@ -25,7 +25,6 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
import asyncio
import collections
import collections.abc
import inspect
@ -53,7 +52,7 @@ from typing import (
import discord
from discord import app_commands
from discord.app_commands.tree import _retrieve_guild_ids
from discord.utils import MISSING, _is_submodule
from discord.utils import MISSING, _iscoroutinefunction, _is_submodule
from .core import GroupMixin
from .view import StringView
@ -89,8 +88,8 @@ if TYPE_CHECKING:
PrefixType = Union[_Prefix, _PrefixCallable[BotT]]
class _BotOptions(_ClientOptions, total=False):
owner_id: int
owner_ids: Collection[int]
owner_id: Optional[int]
owner_ids: Optional[Collection[int]]
strip_after_prefix: bool
case_insensitive: bool
@ -581,7 +580,7 @@ class BotBase(GroupMixin[None]):
TypeError
The coroutine passed is not actually a coroutine.
"""
if not asyncio.iscoroutinefunction(coro):
if not _iscoroutinefunction(coro):
raise TypeError('The pre-invoke hook must be a coroutine.')
self._before_invoke = coro
@ -618,7 +617,7 @@ class BotBase(GroupMixin[None]):
TypeError
The coroutine passed is not actually a coroutine.
"""
if not asyncio.iscoroutinefunction(coro):
if not _iscoroutinefunction(coro):
raise TypeError('The post-invoke hook must be a coroutine.')
self._after_invoke = coro
@ -654,7 +653,7 @@ class BotBase(GroupMixin[None]):
"""
name = func.__name__ if name is MISSING else name
if not asyncio.iscoroutinefunction(func):
if not _iscoroutinefunction(func):
raise TypeError('Listeners must be coroutines')
if name in self.extra_events:

37
discord/ext/commands/cog.py

@ -28,7 +28,7 @@ import inspect
import discord
import logging
from discord import app_commands
from discord.utils import maybe_coroutine, _to_kebab_case
from discord.utils import maybe_coroutine, _iscoroutinefunction, _to_kebab_case
from typing import (
Any,
@ -45,29 +45,18 @@ from typing import (
Tuple,
TypeVar,
Union,
TypedDict,
)
from ._types import _BaseCommand, BotT
from ._types import _BaseCommand, BotT, MaybeCoro
if TYPE_CHECKING:
from typing_extensions import Self, Unpack
from typing_extensions import Self
from discord.abc import Snowflake
from discord._types import ClientT
from .bot import BotBase
from .context import Context
from .core import Command, _CommandDecoratorKwargs
class _CogKwargs(TypedDict, total=False):
name: str
group_name: Union[str, app_commands.locale_str]
description: str
group_description: Union[str, app_commands.locale_str]
group_nsfw: bool
group_auto_locale_strings: bool
group_extras: Dict[Any, Any]
command_attrs: _CommandDecoratorKwargs
from .core import Command
__all__ = (
@ -182,7 +171,7 @@ class CogMeta(type):
__cog_app_commands__: List[Union[app_commands.Group, app_commands.Command[Any, ..., Any]]]
__cog_listeners__: List[Tuple[str, str]]
def __new__(cls, *args: Any, **kwargs: Unpack[_CogKwargs]) -> CogMeta:
def __new__(cls, *args: Any, **kwargs: Any) -> CogMeta:
name, bases, attrs = args
if any(issubclass(base, app_commands.Group) for base in bases):
raise TypeError(
@ -244,7 +233,7 @@ class CogMeta(type):
if elem.startswith(('cog_', 'bot_')):
raise TypeError(no_bot_cog.format(base, elem))
cog_app_commands[elem] = value
elif inspect.iscoroutinefunction(value):
elif _iscoroutinefunction(value):
try:
getattr(value, '__cog_listener__')
except AttributeError:
@ -533,7 +522,7 @@ class Cog(metaclass=CogMeta):
actual = func
if isinstance(actual, staticmethod):
actual = actual.__func__
if not inspect.iscoroutinefunction(actual):
if not _iscoroutinefunction(actual):
raise TypeError('Listener function must be a coroutine function.')
actual.__cog_listener__ = True
to_assign = name or actual.__name__
@ -594,7 +583,7 @@ class Cog(metaclass=CogMeta):
pass
@_cog_special_method
def bot_check_once(self, ctx: Context[BotT]) -> bool:
def bot_check_once(self, ctx: Context[BotT]) -> MaybeCoro[bool]:
"""A special method that registers as a :meth:`.Bot.check_once`
check.
@ -604,7 +593,7 @@ class Cog(metaclass=CogMeta):
return True
@_cog_special_method
def bot_check(self, ctx: Context[BotT]) -> bool:
def bot_check(self, ctx: Context[BotT]) -> MaybeCoro[bool]:
"""A special method that registers as a :meth:`.Bot.check`
check.
@ -614,7 +603,7 @@ class Cog(metaclass=CogMeta):
return True
@_cog_special_method
def cog_check(self, ctx: Context[BotT]) -> bool:
def cog_check(self, ctx: Context[BotT]) -> MaybeCoro[bool]:
"""A special method that registers as a :func:`~discord.ext.commands.check`
for every command and subcommand in this cog.
@ -624,7 +613,7 @@ class Cog(metaclass=CogMeta):
return True
@_cog_special_method
def interaction_check(self, interaction: discord.Interaction[ClientT], /) -> bool:
def interaction_check(self, interaction: discord.Interaction[ClientT], /) -> MaybeCoro[bool]:
"""A special method that registers as a :func:`discord.app_commands.check`
for every app command and subcommand in this cog.
@ -657,7 +646,9 @@ class Cog(metaclass=CogMeta):
pass
@_cog_special_method
async def cog_app_command_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError) -> None:
async def cog_app_command_error(
self, interaction: discord.Interaction[ClientT], error: app_commands.AppCommandError
) -> None:
"""|coro|
A special method that is called whenever an error within

5
discord/ext/commands/context.py

@ -259,6 +259,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
bot: BotT = interaction.client
data: ApplicationCommandInteractionData = interaction.data # type: ignore
type_ = data.get('type', 1)
if interaction.message is None:
synthetic_payload = {
'id': interaction.id,
@ -268,7 +269,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
'tts': False,
'pinned': False,
'edited_timestamp': None,
'type': MessageType.chat_input_command if data.get('type', 1) == 1 else MessageType.context_menu_command,
'type': MessageType.chat_input_command.value if type_ == 1 else MessageType.context_menu_command.value,
'flags': 64,
'content': '',
'mentions': [],
@ -288,7 +289,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
else:
message = interaction.message
prefix = '/' if data.get('type', 1) == 1 else '\u200b' # Mock the prefix
prefix = '/' if type_ == 1 else '\u200b' # Mock the prefix
ctx = cls(
message=message,
bot=bot,

24
discord/ext/commands/converter.py

@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
import datetime
import inspect
import re
from typing import (
@ -86,6 +87,7 @@ __all__ = (
'clean_content',
'Greedy',
'Range',
'Timestamp',
'run_converters',
)
@ -893,6 +895,28 @@ class GuildStickerConverter(IDConverter[discord.GuildSticker]):
return result
if TYPE_CHECKING:
Timestamp = datetime.datetime
else:
class Timestamp(Converter[str]):
"""Converts to a :class:`datetime.datetime`.
Conversion is attempted based on the :ddocs:`Discord style timestamp <reference#message-formatting>` input format.
.. versionadded:: 2.7
.. warning::
Due to a Discord limitation, no timezone is provided with the input. The UTC timezone has been supplanted instead.
"""
async def convert(self, ctx: Context[BotT], argument: str) -> datetime.datetime:
match = discord.utils.TIMESTAMP_PATTERN.match(argument)
if not match:
raise BadTimestampArgument(argument)
return datetime.datetime.fromtimestamp(int(match[1]), tz=datetime.timezone.utc)
class ScheduledEventConverter(IDConverter[discord.ScheduledEvent]):
"""Converts to a :class:`~discord.ScheduledEvent`.

24
discord/ext/commands/core.py

@ -68,11 +68,11 @@ if TYPE_CHECKING:
class _CommandDecoratorKwargs(TypedDict, total=False):
enabled: bool
help: str
brief: str
usage: str
help: Optional[str]
brief: Optional[str]
usage: Optional[str]
rest_is_raw: bool
aliases: List[str]
aliases: Union[List[str], Tuple[str, ...]]
description: str
hidden: bool
checks: List[UserCheck[Context[Any]]]
@ -427,7 +427,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
/,
**kwargs: Unpack[_CommandKwargs],
) -> None:
if not asyncio.iscoroutinefunction(func):
if not discord.utils._iscoroutinefunction(func):
raise TypeError('Callback must be a coroutine.')
name = kwargs.get('name') or func.__name__
@ -449,7 +449,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
self.brief: Optional[str] = kwargs.get('brief')
self.usage: Optional[str] = kwargs.get('usage')
self.rest_is_raw: bool = kwargs.get('rest_is_raw', False)
self.aliases: Union[List[str], Tuple[str]] = kwargs.get('aliases', [])
self.aliases: Union[List[str], Tuple[str, ...]] = kwargs.get('aliases', [])
self.extras: Dict[Any, Any] = kwargs.get('extras', {})
if not isinstance(self.aliases, (list, tuple)):
@ -1102,7 +1102,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
The coroutine passed is not actually a coroutine.
"""
if not asyncio.iscoroutinefunction(coro):
if not discord.utils._iscoroutinefunction(coro):
raise TypeError('The error handler must be a coroutine.')
self.on_error: Error[CogT, Any] = coro
@ -1140,7 +1140,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
TypeError
The coroutine passed is not actually a coroutine.
"""
if not asyncio.iscoroutinefunction(coro):
if not discord.utils._iscoroutinefunction(coro):
raise TypeError('The pre-invoke hook must be a coroutine.')
self._before_invoke = coro
@ -1171,7 +1171,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
TypeError
The coroutine passed is not actually a coroutine.
"""
if not asyncio.iscoroutinefunction(coro):
if not discord.utils._iscoroutinefunction(coro):
raise TypeError('The post-invoke hook must be a coroutine.')
self._after_invoke = coro
@ -1945,7 +1945,7 @@ def check(predicate: UserCheck[ContextT], /) -> Check[ContextT]:
return func
if inspect.iscoroutinefunction(predicate):
if discord.utils._iscoroutinefunction(predicate):
decorator.predicate = predicate
else:
@ -2369,7 +2369,7 @@ def guild_only() -> Check[Any]:
return func
if inspect.iscoroutinefunction(predicate):
if discord.utils._iscoroutinefunction(predicate):
decorator.predicate = predicate
else:
@ -2444,7 +2444,7 @@ def is_nsfw() -> Check[Any]:
return func
if inspect.iscoroutinefunction(predicate):
if discord.utils._iscoroutinefunction(predicate):
decorator.predicate = predicate
else:

21
discord/ext/commands/errors.py

@ -79,6 +79,7 @@ __all__ = (
'SoundboardSoundNotFound',
'PartialEmojiConversionFailure',
'BadBoolArgument',
'BadTimestampArgument',
'MissingRole',
'BotMissingRole',
'MissingAnyRole',
@ -602,6 +603,24 @@ class BadBoolArgument(BadArgument):
super().__init__(f'{argument} is not a recognised boolean option')
class BadTimestampArgument(BadArgument):
"""Exception raised when a timestamp argument was not convertable.
This inherits from :exc:`BadArgument`
.. versionadded:: 2.7
Attributes
-----------
argument: :class:`str`
The datetime/timestamp argument supplied by the caller that was not a valid timestamp format.
"""
def __init__(self, argument: str) -> None:
self.argument: str = argument
super().__init__(f'{argument} is not a recognised datetime or timestamp option')
class RangeError(BadArgument):
"""Exception raised when an argument is out of range.
@ -870,7 +889,7 @@ class BotMissingPermissions(CheckFailure):
class BadUnionArgument(UserInputError):
"""Exception raised when a :data:`typing.Union` converter fails for all
"""Exception raised when a :obj:`typing.Union` converter fails for all
its associated types.
This inherits from :exc:`UserInputError`

21
discord/ext/commands/flags.py

@ -50,6 +50,25 @@ if TYPE_CHECKING:
from .context import Context
from .parameters import Parameter
try:
from annotationlib import call_annotate_function, get_annotate_from_class_namespace # type: ignore
def get_annotations_from_namespace(namespace: Dict[str, Any]) -> Dict[str, Any]:
# In Python 3.14, classes no longer get `__annotations__` and instead a function
# under __annotate__ is used instead that that takes a format argument on how to
# receive those annotations.
# Format 1 is full value, Format 3 is value and ForwardRef for undefined ones
# So format 3 is the one we're typically used to
annotate = get_annotate_from_class_namespace(namespace)
if annotate is not None:
return call_annotate_function(annotate, 3) # type: ignore
return namespace.get('__annotations__', {})
except ImportError:
def get_annotations_from_namespace(namespace: Dict[str, Any]) -> Dict[str, Any]:
return namespace.get('__annotations__', {})
@dataclass
class Flag:
@ -177,7 +196,7 @@ def validate_flag_name(name: str, forbidden: Set[str]) -> None:
def get_flags(namespace: Dict[str, Any], globals: Dict[str, Any], locals: Dict[str, Any]) -> Dict[str, Flag]:
annotations = namespace.get('__annotations__', {})
annotations = get_annotations_from_namespace(namespace)
case_insensitive = namespace['__commands_flag_case_insensitive__']
flags: Dict[str, Flag] = {}
cache: Dict[str, Any] = {}

10
discord/ext/commands/help.py

@ -69,12 +69,12 @@ if TYPE_CHECKING:
class _HelpCommandOptions(TypedDict, total=False):
show_hidden: bool
verify_checks: bool
verify_checks: Optional[bool]
command_attrs: _CommandKwargs
class _BaseHelpCommandOptions(_HelpCommandOptions, total=False):
sort_commands: bool
dm_help: bool
dm_help: Optional[bool]
dm_help_threshold: int
no_category: str
paginator: Paginator
@ -394,7 +394,7 @@ class HelpCommand:
def __init__(self, **options: Unpack[_HelpCommandOptions]) -> None:
self.show_hidden: bool = options.pop('show_hidden', False)
self.verify_checks: bool = options.pop('verify_checks', True)
self.verify_checks: Optional[bool] = options.pop('verify_checks', True)
self.command_attrs = attrs = options.pop('command_attrs', {})
attrs.setdefault('name', 'help')
attrs.setdefault('help', 'Shows this message')
@ -1070,7 +1070,7 @@ class DefaultHelpCommand(HelpCommand):
self.width: int = options.pop('width', 80)
self.indent: int = options.pop('indent', 2)
self.sort_commands: bool = options.pop('sort_commands', True)
self.dm_help: bool = options.pop('dm_help', False)
self.dm_help: Optional[bool] = options.pop('dm_help', False)
self.dm_help_threshold: int = options.pop('dm_help_threshold', 1000)
self.arguments_heading: str = options.pop('arguments_heading', 'Arguments:')
self.commands_heading: str = options.pop('commands_heading', 'Commands:')
@ -1364,7 +1364,7 @@ class MinimalHelpCommand(HelpCommand):
def __init__(self, **options: Unpack[_MinimalHelpCommandOptions]) -> None:
self.sort_commands: bool = options.pop('sort_commands', True)
self.commands_heading: str = options.pop('commands_heading', 'Commands')
self.dm_help: bool = options.pop('dm_help', False)
self.dm_help: Optional[bool] = options.pop('dm_help', False)
self.dm_help_threshold: int = options.pop('dm_help_threshold', 1000)
self.aliases_heading: str = options.pop('aliases_heading', 'Aliases:')
self.no_category: str = options.pop('no_category', 'No Category')

12
discord/ext/commands/hybrid.py

@ -67,10 +67,12 @@ if TYPE_CHECKING:
default_permissions: bool
nsfw: bool
description: str
case_insensitive: bool
class _HybridGroupDecoratorKwargs(_HybridGroupKwargs, total=False):
description: Union[str, app_commands.locale_str]
fallback: Union[str, app_commands.locale_str]
fallback: Optional[str]
fallback_locale: Optional[app_commands.locale_str]
__all__ = (
@ -532,6 +534,10 @@ class HybridCommand(Command[CogT, P, T]):
HybridAppCommand(self) if self.with_app_command else None
)
@property
def __discord_app_commands_unwrap__(self) -> Optional[HybridAppCommand[CogT, Any, T]]:
return self.app_command
@property
def cog(self) -> CogT:
return self._cog
@ -700,6 +706,10 @@ class HybridGroup(Group[CogT, P, T]):
return None
return self.app_command.get_command(self.fallback) # type: ignore
@property
def __discord_app_commands_unwrap__(self) -> Optional[app_commands.Group]:
return self.app_command
@property
def cog(self) -> CogT:
return self._cog

51
discord/ext/tasks/__init__.py

@ -37,6 +37,7 @@ from typing import (
Type,
TypeVar,
Union,
overload,
)
import aiohttp
@ -45,7 +46,7 @@ import inspect
from collections.abc import Sequence
from discord.backoff import ExponentialBackoff
from discord.utils import MISSING
from discord.utils import MISSING, _iscoroutinefunction
_log = logging.getLogger(__name__)
@ -176,12 +177,12 @@ class Loop(Generic[LF]):
if self.count is not None and self.count <= 0:
raise ValueError('count must be greater than 0 or None.')
self.change_interval(seconds=seconds, minutes=minutes, hours=hours, time=time)
self.change_interval(seconds=seconds, minutes=minutes, hours=hours, time=time) # type: ignore
self._last_iteration_failed = False
self._last_iteration: datetime.datetime = MISSING
self._next_iteration = None
if not inspect.iscoroutinefunction(self.coro):
if not _iscoroutinefunction(self.coro):
raise TypeError(f'Expected coroutine function, not {type(self.coro).__name__!r}.')
async def _call_loop_function(self, name: str, *args: Any, **kwargs: Any) -> None:
@ -573,7 +574,7 @@ class Loop(Generic[LF]):
The function was not a coroutine.
"""
if not inspect.iscoroutinefunction(coro):
if not _iscoroutinefunction(coro):
raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.')
self._before_loop = coro
@ -601,7 +602,7 @@ class Loop(Generic[LF]):
The function was not a coroutine.
"""
if not inspect.iscoroutinefunction(coro):
if not _iscoroutinefunction(coro):
raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.')
self._after_loop = coro
@ -631,7 +632,7 @@ class Loop(Generic[LF]):
TypeError
The function was not a coroutine.
"""
if not inspect.iscoroutinefunction(coro):
if not _iscoroutinefunction(coro):
raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.')
self._error = coro # type: ignore
@ -710,6 +711,22 @@ class Loop(Generic[LF]):
ret = sorted(set(ret)) # de-dupe and sort times
return ret
@overload
def change_interval(
self,
*,
seconds: float = 0,
minutes: float = 0,
hours: float = 0,
) -> None: ...
@overload
def change_interval(
self,
*,
time: Union[datetime.time, Sequence[datetime.time]],
) -> None: ...
def change_interval(
self,
*,
@ -777,6 +794,28 @@ class Loop(Generic[LF]):
self._handle.recalculate(self._next_iteration)
@overload
def loop(
*,
seconds: float = 0,
minutes: float = 0,
hours: float = 0,
count: Optional[int] = None,
reconnect: bool = True,
name: Optional[str] = None,
) -> Callable[[LF], Loop[LF]]: ...
@overload
def loop(
*,
time: Union[datetime.time, Sequence[datetime.time]],
count: Optional[int] = None,
reconnect: bool = True,
name: Optional[str] = None,
) -> Callable[[LF], Loop[LF]]: ...
def loop(
*,
seconds: float = MISSING,

137
discord/gateway.py

@ -44,6 +44,11 @@ from .activity import BaseActivity
from .enums import SpeakingState
from .errors import ConnectionClosed
try:
import davey # type: ignore
except ImportError:
pass
_log = logging.getLogger(__name__)
__all__ = (
@ -205,6 +210,10 @@ class KeepAliveHandler(threading.Thread):
def tick(self) -> None:
self._last_recv = time.perf_counter()
def beat(self) -> Dict[str, Any]:
self._last_send = time.perf_counter()
return self.get_payload()
def ack(self) -> None:
ack_time = time.perf_counter()
self._last_ack = ack_time
@ -536,7 +545,7 @@ class DiscordWebSocket:
if op == self.HEARTBEAT:
if self._keep_alive:
beat = self._keep_alive.get_payload()
beat = self._keep_alive.beat()
await self.send_as_json(beat)
return
@ -645,6 +654,7 @@ class DiscordWebSocket:
self._keep_alive.stop()
self._keep_alive = None
await self.socket.close(code=4000)
if isinstance(e, asyncio.TimeoutError):
_log.debug('Timed out receiving packet. Attempting a reconnect.')
raise ReconnectWebSocket(self.shard_id) from None
@ -812,18 +822,30 @@ class DiscordVoiceWebSocket:
_max_heartbeat_timeout: float
# fmt: off
IDENTIFY = 0
SELECT_PROTOCOL = 1
READY = 2
HEARTBEAT = 3
SESSION_DESCRIPTION = 4
SPEAKING = 5
HEARTBEAT_ACK = 6
RESUME = 7
HELLO = 8
RESUMED = 9
CLIENT_CONNECT = 12
CLIENT_DISCONNECT = 13
IDENTIFY = 0
SELECT_PROTOCOL = 1
READY = 2
HEARTBEAT = 3
SESSION_DESCRIPTION = 4
SPEAKING = 5
HEARTBEAT_ACK = 6
RESUME = 7
HELLO = 8
RESUMED = 9
CLIENTS_CONNECT = 11
CLIENT_CONNECT = 12
CLIENT_DISCONNECT = 13
DAVE_PREPARE_TRANSITION = 21
DAVE_EXECUTE_TRANSITION = 22
DAVE_TRANSITION_READY = 23
DAVE_PREPARE_EPOCH = 24
MLS_EXTERNAL_SENDER = 25
MLS_KEY_PACKAGE = 26
MLS_PROPOSALS = 27
MLS_COMMIT_WELCOME = 28
MLS_ANNOUNCE_COMMIT_TRANSITION = 29
MLS_WELCOME = 30
MLS_INVALID_COMMIT_WELCOME = 31
# fmt: on
def __init__(
@ -850,6 +872,10 @@ class DiscordVoiceWebSocket:
_log.debug('Sending voice websocket frame: %s.', data)
await self.ws.send_str(utils._to_json(data))
async def send_binary(self, opcode: int, data: bytes) -> None:
_log.debug('Sending voice websocket binary frame: opcode=%s size=%d', opcode, len(data))
await self.ws.send_bytes(bytes([opcode]) + data)
send_heartbeat = send_as_json
async def resume(self) -> None:
@ -874,6 +900,7 @@ class DiscordVoiceWebSocket:
'user_id': str(state.user.id),
'session_id': state.session_id,
'token': state.token,
'max_dave_protocol_version': state.max_dave_protocol_version,
},
}
await self.send_as_json(payload)
@ -943,6 +970,16 @@ class DiscordVoiceWebSocket:
await self.send_as_json(payload)
async def send_transition_ready(self, transition_id: int):
payload = {
'op': DiscordVoiceWebSocket.DAVE_TRANSITION_READY,
'd': {
'transition_id': transition_id,
},
}
await self.send_as_json(payload)
async def received_message(self, msg: Dict[str, Any]) -> None:
_log.debug('Voice websocket frame received: %s', msg)
op = msg['op']
@ -959,13 +996,85 @@ class DiscordVoiceWebSocket:
elif op == self.SESSION_DESCRIPTION:
self._connection.mode = data['mode']
await self.load_secret_key(data)
self._connection.dave_protocol_version = data['dave_protocol_version']
if data['dave_protocol_version'] > 0:
await self._connection.reinit_dave_session()
elif op == self.HELLO:
interval = data['heartbeat_interval'] / 1000.0
self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=min(interval, 5.0))
self._keep_alive.start()
elif self._connection.dave_session:
state = self._connection
if op == self.DAVE_PREPARE_TRANSITION:
_log.debug(
'Preparing for DAVE transition id %d for protocol version %d',
data['transition_id'],
data['protocol_version'],
)
state.dave_pending_transitions[data['transition_id']] = data['protocol_version']
if data['transition_id'] == 0:
await state._execute_transition(data['transition_id'])
else:
if data['protocol_version'] == 0 and state.dave_session:
state.dave_session.set_passthrough_mode(True, 120)
await self.send_transition_ready(data['transition_id'])
elif op == self.DAVE_EXECUTE_TRANSITION:
_log.debug('Executing DAVE transition id %d', data['transition_id'])
await state._execute_transition(data['transition_id'])
elif op == self.DAVE_PREPARE_EPOCH:
_log.debug('Preparing for DAVE epoch %d', data['epoch'])
# When the epoch ID is equal to 1, this message indicates that a new MLS group is to be created for the given protocol version.
if data['epoch'] == 1:
state.dave_protocol_version = data['protocol_version']
await state.reinit_dave_session()
await self._hook(self, msg)
async def received_binary_message(self, msg: bytes) -> None:
self.seq_ack = struct.unpack_from('>H', msg, 0)[0]
op = msg[2]
_log.debug('Voice websocket binary frame received: %d bytes; seq=%s op=%s', len(msg), self.seq_ack, op)
state = self._connection
if state.dave_session is None:
return
if op == self.MLS_EXTERNAL_SENDER:
state.dave_session.set_external_sender(msg[3:])
_log.debug('Set MLS external sender')
elif op == self.MLS_PROPOSALS:
optype = msg[3]
result = state.dave_session.process_proposals(
davey.ProposalsOperationType.append if optype == 0 else davey.ProposalsOperationType.revoke, msg[4:]
)
if isinstance(result, davey.CommitWelcome):
await self.send_binary(
DiscordVoiceWebSocket.MLS_COMMIT_WELCOME,
result.commit + result.welcome if result.welcome else result.commit,
)
_log.debug('MLS proposals processed')
elif op == self.MLS_ANNOUNCE_COMMIT_TRANSITION:
transition_id = struct.unpack_from('>H', msg, 3)[0]
try:
state.dave_session.process_commit(msg[5:])
if transition_id != 0:
state.dave_pending_transitions[transition_id] = state.dave_protocol_version
await self.send_transition_ready(transition_id)
_log.debug('MLS commit processed for transition id %d', transition_id)
except Exception:
await state._recover_from_invalid_commit(transition_id)
elif op == self.MLS_WELCOME:
transition_id = struct.unpack_from('>H', msg, 3)[0]
try:
state.dave_session.process_welcome(msg[5:])
if transition_id != 0:
state.dave_pending_transitions[transition_id] = state.dave_protocol_version
await self.send_transition_ready(transition_id)
_log.debug('MLS welcome processed for transition id %d', transition_id)
except Exception:
await state._recover_from_invalid_commit(transition_id)
async def initial_connection(self, data: Dict[str, Any]) -> None:
state = self._connection
state.ssrc = data['ssrc']
@ -1045,6 +1154,8 @@ class DiscordVoiceWebSocket:
msg = await asyncio.wait_for(self.ws.receive(), timeout=30.0)
if msg.type is aiohttp.WSMsgType.TEXT:
await self.received_message(utils._from_json(msg.data))
elif msg.type is aiohttp.WSMsgType.BINARY:
await self.received_binary_message(msg.data)
elif msg.type is aiohttp.WSMsgType.ERROR:
_log.debug('Received voice %s', msg)
raise ConnectionClosed(self.ws, shard_id=None) from msg.data

62
discord/guild.py

@ -26,7 +26,6 @@ from __future__ import annotations
import copy
import datetime
import unicodedata
from typing import (
Any,
AsyncIterator,
@ -678,7 +677,7 @@ class Guild(Hashable):
scheduled_event = ScheduledEvent(data=s, state=self._state)
self._scheduled_events[scheduled_event.id] = scheduled_event
if 'soundboard_sounds' in guild:
if 'soundboard_sounds' in guild and state.cache_guild_expressions:
for s in guild['soundboard_sounds']:
soundboard_sound = SoundboardSound(guild=self, data=s, state=self._state)
self._add_soundboard_sound(soundboard_sound)
@ -3087,7 +3086,7 @@ class Guild(Hashable):
self,
*,
name: str,
description: str,
description: str = MISSING,
emoji: str,
file: File,
reason: Optional[str] = None,
@ -3103,11 +3102,16 @@ class Guild(Hashable):
Parameters
-----------
name: :class:`str`
The sticker name. Must be at least 2 characters.
The sticker name. Must be between 2 and 30 characters.
description: :class:`str`
The sticker's description.
The sticker's description. Can be an empty string or a string between 2 and 100 characters.
Defaults to an empty string if not provided.
emoji: :class:`str`
The name of a unicode emoji that represents the sticker's expression.
The emoji tag associated with the sticker. This corresponds to the
``tags`` field in Discord's API, which is used for emoji autocomplete
and suggestion purposes. For correct rendering in Discord's UI, this
should ideally be a raw Unicode emoji or the string ID
of a custom emoji. Any string up to 200 characters is accepted.
file: :class:`File`
The file of the sticker to upload.
reason: :class:`str`
@ -3127,19 +3131,10 @@ class Guild(Hashable):
"""
payload = {
'name': name,
'description': description or '',
'tags': emoji,
}
payload['description'] = description
try:
emoji = unicodedata.name(emoji)
except TypeError:
pass
else:
emoji = emoji.replace(' ', '_')
payload['tags'] = emoji
data = await self._state.http.create_guild_sticker(self.id, payload, file, reason)
if self._state.cache_guild_expressions:
return self._state.store_sticker(self, data)
@ -3872,6 +3867,39 @@ class Guild(Hashable):
return roles
async def role_member_counts(self) -> Dict[Union[Object, Role], int]:
"""|coro|
Retrieves a mapping of roles to the number of members that have it.
You must have :attr:`~Permissions.manage_roles` to do this.
.. versionadded:: 2.7
Raises
-------
Forbidden
You do not have permissions to view the role member counts.
HTTPException
Retrieving the role member counts failed.
Returns
--------
Dict[Union[:class:`Object`, :class:`Role`], :class:`int`]
A mapping of roles to the number of members that have it.
If a role is not found in the cache, it will be represented as an :class:`Object`
instead of a :class:`Role`.
"""
data = await self._state.http.get_role_member_counts(self.id)
result: Dict[Union[Object, Role], int] = {}
for role_id, member_count in data.items():
role_id = int(role_id)
role = self.get_role(role_id)
if role is None:
role = Object(id=role_id, type=Role)
result[role] = member_count
return result
async def welcome_screen(self) -> WelcomeScreen:
"""|coro|

25
discord/http.py

@ -551,11 +551,16 @@ class HTTPClient:
self.__session = MISSING
async def ws_connect(self, url: str, *, compress: int = 0) -> aiohttp.ClientWebSocketResponse:
try:
timeout: Any = aiohttp.ClientWSTimeout(ws_close=30.0) # pyright: ignore[reportCallIssue]
except (AttributeError, TypeError):
timeout = 30.0
kwargs = {
'proxy_auth': self.proxy_auth,
'proxy': self.proxy,
'max_msg_size': 0,
'timeout': 30.0,
'timeout': timeout,
'autoclose': False,
'headers': {
'User-Agent': self.user_agent,
@ -1136,18 +1141,15 @@ class HTTPClient:
def edit_profile(self, payload: Dict[str, Any]) -> Response[user.User]:
return self.request(Route('PATCH', '/users/@me'), json=payload)
def change_my_nickname(
def edit_my_member(
self,
guild_id: Snowflake,
nickname: str,
*,
reason: Optional[str] = None,
) -> Response[member.Nickname]:
r = Route('PATCH', '/guilds/{guild_id}/members/@me/nick', guild_id=guild_id)
payload = {
'nick': nickname,
}
return self.request(r, json=payload, reason=reason)
**fields: Any,
) -> Response[member.MemberWithUser]:
r = Route('PATCH', '/guilds/{guild_id}/members/@me', guild_id=guild_id)
return self.request(r, json=fields, reason=reason)
def change_nickname(
self,
@ -1348,7 +1350,7 @@ class HTTPClient:
return self.request(r, json=params.payload, params=query, reason=reason)
def join_thread(self, channel_id: Snowflake) -> Response[None]:
return self.request(Route('POST', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id))
return self.request(Route('PUT', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id))
def add_user_to_thread(self, channel_id: Snowflake, user_id: Snowflake) -> Response[None]:
return self.request(
@ -1909,6 +1911,9 @@ class HTTPClient:
def get_role(self, guild_id: Snowflake, role_id: Snowflake) -> Response[role.Role]:
return self.request(Route('GET', '/guilds/{guild_id}/roles/{role_id}', guild_id=guild_id, role_id=role_id))
def get_role_member_counts(self, guild_id: Snowflake) -> Response[Dict[str, int]]:
return self.request(Route('GET', '/guilds/{guild_id}/roles/member-counts', guild_id=guild_id))
def edit_role(
self, guild_id: Snowflake, role_id: Snowflake, *, reason: Optional[str] = None, **fields: Any
) -> Response[role.Role]:

16
discord/integrations.py

@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
import datetime
from typing import Any, Dict, Optional, TYPE_CHECKING, Type, Tuple
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Type, Tuple
from .utils import _get_as_snowflake, parse_time, MISSING
from .user import User
from .enums import try_enum, ExpireBehaviour
@ -98,6 +98,10 @@ class Integration:
The account linked to this integration.
user: :class:`User`
The user that added this integration.
scopes: List[:class:`str`]
The OAuth2 scopes the application has been authorized for.
.. versionadded:: 2.7
"""
__slots__ = (
@ -109,6 +113,7 @@ class Integration:
'account',
'user',
'enabled',
'scopes',
)
def __init__(self, *, data: IntegrationPayload, guild: Guild) -> None:
@ -128,6 +133,7 @@ class Integration:
user = data.get('user')
self.user: Optional[User] = User(state=self._state, data=user) if user else None
self.enabled: bool = data['enabled']
self.scopes: List[str] = data.get('scopes', [])
async def delete(self, *, reason: Optional[str] = None) -> None:
"""|coro|
@ -184,6 +190,10 @@ class StreamIntegration(Integration):
The integration account information.
synced_at: :class:`datetime.datetime`
An aware UTC datetime representing when the integration was last synced.
scopes: List[:class:`str`]
The OAuth2 scopes the application has been authorized for.
.. versionadded:: 2.7
"""
__slots__ = (
@ -352,6 +362,10 @@ class BotIntegration(Integration):
The integration account information.
application: :class:`IntegrationApplication`
The application tied to this integration.
scopes: List[:class:`str`]
The OAuth2 scopes the application has been authorized for.
.. versionadded:: 2.7
"""
__slots__ = ('application',)

83
discord/interactions.py

@ -65,6 +65,8 @@ if TYPE_CHECKING:
ApplicationCommandInteractionData,
InteractionCallback as InteractionCallbackPayload,
InteractionCallbackActivity as InteractionCallbackActivityPayload,
MessageComponentInteractionData,
ModalSubmitInteractionData,
)
from .types.webhook import (
Webhook as WebhookPayload,
@ -191,6 +193,8 @@ class Interaction(Generic[ClientT]):
'channel',
'_cs_namespace',
'_cs_command',
'_cs_command_id',
'_cs_custom_id',
)
def __init__(self, *, data: InteractionPayload, state: ConnectionState[ClientT]):
@ -376,6 +380,21 @@ class Interaction(Generic[ClientT]):
else:
return tree._get_context_menu(data)
@utils.cached_slot_property('_cs_command_id')
def command_id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of the command that triggered this interaction.
Only applicable if :attr:`type` is one of, :attr:`InteractionType.application_command` or
:attr:`InteractionType.autocomplete`.
.. versionadded:: 2.7
"""
if self.type not in (InteractionType.application_command, InteractionType.autocomplete):
return None
data: ApplicationCommandInteractionData = self.data # type: ignore
return int(data.get('id', 0))
@utils.cached_slot_property('_cs_response')
def response(self) -> InteractionResponse[ClientT]:
""":class:`InteractionResponse`: Returns an object responsible for handling responding to the interaction.
@ -405,6 +424,21 @@ class Interaction(Generic[ClientT]):
""":class:`datetime.datetime`: When the interaction expires."""
return self.created_at + datetime.timedelta(minutes=15)
@utils.cached_slot_property('_cs_custom_id')
def custom_id(self) -> Optional[str]:
"""Optional[:class:`str`]: The custom ID of the component that triggered this interaction.
Only applicable if :attr:`type` is one of, :attr:`InteractionType.component` or
:attr:`InteractionType.modal_submit`.
.. versionadded:: 2.7
"""
if self.type not in (InteractionType.component, InteractionType.modal_submit):
return None
data: Union[MessageComponentInteractionData, ModalSubmitInteractionData] = self.data # type: ignore
return data.get('custom_id')
def is_expired(self) -> bool:
""":class:`bool`: Returns ``True`` if the interaction is expired."""
return utils.utcnow() >= self.expires_at
@ -581,7 +615,7 @@ class Interaction(Generic[ClientT]):
state = _InteractionMessageState(self, self._state)
message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore
if view and not view.is_finished() and view.is_dispatchable():
self._state.store_view(view, message.id, interaction_id=self.id)
self._state.store_view(view, message.id)
return message
async def delete_original_response(self) -> None:
@ -1048,7 +1082,7 @@ class InteractionResponse(Generic[ClientT]):
)
http = parent._state.http
response = await adapter.create_interaction_response(
data = await adapter.create_interaction_response(
parent.id,
parent.token,
session=parent._session,
@ -1056,17 +1090,19 @@ class InteractionResponse(Generic[ClientT]):
proxy_auth=http.proxy_auth,
params=params,
)
self._response_type = InteractionResponseType.channel_message
response = InteractionCallbackResponse(
data=data,
parent=self._parent,
state=self._parent._state,
type=self._response_type,
)
if view is not MISSING and not view.is_finished():
if ephemeral and view.timeout is None:
view.timeout = 15 * 60.0
# If the interaction type isn't an application command then there's no way
# to obtain this interaction_id again, so just default to None
entity_id = parent.id if parent.type is InteractionType.application_command else None
self._parent._state.store_view(view, entity_id)
self._response_type = InteractionResponseType.channel_message
self._parent._state.store_view(view, response.message_id)
if delete_after is not None:
@ -1079,12 +1115,7 @@ class InteractionResponse(Generic[ClientT]):
asyncio.create_task(inner_call())
return InteractionCallbackResponse(
data=response,
parent=self._parent,
state=self._parent._state,
type=self._response_type,
)
return response
async def edit_message(
self,
@ -1171,12 +1202,8 @@ class InteractionResponse(Generic[ClientT]):
state = parent._state
if msg is not None:
message_id = msg.id
# If this was invoked via an application command then we can use its original interaction ID
# Since this is used as a cache key for view updates
original_interaction_id = msg.interaction_metadata.id if msg.interaction_metadata is not None else None
else:
message_id = None
original_interaction_id = None
if parent.type not in (InteractionType.component, InteractionType.modal_submit):
return
@ -1204,7 +1231,7 @@ class InteractionResponse(Generic[ClientT]):
)
http = parent._state.http
response = await adapter.create_interaction_response(
data = await adapter.create_interaction_response(
parent.id,
parent.token,
session=parent._session,
@ -1212,11 +1239,16 @@ class InteractionResponse(Generic[ClientT]):
proxy_auth=http.proxy_auth,
params=params,
)
self._response_type = InteractionResponseType.message_update
response = InteractionCallbackResponse(
data=data,
parent=self._parent,
state=self._parent._state,
type=self._response_type,
)
if view and not view.is_finished() and view.is_dispatchable():
state.store_view(view, message_id, interaction_id=original_interaction_id)
self._response_type = InteractionResponseType.message_update
state.store_view(view, message_id or response.message_id)
if delete_after is not None:
@ -1229,12 +1261,7 @@ class InteractionResponse(Generic[ClientT]):
asyncio.create_task(inner_call())
return InteractionCallbackResponse(
data=response,
parent=self._parent,
state=self._parent._state,
type=self._response_type,
)
return response
async def send_modal(self, modal: Modal, /) -> InteractionCallbackResponse[ClientT]:
"""|coro|

65
discord/member.py

@ -25,7 +25,6 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
import datetime
import inspect
import itertools
from operator import attrgetter
from typing import Any, Awaitable, Callable, Collection, Dict, List, Optional, TYPE_CHECKING, Tuple, TypeVar, Union
@ -190,7 +189,7 @@ def flatten_user(cls: T) -> T:
# probably a member function by now
def generate_function(x):
# We want sphinx to properly show coroutine functions as coroutines
if inspect.iscoroutinefunction(value):
if utils._iscoroutinefunction(value):
async def general(self, *args, **kwargs): # type: ignore
return await getattr(self._user, x)(*args, **kwargs)
@ -815,12 +814,22 @@ class Member(discord.abc.Messageable, _UserTag):
voice_channel: Optional[VocalGuildChannel] = MISSING,
timed_out_until: Optional[datetime.datetime] = MISSING,
bypass_verification: bool = MISSING,
avatar: Optional[bytes] = MISSING,
banner: Optional[bytes] = MISSING,
bio: Optional[str] = MISSING,
reason: Optional[str] = None,
) -> Optional[Member]:
"""|coro|
Edits the member's data.
.. note::
To upload an avatar or banner, a :term:`py:bytes-like object` must be passed in that
represents the image being uploaded. If this is done through a file
then the file must be opened via ``open('some_filename', 'rb')`` and
the :term:`py:bytes-like object` is given through the use of ``fp.read()``.
Depending on the parameter passed, this requires different permissions listed below:
+---------------------+---------------------------------------+
@ -876,6 +885,23 @@ class Member(discord.abc.Messageable, _UserTag):
Indicates if the member should be allowed to bypass the guild verification requirements.
.. versionadded:: 2.2
avatar: Optional[:class:`bytes`]
A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no avatar.
Only image formats supported for uploading are JPEG, PNG, GIF, and WEBP.
This can only be set when editing the bot's own member.
.. versionadded:: 2.7
banner: Optional[:class:`bytes`]
A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no banner.
Only image formats supported for uploading are JPEG, PNG, GIF and WEBP..
This can only be set when editing the bot's own member.
.. versionadded:: 2.7
bio: Optional[:class:`str`]
The new bio for the member. Use ``None`` to remove the bio.
This can only be set when editing the bot's own member.
.. versionadded:: 2.7
reason: Optional[:class:`str`]
The reason for editing this member. Shows up on the audit log.
@ -888,6 +914,9 @@ class Member(discord.abc.Messageable, _UserTag):
The operation failed.
TypeError
The datetime object passed to ``timed_out_until`` was not timezone-aware.
ValueError
You tried to edit the bio, avatar or banner of a member that is not the bot's own member.
Or the wrong image format passed for ``avatar`` or ``banner``.
Returns
--------
@ -899,14 +928,33 @@ class Member(discord.abc.Messageable, _UserTag):
guild_id = self.guild.id
me = self._state.self_id == self.id
payload: Dict[str, Any] = {}
self_payload: Dict[str, Any] = {}
if nick is not MISSING:
nick = nick or ''
if me:
await http.change_my_nickname(guild_id, nick, reason=reason)
self_payload['nick'] = nick
else:
payload['nick'] = nick
if avatar is not MISSING:
if avatar is None:
self_payload['avatar'] = None
else:
self_payload['avatar'] = utils._bytes_to_base64_data(avatar)
if banner is not MISSING:
if banner is None:
self_payload['banner'] = None
else:
self_payload['banner'] = utils._bytes_to_base64_data(banner)
if bio is not MISSING:
self_payload['bio'] = bio or ''
if not me and self_payload:
raise ValueError("Editing the bio, avatar or banner is only for the bot's own member.")
if deafen is not MISSING:
payload['deaf'] = deafen
@ -928,7 +976,7 @@ class Member(discord.abc.Messageable, _UserTag):
await http.edit_my_voice_state(guild_id, voice_state_payload)
else:
if not suppress:
voice_state_payload['request_to_speak_timestamp'] = datetime.datetime.utcnow().isoformat()
voice_state_payload['request_to_speak_timestamp'] = utils.utcnow().isoformat()
await http.edit_voice_state(guild_id, self.id, voice_state_payload)
if voice_channel is not MISSING:
@ -954,7 +1002,12 @@ class Member(discord.abc.Messageable, _UserTag):
if payload:
data = await http.edit_member(guild_id, self.id, reason=reason, **payload)
return Member(data=data, guild=self.guild, state=self._state)
elif self_payload:
data = await http.edit_my_member(guild_id, reason=reason, **self_payload)
else:
return None
return Member(data=data, guild=self.guild, state=self._state)
async def request_to_speak(self) -> None:
"""|coro|
@ -984,7 +1037,7 @@ class Member(discord.abc.Messageable, _UserTag):
payload = {
'channel_id': self.voice.channel.id,
'request_to_speak_timestamp': datetime.datetime.utcnow().isoformat(),
'request_to_speak_timestamp': utils.utcnow().isoformat(),
}
if self._state.self_id != self.id:

43
discord/message.py

@ -1415,11 +1415,7 @@ class PartialMessage(Hashable):
message = Message(state=self._state, channel=self.channel, data=data)
if view and not view.is_finished() and view.is_dispatchable():
interaction: Optional[MessageInteractionMetadata] = getattr(self, 'interaction_metadata', None)
if interaction is not None:
self._state.store_view(view, self.id, interaction_id=interaction.id)
else:
self._state.store_view(view, self.id)
self._state.store_view(view, self.id)
if delete_after is not None:
await self.delete(delay=delete_after)
@ -1453,7 +1449,7 @@ class PartialMessage(Hashable):
Pins the message.
You must have :attr:`~Permissions.manage_messages` to do
You must have :attr:`~Permissions.pin_messages` to do
this in a non-private channel context.
Parameters
@ -1471,7 +1467,7 @@ class PartialMessage(Hashable):
The message or channel was not found or deleted.
HTTPException
Pinning the message failed, probably due to the channel
having more than 50 pinned messages.
having more than 250 pinned messages.
"""
await self._state.http.pin_message(self.channel.id, self.id, reason=reason)
@ -1483,7 +1479,7 @@ class PartialMessage(Hashable):
Unpins the message.
You must have :attr:`~Permissions.manage_messages` to do
You must have :attr:`~Permissions.pin_messages` to do
this in a non-private channel context.
Parameters
@ -2221,6 +2217,7 @@ class Message(PartialMessage, Hashable):
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.message_snapshots: List[MessageSnapshot] = MessageSnapshot._from_value(state, data.get('message_snapshots'))
self.call: Optional[CallMessage] = None
# Set by Messageable.pins
self._pinned_at: Optional[datetime.datetime] = None
@ -2513,11 +2510,8 @@ class Message(PartialMessage, Hashable):
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(
self,
@ -3053,3 +3047,30 @@ class Message(PartialMessage, Hashable):
The newly edited message.
"""
return await self.edit(attachments=[a for a in self.attachments if a not in attachments])
def is_forwardable(self) -> bool:
""":class:`bool`: Whether the message can be forwarded using :meth:`Message.forward`.
A message is forwardable only if it is a basic message type and does not
contain a poll, call, or activity, and is not a system message.
.. versionadded:: 2.7
"""
if self.type not in (
MessageType.default,
MessageType.reply,
MessageType.chat_input_command,
MessageType.context_menu_command,
):
return False
if self.poll is not None:
return False
if self.call is not None:
return False
if self.activity is not None:
return False
return True

16
discord/partial_emoji.py

@ -39,6 +39,7 @@ __all__ = (
if TYPE_CHECKING:
from typing_extensions import Self
from .client import Client
from .state import ConnectionState
from datetime import datetime
from .types.emoji import Emoji as EmojiPayload, PartialEmoji as PartialEmojiPayload
@ -114,7 +115,7 @@ class PartialEmoji(_EmojiTag, AssetMixin):
)
@classmethod
def from_str(cls, value: str) -> Self:
def from_str(cls, value: str, *, client: Client = utils.MISSING) -> Self:
"""Converts a Discord string representation of an emoji to a :class:`PartialEmoji`.
The formats accepted are:
@ -132,6 +133,11 @@ class PartialEmoji(_EmojiTag, AssetMixin):
------------
value: :class:`str`
The string representation of an emoji.
client: :class:`Client`
The client to initialise this emoji with. This allows it to
attach the client's internal state.
.. versionadded:: 2.7
Returns
--------
@ -144,8 +150,12 @@ class PartialEmoji(_EmojiTag, AssetMixin):
animated = bool(groups['animated'])
emoji_id = int(groups['id'])
name = groups['name']
if client is not utils.MISSING:
return cls.with_state(name=name, animated=animated, id=emoji_id, state=client._connection)
return cls(name=name, animated=animated, id=emoji_id)
if client is not utils.MISSING:
return cls.with_state(name=value, animated=False, id=None, state=client._connection)
return cls(name=value, id=None, animated=False)
def to_dict(self) -> EmojiPayload:
@ -245,8 +255,8 @@ class PartialEmoji(_EmojiTag, AssetMixin):
if self.is_unicode_emoji():
return ''
fmt = 'gif' if self.animated else 'png'
return f'{Asset.BASE}/emojis/{self.id}.{fmt}'
end = 'webp?animated=true' if self.animated else 'png'
return f'{Asset.BASE}/emojis/{self.id}.{end}'
async def read(self) -> bytes:
"""|coro|

24
discord/permissions.py

@ -95,6 +95,7 @@ if TYPE_CHECKING:
create_polls: BoolOrNoneT
use_external_apps: BoolOrNoneT
pin_messages: BoolOrNoneT
bypass_slowmode: BoolOrNoneT
class _PermissionsKwargs(_BasePermissionsKwargs[bool]): ...
@ -253,7 +254,7 @@ class Permissions(BaseFlags):
permissions set to ``True``.
"""
# Some of these are 0 because we don't want to set unnecessary bits
return cls(0b0000_0000_0000_1111_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111)
return cls(0b0000_0000_0001_1111_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111)
@classmethod
def _timeout_mask(cls) -> int:
@ -273,6 +274,7 @@ class Permissions(BaseFlags):
base.create_public_threads = False
base.manage_threads = False
base.send_messages_in_threads = False
base.bypass_slowmode = False
return base
@classmethod
@ -326,8 +328,11 @@ class Permissions(BaseFlags):
.. versionchanged:: 2.4
Added :attr:`send_polls`, :attr:`send_voice_messages`, attr:`use_external_sounds`,
:attr:`use_embedded_activities`, and :attr:`use_external_apps` permissions.
.. versionchanged:: 2.7
Added :attr:`pin_messages` and :attr:`bypass_slowmode` permissions.
"""
return cls(0b0000_0000_0000_1110_0110_0100_1111_1101_1011_0011_1111_0111_1111_1111_0101_0001)
return cls(0b0000_0000_0001_1110_0110_0100_1111_1101_1011_0011_1111_0111_1111_1111_0101_0001)
@classmethod
def general(cls) -> Self:
@ -377,9 +382,9 @@ class Permissions(BaseFlags):
Added :attr:`send_polls` and :attr:`use_external_apps` permissions.
.. versionchanged:: 2.7
Added :attr:`pin_messages` permission.
Added :attr:`pin_messages` and :attr:`bypass_slowmode` permissions.
"""
return cls(0b0000_0000_0000_1110_0100_0000_0111_1100_1000_0000_0000_0111_1111_1000_0100_0000)
return cls(0b0000_0000_0001_1110_0100_0000_0111_1100_1000_0000_0000_0111_1111_1000_0100_0000)
@classmethod
def voice(cls) -> Self:
@ -577,7 +582,7 @@ class Permissions(BaseFlags):
@flag_value
def manage_messages(self) -> int:
""":class:`bool`: Returns ``True`` if a user can delete messages or bypass slowmode in a text channel.
""":class:`bool`: Returns ``True`` if a user can delete messages in a text channel.
.. note::
@ -884,6 +889,14 @@ class Permissions(BaseFlags):
"""
return 1 << 51
@flag_value
def bypass_slowmode(self) -> int:
""":class:`bool`: Returns ``True`` if a user can bypass slowmode.
.. versionadded:: 2.7
"""
return 1 << 52
def _augment_from_permissions(cls):
cls.VALID_NAMES = set(Permissions.VALID_FLAGS)
@ -1009,6 +1022,7 @@ class PermissionOverwrite:
create_polls: Optional[bool]
use_external_apps: Optional[bool]
pin_messages: Optional[bool]
bypass_slowmode: Optional[bool]
def __init__(self, **kwargs: Unpack[_PermissionOverwriteKwargs]) -> None:
self._values: Dict[str, Optional[bool]] = {}

79
discord/player.py

@ -40,7 +40,7 @@ import io
from typing import Any, Callable, Generic, IO, Optional, TYPE_CHECKING, Tuple, TypeVar, Union
from .enums import SpeakingState
from .errors import ClientException
from .errors import ClientException, FFmpegProcessError
from .opus import Encoder as OpusEncoder, OPUS_SILENCE
from .oggparse import OggStream
from .utils import MISSING
@ -186,6 +186,8 @@ class FFmpegAudio(AudioSource):
self._stderr: Optional[IO[bytes]] = None
self._pipe_writer_thread: Optional[threading.Thread] = None
self._pipe_reader_thread: Optional[threading.Thread] = None
self._current_error: Optional[Exception] = None
self._stopped: bool = False
if piping_stdin:
n = f'popen-stdin-writer:pid-{self._process.pid}'
@ -212,25 +214,72 @@ class FFmpegAudio(AudioSource):
else:
return process
def _check_process_returncode(self) -> None:
"""Set _current_error if FFmpeg exited with a non-zero code."""
if self._process is MISSING:
return
ret = self._process.poll()
if ret is None:
return # still running
if self._stopped:
return # intentionally stopped
if ret != 0 and self._current_error is None:
# Only set error once, on first detection
# read stderr if available
stderr_text = None
if self._stderr:
try:
stderr_text = self._stderr.read(8192).decode(errors='ignore')
except Exception:
stderr_text = '<failed to read stderr>'
stderr_info = stderr_text if stderr_text else '<no stderr>'
self._current_error = FFmpegProcessError(f'FFmpeg exited with code {ret}. Stderr: {stderr_info}')
def _kill_process(self) -> None:
# check if FFmpeg process failed
self._check_process_returncode()
# this function gets called in __del__ so instance attributes might not even exist
proc = getattr(self, '_process', MISSING)
# Only proceed if proc is a subprocess.Popen instance
if proc is MISSING:
return
_log.debug('Preparing to terminate ffmpeg process %s.', proc.pid)
pid = getattr(proc, 'pid', 'unknown')
_log.debug('Preparing to terminate ffmpeg process %s.', pid)
try:
proc.kill()
except Exception:
_log.exception('Ignoring error attempting to kill ffmpeg process %s', proc.pid)
_log.exception('Ignoring error attempting to kill ffmpeg process %s', pid)
try:
still_running = proc.poll() is None
except Exception:
_log.exception('Error checking poll() on ffmpeg process %s', pid)
still_running = False
if proc.poll() is None:
_log.info('ffmpeg process %s has not terminated. Waiting to terminate...', proc.pid)
proc.communicate()
_log.info('ffmpeg process %s should have terminated with a return code of %s.', proc.pid, proc.returncode)
if still_running:
_log.info('ffmpeg process %s has not terminated. Waiting to terminate...', pid)
try:
proc.communicate()
except Exception:
pass
_log.info(
'ffmpeg process %s should have terminated with a return code of %s.',
pid,
getattr(proc, 'returncode', 'unknown'),
)
else:
_log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode)
_log.info(
'ffmpeg process %s successfully terminated with return code of %s.',
pid,
getattr(proc, 'returncode', 'unknown'),
)
def _pipe_writer(self, source: io.BufferedIOBase) -> None:
while self._process:
@ -267,6 +316,7 @@ class FFmpegAudio(AudioSource):
return
def cleanup(self) -> None:
self._stopped = True
self._kill_process()
self._process = self._stdout = self._stdin = self._stderr = MISSING
@ -348,6 +398,8 @@ class FFmpegPCMAudio(FFmpegAudio):
def read(self) -> bytes:
ret = self._stdout.read(OpusEncoder.FRAME_SIZE)
if len(ret) != OpusEncoder.FRAME_SIZE:
# Check for FFmpeg process failure when read returns incomplete data
self._check_process_returncode()
return b''
return ret
@ -646,7 +698,11 @@ class FFmpegOpusAudio(FFmpegAudio):
return codec, bitrate
def read(self) -> bytes:
return next(self._packet_iter, b'')
data = next(self._packet_iter, b'')
if not data:
# Check for FFmpeg process failure when read returns empty
self._check_process_returncode()
return data
def is_opus(self) -> bool:
return True
@ -745,6 +801,11 @@ class AudioPlayer(threading.Thread):
data = self.source.read()
if not data:
# Check if the source has an error (e.g., from FFmpegAudio process failure)
if self._current_error is None:
source_error = getattr(self.source, '_current_error', None)
if source_error:
self._current_error = source_error
self.stop()
break

5
discord/shard.py

@ -41,6 +41,7 @@ from .errors import (
ConnectionClosed,
PrivilegedIntentsRequired,
)
from .utils import MISSING
from .enums import Status
@ -389,6 +390,7 @@ class AutoShardedClient(Client):
self.__shards = {}
self._connection._get_websocket = self._get_websocket
self._connection._get_client = lambda: self
self.__queue: asyncio.PriorityQueue = MISSING
def _get_websocket(self, guild_id: Optional[int] = None, *, shard_id: Optional[int] = None) -> DiscordWebSocket:
if shard_id is None:
@ -554,7 +556,8 @@ class AutoShardedClient(Client):
await asyncio.wait(to_close)
await self.http.close()
self.__queue.put_nowait(EventItem(EventType.clean_close, None, None))
if self.__queue is not MISSING:
self.__queue.put_nowait(EventItem(EventType.clean_close, None, None))
self._closing_task = asyncio.create_task(_close())
await self._closing_task

9
discord/state.py

@ -279,7 +279,7 @@ class ConnectionState(Generic[ClientT]):
# So this is checked instead, it's a small penalty to pay
@property
def cache_guild_expressions(self) -> bool:
return self._intents.emojis_and_stickers
return self._intents.expressions
async def close(self) -> None:
for voice in self.voice_clients:
@ -412,9 +412,7 @@ class ConnectionState(Generic[ClientT]):
self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data)
return sticker
def store_view(self, view: BaseView, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None:
if interaction_id is not None:
self._view_store.remove_interaction_mapping(interaction_id)
def store_view(self, view: BaseView, message_id: Optional[int] = None) -> None:
self._view_store.add_view(view, message_id)
def prevent_view_updates_for(self, message_id: int) -> Optional[BaseView]:
@ -828,7 +826,8 @@ class ConnectionState(Generic[ClientT]):
inner_data = data['data']
custom_id = inner_data['custom_id']
components = inner_data['components']
self._view_store.dispatch_modal(custom_id, interaction, components)
resolved = inner_data.get('resolved', {})
self._view_store.dispatch_modal(custom_id, interaction, components, resolved)
self.dispatch('interaction', interaction)
def parse_presence_update(self, data: gw.PresenceUpdateEvent) -> None:

3
discord/threads.py

@ -103,8 +103,7 @@ class Thread(Messageable, Hashable):
slowmode_delay: :class:`int`
The number of seconds a member must wait between sending messages
in this thread. A value of ``0`` denotes that it is disabled.
Bots and users with :attr:`~Permissions.manage_channels` or
:attr:`~Permissions.manage_messages` bypass slowmode.
Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode.
message_count: :class:`int`
An approximate number of messages in this thread.
member_count: :class:`int`

70
discord/types/components.py

@ -30,7 +30,7 @@ from typing_extensions import NotRequired
from .emoji import PartialEmoji
from .channel import ChannelType
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18]
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18, 19, 21, 22, 23]
ButtonStyle = Literal[1, 2, 3, 4, 5, 6]
TextStyle = Literal[1, 2]
DefaultValueType = Literal['user', 'role', 'channel']
@ -43,6 +43,13 @@ class ComponentBase(TypedDict):
type: int
class OptionBase(TypedDict):
label: str
value: str
default: NotRequired[bool]
description: NotRequired[str]
class ActionRow(ComponentBase):
type: Literal[1]
components: List[ActionRowChildComponent]
@ -59,11 +66,7 @@ class ButtonComponent(ComponentBase):
sku_id: NotRequired[str]
class SelectOption(TypedDict):
label: str
value: str
default: bool
description: NotRequired[str]
class SelectOption(OptionBase):
emoji: NotRequired[PartialEmoji]
@ -192,7 +195,43 @@ class LabelComponent(ComponentBase):
type: Literal[18]
label: str
description: NotRequired[str]
component: Union[StringSelectComponent, TextInput]
component: LabelChildComponent
class FileUploadComponent(ComponentBase):
type: Literal[19]
custom_id: str
max_values: NotRequired[int]
min_values: NotRequired[int]
required: NotRequired[bool]
class RadioGroupComponent(ComponentBase):
type: Literal[21]
custom_id: str
options: NotRequired[List[RadioGroupOption]]
required: NotRequired[bool]
RadioGroupOption = OptionBase
class CheckboxGroupComponent(ComponentBase):
type: Literal[22]
custom_id: str
options: NotRequired[List[CheckboxGroupOption]]
max_values: NotRequired[int]
min_values: NotRequired[int]
required: NotRequired[bool]
CheckboxGroupOption = OptionBase
class CheckboxComponent(ComponentBase):
type: Literal[23]
custom_id: str
default: NotRequired[bool]
ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput]
@ -203,8 +242,21 @@ ContainerChildComponent = Union[
FileComponent,
SectionComponent,
SectionComponent,
ContainerComponent,
SeparatorComponent,
ThumbnailComponent,
]
Component = Union[ActionRowChildComponent, LabelComponent, ContainerChildComponent]
LabelChildComponent = Union[
TextInput,
SelectMenu,
FileUploadComponent,
RadioGroupComponent,
CheckboxGroupComponent,
CheckboxComponent,
]
Component = Union[
ActionRowChildComponent,
LabelComponent,
LabelChildComponent,
ContainerChildComponent,
ContainerComponent,
]

76
discord/types/interactions.py

@ -27,7 +27,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict, Union, Optional
from typing_extensions import NotRequired
from .channel import ChannelTypeWithoutThread, GuildChannel, InteractionDMChannel, GroupDMChannel
from .channel import (
ChannelTypeWithoutThread,
GuildChannel,
InteractionDMChannel,
GroupDMChannel,
)
from .sku import Entitlement
from .threads import ThreadType, ThreadMetadata
from .member import Member
@ -36,6 +41,7 @@ from .role import Role
from .snowflake import Snowflake
from .user import User
from .guild import GuildFeature
from .components import ComponentBase
if TYPE_CHECKING:
from .message import Message
@ -204,39 +210,81 @@ class SelectMessageComponentInteractionData(_BaseMessageComponentInteractionData
MessageComponentInteractionData = Union[ButtonMessageComponentInteractionData, SelectMessageComponentInteractionData]
class ModalSubmitTextInputInteractionData(TypedDict):
class ModalSubmitTextInputInteractionData(ComponentBase):
type: Literal[4]
custom_id: str
value: str
class ModalSubmitStringSelectInteractionData(TypedDict):
type: Literal[3]
class ModalSubmitSelectInteractionData(ComponentBase):
type: Literal[3, 5, 6, 7, 8]
custom_id: str
values: List[str]
class ModalSubmitFileUploadInteractionData(ComponentBase):
type: Literal[19]
custom_id: str
values: List[str]
class ModalSubmitRadioGroupInteractionData(ComponentBase):
type: Literal[21]
custom_id: str
id: int
value: Optional[str]
class ModalSubmitCheckboxGroupInteractionData(ComponentBase):
type: Literal[22]
custom_id: str
id: int
values: List[str]
ModalSubmitComponentItemInteractionData = Union[ModalSubmitTextInputInteractionData, ModalSubmitStringSelectInteractionData]
class ModalSubmitCheckboxInteractionData(ComponentBase):
type: Literal[23]
custom_id: str
id: int
value: bool
ModalSubmitLabelComponentItemInteractionData = Union[
ModalSubmitSelectInteractionData,
ModalSubmitTextInputInteractionData,
ModalSubmitFileUploadInteractionData,
ModalSubmitRadioGroupInteractionData,
ModalSubmitCheckboxGroupInteractionData,
ModalSubmitCheckboxInteractionData,
]
class ModalSubmitActionRowInteractionData(TypedDict):
type: Literal[1]
components: List[ModalSubmitComponentItemInteractionData]
components: List[ModalSubmitTextInputInteractionData]
class ModalSubmitLabelInteractionData(TypedDict):
class ModalSubmitTextDisplayInteractionData(ComponentBase):
type: Literal[10]
content: str
class ModalSubmitLabelInteractionData(ComponentBase):
type: Literal[18]
component: ModalSubmitComponentItemInteractionData
component: ModalSubmitLabelComponentItemInteractionData
ModalSubmitComponentInteractionData = Union[
ModalSubmitLabelInteractionData, ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData
ModalSubmitActionRowInteractionData,
ModalSubmitTextDisplayInteractionData,
ModalSubmitLabelInteractionData,
]
class ModalSubmitInteractionData(TypedDict):
custom_id: str
components: List[ModalSubmitComponentInteractionData]
resolved: NotRequired[ResolvedData]
InteractionData = Union[
@ -284,7 +332,12 @@ class ModalSubmitInteraction(_BaseInteraction):
data: ModalSubmitInteractionData
Interaction = Union[PingInteraction, ApplicationCommandInteraction, MessageComponentInteraction, ModalSubmitInteraction]
Interaction = Union[
PingInteraction,
ApplicationCommandInteraction,
MessageComponentInteraction,
ModalSubmitInteraction,
]
class MessageInteraction(TypedDict):
@ -332,7 +385,8 @@ class MessageComponentMessageInteractionMetadata(_MessageInteractionMetadata):
class ModalSubmitMessageInteractionMetadata(_MessageInteractionMetadata):
type: Literal[5]
triggering_interaction_metadata: Union[
ApplicationCommandMessageInteractionMetadata, MessageComponentMessageInteractionMetadata
ApplicationCommandMessageInteractionMetadata,
MessageComponentMessageInteractionMetadata,
]

3
discord/ui/__init__.py

@ -25,3 +25,6 @@ from .text_display import *
from .thumbnail import *
from .action_row import *
from .label import *
from .file_upload import *
from .radio import *
from .checkbox import *

45
discord/ui/action_row.py

@ -24,12 +24,12 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
import copy
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Coroutine,
Dict,
Generator,
List,
@ -42,7 +42,7 @@ from typing import (
overload,
)
from .item import Item, ContainedItemCallbackType as ItemCallbackType
from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback
from .button import Button, button as _button
from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect
from ..components import ActionRow as ActionRowComponent
@ -65,7 +65,6 @@ if TYPE_CHECKING:
)
from ..emoji import Emoji
from ..components import SelectOption
from ..interactions import Interaction
from .container import Container
from .dynamic import DynamicItem
@ -77,18 +76,6 @@ V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('ActionRow',)
class _ActionRowCallback:
__slots__ = ('row', 'callback', 'item')
def __init__(self, callback: ItemCallbackType[S, Any], row: ActionRow, item: Item[Any]) -> None:
self.callback: ItemCallbackType[Any, Any] = callback
self.row: ActionRow = row
self.item: Item[Any] = item
def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]:
return self.callback(self.row, interaction, self.item)
class ActionRow(Item[V]):
r"""Represents a UI action row.
@ -143,8 +130,9 @@ class ActionRow(Item[V]):
) -> None:
super().__init__()
self._children: List[Item[V]] = self._init_children()
self._children.extend(children)
self._weight: int = sum(i.width for i in self._children)
for child in children:
self.add_item(child)
if self._weight > 5:
raise ValueError('maximum number of children exceeded')
@ -173,8 +161,8 @@ class ActionRow(Item[V]):
for func in self.__action_row_children_items__:
item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__)
item.callback = _ActionRowCallback(func, self, item) # type: ignore
item._parent = getattr(func, '__discord_ui_parent__', self)
item.callback = _ItemCallback(func, self, item) # type: ignore
item._parent = self
setattr(self, func.__name__, item)
children.append(item)
return children
@ -184,6 +172,23 @@ class ActionRow(Item[V]):
for child in self._children:
child._view = view
def copy(self) -> ActionRow[V]:
new = copy.copy(self)
children = []
for child in new._children:
newch = child.copy()
newch._parent = new
if isinstance(newch.callback, _ItemCallback):
newch.callback.parent = new
children.append(newch)
new._children = children
new._parent = self._parent
new._update_view(self.view)
return new
def __deepcopy__(self, memo) -> ActionRow[V]:
return self.copy()
def _has_children(self):
return True
@ -269,7 +274,7 @@ class ActionRow(Item[V]):
item._update_view(self.view)
item._parent = self
self._weight += 1
self._weight += item.width
self._children.append(item)
return self
@ -293,7 +298,7 @@ class ActionRow(Item[V]):
else:
if self._view:
self._view._add_count(-1)
self._weight -= 1
self._weight -= item.width
return self

9
discord/ui/button.py

@ -26,14 +26,14 @@ from __future__ import annotations
import copy
from typing import Callable, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union
import inspect
import os
from .item import Item, ContainedItemCallbackType as ItemCallbackType
from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback
from ..enums import ButtonStyle, ComponentType
from ..partial_emoji import PartialEmoji, _EmojiTag
from ..components import Button as ButtonComponent
from ..utils import _iscoroutinefunction
__all__ = (
'Button',
@ -304,6 +304,9 @@ class Button(Item[V]):
sku_id=self.sku_id,
id=self.id,
)
if isinstance(new.callback, _ItemCallback):
new.callback.item = new
new._update_view(self.view)
return new
def __deepcopy__(self, memo) -> Self:
@ -367,7 +370,7 @@ def button(
"""
def decorator(func: ItemCallbackType[S, Button[V]]) -> ItemCallbackType[S, Button[V]]:
if not inspect.iscoroutinefunction(func):
if not _iscoroutinefunction(func):
raise TypeError('button function must be a coroutine function')
func.__discord_ui_model_type__ = Button

391
discord/ui/checkbox.py

@ -0,0 +1,391 @@
"""
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, Any, List, Literal, Optional, Tuple, TypeVar, Dict
import os
from ..utils import MISSING
from ..components import CheckboxGroupComponent, CheckboxComponent, CheckboxGroupOption
from ..enums import ComponentType
from .item import Item
if TYPE_CHECKING:
from typing_extensions import Self
from ..interactions import Interaction
from ..types.interactions import (
ModalSubmitCheckboxGroupInteractionData as ModalSubmitCheckboxGroupInteractionDataPayload,
ModalSubmitCheckboxInteractionData as ModalSubmitCheckboxInteractionDataPayload,
)
from ..types.components import (
CheckboxGroupComponent as CheckboxGroupComponentPayload,
CheckboxComponent as CheckboxComponentPayload,
)
from .view import BaseView
from ..app_commands.namespace import ResolveKey
# fmt: off
__all__ = (
'CheckboxGroup',
'Checkbox',
)
# fmt: on
V = TypeVar('V', bound='BaseView', covariant=True)
class CheckboxGroup(Item[V]):
"""Represents a checkbox group component within a modal.
.. versionadded:: 2.7
Parameters
------------
id: Optional[:class:`int`]
The ID of the component. This must be unique across the view.
custom_id: Optional[:class:`str`]
The custom ID of the component.
options: List[:class:`discord.CheckboxGroupOption`]
A list of options that can be selected in this checkbox group.
Can only contain up to 10 items.
max_values: Optional[:class:`int`]
The maximum number of options that can be selected in this component.
Must be between 1 and 10. Defaults to 1.
min_values: Optional[:class:`int`]
The minimum number of options that must be selected in this component.
Must be between 0 and 10. Defaults to 0.
required: :class:`bool`
Whether this component is required to be filled before submitting the modal.
Defaults to ``True``.
"""
__item_repr_attributes__: Tuple[str, ...] = (
'id',
'custom_id',
'options',
'required',
)
def __init__(
self,
*,
custom_id: str = MISSING,
required: bool = True,
min_values: Optional[int] = None,
max_values: Optional[int] = None,
options: List[CheckboxGroupOption] = MISSING,
id: Optional[int] = None,
) -> None:
super().__init__()
self._provided_custom_id = custom_id is not MISSING
custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id
if not isinstance(custom_id, str):
raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}')
self._underlying: CheckboxGroupComponent = CheckboxGroupComponent._raw_construct(
id=id,
custom_id=custom_id,
required=required,
options=options or [],
min_values=min_values,
max_values=max_values,
)
self.id = id
self._values: List[str] = []
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this component."""
return self._underlying.id
@id.setter
def id(self, value: Optional[int]) -> None:
self._underlying.id = value
@property
def values(self) -> List[str]:
"""List[:class:`str`]: A list of values that have been selected by the user."""
return self._values
@property
def custom_id(self) -> str:
""":class:`str`: The ID of the component that gets received during an interaction."""
return self._underlying.custom_id
@custom_id.setter
def custom_id(self, value: str) -> None:
if not isinstance(value, str):
raise TypeError('custom_id must be a str')
self._underlying.custom_id = value
self._provided_custom_id = True
@property
def type(self) -> Literal[ComponentType.checkbox_group]:
""":class:`.ComponentType`: The type of this component."""
return ComponentType.checkbox_group
@property
def options(self) -> List[CheckboxGroupOption]:
"""List[:class:`discord.CheckboxGroupOption`]: A list of options that can be selected in this menu."""
return self._underlying.options
@options.setter
def options(self, value: List[CheckboxGroupOption]) -> None:
if not isinstance(value, list) or not all(isinstance(obj, CheckboxGroupOption) for obj in value):
raise TypeError('options must be a list of CheckboxGroupOption')
self._underlying.options = value
@property
def min_values(self) -> int:
""":class:`int`: The minimum number of options that must be selected before submitting the modal."""
return self._underlying.min_values
@min_values.setter
def min_values(self, value: int) -> None:
self._underlying.min_values = int(value)
@property
def max_values(self) -> int:
""":class:`int`: The maximum number of options that can be selected before submitting the modal."""
return self._underlying.max_values
@max_values.setter
def max_values(self, value: int) -> None:
self._underlying.max_values = int(value)
def add_option(
self,
*,
label: str,
value: str = MISSING,
description: Optional[str] = None,
default: bool = False,
) -> None:
"""Adds an option to the checkbox group.
To append a pre-existing :class:`discord.CheckboxGroupOption` use the
:meth:`append_option` method instead.
Parameters
-----------
label: :class:`str`
The label of the option. This is displayed to users.
Can only be up to 100 characters.
value: :class:`str`
The value of the option. This is not displayed to users.
If not given, defaults to the label.
Can only be up to 100 characters.
description: Optional[:class:`str`]
An additional description of the option, if any.
Can only be up to 100 characters.
default: :class:`bool`
Whether this option is selected by default.
Raises
-------
ValueError
The number of options exceeds 10.
"""
option = CheckboxGroupOption(
label=label,
value=value,
description=description,
default=default,
)
self.append_option(option)
def append_option(self, option: CheckboxGroupOption) -> None:
"""Appends an option to the checkbox group.
Parameters
-----------
option: :class:`discord.CheckboxGroupOption`
The option to append to the checkbox group.
Raises
-------
ValueError
The number of options exceeds 10.
"""
if len(self._underlying.options) >= 10:
raise ValueError('maximum number of options already provided (10)')
self._underlying.options.append(option)
@property
def required(self) -> bool:
""":class:`bool`: Whether the component is required or not."""
return self._underlying.required
@required.setter
def required(self, value: bool) -> None:
self._underlying.required = bool(value)
@property
def width(self) -> int:
return 5
def to_component_dict(self) -> CheckboxGroupComponentPayload:
return self._underlying.to_dict()
def _refresh_component(self, component: CheckboxGroupComponent) -> None:
self._underlying = component
def _handle_submit(
self, interaction: Interaction, data: ModalSubmitCheckboxGroupInteractionDataPayload, resolved: Dict[ResolveKey, Any]
) -> None:
self._values = data.get('values', [])
@classmethod
def from_component(cls, component: CheckboxGroupComponent) -> Self:
self = cls(
id=component.id,
custom_id=component.custom_id,
options=component.options,
required=component.required,
min_values=component.min_values,
max_values=component.max_values,
)
return self
def is_dispatchable(self) -> bool:
return False
class Checkbox(Item[V]):
"""Represents a checkbox component within a modal.
.. versionadded:: 2.7
Parameters
------------
id: Optional[:class:`int`]
The ID of the component. This must be unique across the view.
custom_id: Optional[:class:`str`]
The custom ID of the component.
default: :class:`bool`
Whether this checkbox is selected by default.
"""
__item_repr_attributes__: Tuple[str, ...] = (
'id',
'custom_id',
'default',
)
def __init__(
self,
*,
custom_id: str = MISSING,
default: bool = False,
id: Optional[int] = None,
) -> None:
super().__init__()
self._provided_custom_id = custom_id is not MISSING
custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id
if not isinstance(custom_id, str):
raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}')
self._underlying: CheckboxComponent = CheckboxComponent._raw_construct(
id=id,
custom_id=custom_id,
default=default,
)
self.id = id
self._value: bool = default
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this component."""
return self._underlying.id
@id.setter
def id(self, value: Optional[int]) -> None:
self._underlying.id = value
@property
def value(self) -> bool:
""":class:`bool`: ``True`` if this checkbox was selected, otherwise ``False``."""
return self._value
@property
def custom_id(self) -> str:
""":class:`str`: The ID of the component that gets received during an interaction."""
return self._underlying.custom_id
@custom_id.setter
def custom_id(self, value: str) -> None:
if not isinstance(value, str):
raise TypeError('custom_id must be a str')
self._underlying.custom_id = value
self._provided_custom_id = True
@property
def type(self) -> Literal[ComponentType.checkbox]:
""":class:`.ComponentType`: The type of this component."""
return ComponentType.checkbox
@property
def default(self) -> bool:
""":class:`bool`: Whether this checkbox is selected by default."""
return self._underlying.default
@default.setter
def default(self, value: bool) -> None:
self._underlying.default = bool(value)
@property
def width(self) -> int:
return 5
def to_component_dict(self) -> CheckboxComponentPayload:
return self._underlying.to_dict()
def _refresh_component(self, component: CheckboxComponent) -> None:
self._underlying = component
def _handle_submit(
self, interaction: Interaction, data: ModalSubmitCheckboxInteractionDataPayload, resolved: Dict[ResolveKey, Any]
) -> None:
self._value = data.get('value', False)
@classmethod
def from_component(cls, component: CheckboxComponent) -> Self:
self = cls(
id=component.id,
custom_id=component.custom_id,
default=component.default,
)
return self
def is_dispatchable(self) -> bool:
return False

31
discord/ui/container.py

@ -29,7 +29,6 @@ from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Coroutine,
Dict,
Generator,
List,
@ -39,7 +38,7 @@ from typing import (
Union,
)
from .item import Item, ContainedItemCallbackType as ItemCallbackType
from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback
from .view import _component_to_item, LayoutView
from ..enums import ComponentType
from ..utils import get as _utils_get
@ -49,7 +48,6 @@ if TYPE_CHECKING:
from typing_extensions import Self
from ..components import Container as ContainerComponent
from ..interactions import Interaction
from .dynamic import DynamicItem
S = TypeVar('S', bound='Container', covariant=True)
@ -58,18 +56,6 @@ V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('Container',)
class _ContainerCallback:
__slots__ = ('container', 'callback', 'item')
def __init__(self, callback: ItemCallbackType[S, Any], container: Container, item: Item[Any]) -> None:
self.callback: ItemCallbackType[Any, Any] = callback
self.container: Container = container
self.item: Item[Any] = item
def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]:
return self.callback(self.container, interaction, self.item)
class Container(Item[V]):
r"""Represents a UI container.
@ -163,7 +149,7 @@ class Container(Item[V]):
# action rows can be created inside containers, and then callbacks can exist here
# so we create items based off them
item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__)
item.callback = _ContainerCallback(raw, self, item) # type: ignore
item.callback = _ItemCallback(raw, self, item) # type: ignore
setattr(self, raw.__name__, item)
# this should not fail because in order for a function to be here it should be from
# an action row and must have passed the check in __init_subclass__, but still
@ -196,6 +182,15 @@ class Container(Item[V]):
child._update_view(view)
return True
def copy(self) -> Container[V]:
new = copy.deepcopy(self)
for child in new._children:
newch = child.copy()
newch._parent = new
new._parent = self._parent
new._update_view(self.view)
return new
def _has_children(self):
return True
@ -208,10 +203,6 @@ class Container(Item[V]):
"""List[:class:`Item`]: The children of this container."""
return self._children.copy()
@children.setter
def children(self, value: List[Item[V]]) -> None:
self._children = value
@property
def accent_colour(self) -> Optional[Union[Colour, int]]:
"""Optional[Union[:class:`discord.Colour`, :class:`int`]]: The colour of the container, or ``None``."""

10
discord/ui/file.py

@ -100,7 +100,15 @@ class File(Item[V]):
spoiler=bool(spoiler),
id=id,
)
self.id = id
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this file component."""
return self._underlying.id
@id.setter
def id(self, value: Optional[int]) -> None:
self._underlying.id = value
def _is_v2(self):
return True

199
discord/ui/file_upload.py

@ -0,0 +1,199 @@
"""
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, Any, List, Literal, Optional, Tuple, TypeVar, Dict
import os
from ..utils import MISSING
from ..components import FileUploadComponent
from ..enums import ComponentType
from .item import Item
if TYPE_CHECKING:
from typing_extensions import Self
from ..message import Attachment
from ..interactions import Interaction
from ..types.interactions import ModalSubmitTextInputInteractionData as ModalSubmitFileUploadInteractionDataPayload
from ..types.components import FileUploadComponent as FileUploadComponentPayload
from .view import BaseView
from ..app_commands.namespace import ResolveKey
# fmt: off
__all__ = (
'FileUpload',
)
# fmt: on
V = TypeVar('V', bound='BaseView', covariant=True)
class FileUpload(Item[V]):
"""Represents a file upload component within a modal.
.. versionadded:: 2.7
Parameters
------------
id: Optional[:class:`int`]
The ID of the component. This must be unique across the view.
custom_id: Optional[:class:`str`]
The custom ID of the file upload component.
max_values: Optional[:class:`int`]
The maximum number of files that can be uploaded in this component.
Must be between 1 and 10. Defaults to 1.
min_values: Optional[:class:`int`]
The minimum number of files that must be uploaded in this component.
Must be between 0 and 10. Defaults to 0.
required: :class:`bool`
Whether this component is required to be filled before submitting the modal.
Defaults to ``True``.
"""
__item_repr_attributes__: Tuple[str, ...] = (
'id',
'custom_id',
'max_values',
'min_values',
'required',
)
def __init__(
self,
*,
custom_id: str = MISSING,
required: bool = True,
min_values: Optional[int] = None,
max_values: Optional[int] = None,
id: Optional[int] = None,
) -> None:
super().__init__()
self._provided_custom_id = custom_id is not MISSING
custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id
if not isinstance(custom_id, str):
raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}')
self._underlying: FileUploadComponent = FileUploadComponent._raw_construct(
id=id,
custom_id=custom_id,
max_values=max_values,
min_values=min_values,
required=required,
)
self.id = id
self._values: List[Attachment] = []
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this component."""
return self._underlying.id
@id.setter
def id(self, value: Optional[int]) -> None:
self._underlying.id = value
@property
def values(self) -> List[Attachment]:
"""List[:class:`discord.Attachment`]: The list of attachments uploaded by the user.
You can call :meth:`~discord.Attachment.to_file` on each attachment
to get a :class:`~discord.File` for sending.
"""
return self._values
@property
def custom_id(self) -> str:
""":class:`str`: The ID of the component that gets received during an interaction."""
return self._underlying.custom_id
@custom_id.setter
def custom_id(self, value: str) -> None:
if not isinstance(value, str):
raise TypeError('custom_id must be a str')
self._underlying.custom_id = value
self._provided_custom_id = True
@property
def min_values(self) -> int:
""":class:`int`: The minimum number of files that must be user upload before submitting the modal."""
return self._underlying.min_values
@min_values.setter
def min_values(self, value: int) -> None:
self._underlying.min_values = int(value)
@property
def max_values(self) -> int:
""":class:`int`: The maximum number of files that the user must upload before submitting the modal."""
return self._underlying.max_values
@max_values.setter
def max_values(self, value: int) -> None:
self._underlying.max_values = int(value)
@property
def required(self) -> bool:
""":class:`bool`: Whether the component is required or not."""
return self._underlying.required
@required.setter
def required(self, value: bool) -> None:
self._underlying.required = bool(value)
@property
def width(self) -> int:
return 5
def to_component_dict(self) -> FileUploadComponentPayload:
return self._underlying.to_dict()
def _refresh_component(self, component: FileUploadComponent) -> None:
self._underlying = component
def _handle_submit(
self, interaction: Interaction, data: ModalSubmitFileUploadInteractionDataPayload, resolved: Dict[ResolveKey, Any]
) -> None:
self._values = [v for k, v in resolved.items() if k.id in data.get('values', [])]
@classmethod
def from_component(cls, component: FileUploadComponent) -> Self:
self = cls(
id=component.id,
custom_id=component.custom_id,
max_values=component.max_values,
min_values=component.min_values,
required=component.required,
)
return self
@property
def type(self) -> Literal[ComponentType.file_upload]:
return self._underlying.type
def is_dispatchable(self) -> bool:
return False

22
discord/ui/item.py

@ -45,6 +45,7 @@ if TYPE_CHECKING:
from .action_row import ActionRow
from .container import Container
from .dynamic import DynamicItem
from ..app_commands.namespace import ResolveKey
I = TypeVar('I', bound='Item[Any]')
V = TypeVar('V', bound='BaseView', covariant=True)
@ -54,6 +55,21 @@ ItemCallbackType = Callable[[V, Interaction[Any], I], Coroutine[Any, Any, Any]]
ContainedItemCallbackType = Callable[[C, Interaction[Any], I], Coroutine[Any, Any, Any]]
class _ItemCallback:
__slots__ = ('parent', 'callback', 'item')
def __init__(self, callback: ContainedItemCallbackType[Any, Any], parent: Any, item: Item[Any]) -> None:
self.callback: ItemCallbackType[Any, Any] = callback
self.parent: Any = parent
self.item: Item[Any] = item
def __repr__(self) -> str:
return f'<ItemCallback callback={self.callback!r} parent={self.parent!r} item={self.item!r}>'
def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]:
return self.callback(self.parent, interaction, self.item)
class Item(Generic[V]):
"""Represents the base UI item that all UI components inherit from.
@ -71,6 +87,9 @@ class Item(Generic[V]):
- :class:`discord.ui.TextDisplay`
- :class:`discord.ui.Thumbnail`
- :class:`discord.ui.Label`
- :class:`discord.ui.RadioGroup`
- :class:`discord.ui.CheckboxGroup`
- :class:`discord.ui.Checkbox`
.. versionadded:: 2.0
"""
@ -97,6 +116,9 @@ class Item(Generic[V]):
def _refresh_component(self, component: Component) -> None:
return None
def _handle_submit(self, interaction: Interaction, data: Dict[str, Any], resolved: Dict[ResolveKey, Any]) -> None:
return self._refresh_state(interaction, data)
def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None:
return None

12
discord/ui/label.py

@ -50,6 +50,8 @@ V = TypeVar('V', bound='BaseView', covariant=True)
class Label(Item[V]):
"""Represents a UI label within a modal.
This is a top-level layout component that can only be used on :class:`Modal`.
.. versionadded:: 2.6
Parameters
@ -60,7 +62,7 @@ class Label(Item[V]):
description: Optional[:class:`str`]
The description text to display right below the label text.
Can only be up to 100 characters.
component: Union[:class:`discord.ui.TextInput`, :class:`discord.ui.Select`]
component: :class:`Item`
The component to display below the label.
id: Optional[:class:`int`]
The ID of the component. This must be unique across the view.
@ -74,8 +76,7 @@ class Label(Item[V]):
The description text to display right below the label text.
Can only be up to 100 characters.
component: :class:`Item`
The component to display below the label. Currently only
supports :class:`TextInput` and :class:`Select`.
The component to display below the label.
"""
__item_repr_attributes__: Tuple[str, ...] = (
@ -138,3 +139,8 @@ class Label(Item[V]):
def is_dispatchable(self) -> bool:
return False
@property
def _total_count(self) -> int:
# Count the component and ourselves
return 2

55
discord/ui/modal.py

@ -36,12 +36,17 @@ from .item import Item
from .view import BaseView
from .select import BaseSelect
from .text_input import TextInput
from ..interactions import Namespace
if TYPE_CHECKING:
from typing_extensions import Self
from ..interactions import Interaction
from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload
from ..types.interactions import (
ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload,
ResolvedData as ResolvedDataPayload,
)
from ..app_commands.namespace import ResolveKey
# fmt: off
@ -168,23 +173,41 @@ class Modal(BaseView):
"""
_log.error('Ignoring exception in modal %r:', self, exc_info=error)
def _refresh(self, interaction: Interaction, components: Sequence[ModalSubmitComponentInteractionDataPayload]) -> None:
def _refresh(
self,
interaction: Interaction,
components: Sequence[ModalSubmitComponentInteractionDataPayload],
resolved: Dict[ResolveKey, Any],
) -> None:
for component in components:
if component['type'] == 1:
self._refresh(interaction, component['components'])
self._refresh(interaction, component['components'], resolved) # type: ignore
elif component['type'] == 18:
self._refresh(interaction, [component['component']])
self._refresh(interaction, [component['component']], resolved) # type: ignore
else:
item = find(lambda i: getattr(i, 'custom_id', None) == component['custom_id'], self.walk_children()) # type: ignore
custom_id = component.get('custom_id')
if custom_id is None:
continue
item = find(
lambda i: getattr(i, 'custom_id', None) == custom_id,
self.walk_children(),
)
if item is None:
_log.debug('Modal interaction referencing unknown item custom_id %s. Discarding', component['custom_id'])
_log.debug('Modal interaction referencing unknown item custom_id %s. Discarding', custom_id)
continue
item._refresh_state(interaction, component) # type: ignore
async def _scheduled_task(self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload]):
item._handle_submit(interaction, component, resolved) # type: ignore
async def _scheduled_task(
self,
interaction: Interaction,
components: List[ModalSubmitComponentInteractionDataPayload],
resolved: Dict[ResolveKey, Any],
):
try:
self._refresh_timeout()
self._refresh(interaction, components)
self._refresh(interaction, components, resolved)
allow = await self.interaction_check(interaction)
if not allow:
@ -200,7 +223,7 @@ class Modal(BaseView):
def to_components(self) -> List[Dict[str, Any]]:
def key(item: Item) -> int:
return item._rendered_row or 0
return item._rendered_row or item.row or 0
children = sorted(self._children, key=key)
components: List[Dict[str, Any]] = []
@ -221,10 +244,18 @@ class Modal(BaseView):
return components
def _dispatch_submit(
self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload]
self,
interaction: Interaction,
components: List[ModalSubmitComponentInteractionDataPayload],
resolved: ResolvedDataPayload,
) -> asyncio.Task[None]:
try:
namespace = Namespace._get_resolved_items(interaction, resolved)
except KeyError:
namespace = {}
return asyncio.create_task(
self._scheduled_task(interaction, components), name=f'discord-ui-modal-dispatch-{self.id}'
self._scheduled_task(interaction, components, namespace), name=f'discord-ui-modal-dispatch-{self.id}'
)
def to_dict(self) -> Dict[str, Any]:

246
discord/ui/radio.py

@ -0,0 +1,246 @@
"""
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, Any, List, Literal, Optional, Tuple, TypeVar, Dict
import os
from ..utils import MISSING
from ..components import RadioGroupComponent, RadioGroupOption
from ..enums import ComponentType
from .item import Item
if TYPE_CHECKING:
from typing_extensions import Self
from ..interactions import Interaction
from ..types.interactions import (
ModalSubmitRadioGroupInteractionData as ModalSubmitRadioGroupInteractionDataPayload,
)
from ..types.components import RadioGroupComponent as RadioGroupComponentPayload
from .view import BaseView
from ..app_commands.namespace import ResolveKey
# fmt: off
__all__ = (
'RadioGroup',
)
# fmt: on
V = TypeVar('V', bound='BaseView', covariant=True)
class RadioGroup(Item[V]):
"""Represents a radio group component within a modal.
.. versionadded:: 2.7
Parameters
------------
id: Optional[:class:`int`]
The ID of the component. This must be unique across the view.
custom_id: Optional[:class:`str`]
The custom ID of the component.
options: List[:class:`discord.RadioGroupOption`]
A list of options that can be selected in this radio group.
Can contain between 2 and 10 items.
required: :class:`bool`
Whether this component is required to be filled before submitting the modal.
Defaults to ``True``.
"""
__item_repr_attributes__: Tuple[str, ...] = (
'id',
'custom_id',
'options',
'required',
)
def __init__(
self,
*,
custom_id: str = MISSING,
required: bool = True,
options: List[RadioGroupOption] = MISSING,
id: Optional[int] = None,
) -> None:
super().__init__()
self._provided_custom_id = custom_id is not MISSING
custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id
if not isinstance(custom_id, str):
raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}')
self._underlying: RadioGroupComponent = RadioGroupComponent._raw_construct(
id=id,
custom_id=custom_id,
required=required,
options=options or [],
)
self.id = id
self._value: Optional[str] = None
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this component."""
return self._underlying.id
@id.setter
def id(self, value: Optional[int]) -> None:
self._underlying.id = value
@property
def value(self) -> Optional[str]:
"""Optional[:class:`str`]: The value have been selected by the user, if any."""
return self._value
@property
def custom_id(self) -> str:
""":class:`str`: The ID of the component that gets received during an interaction."""
return self._underlying.custom_id
@custom_id.setter
def custom_id(self, value: str) -> None:
if not isinstance(value, str):
raise TypeError('custom_id must be a str')
self._underlying.custom_id = value
self._provided_custom_id = True
@property
def type(self) -> Literal[ComponentType.radio_group]:
""":class:`.ComponentType`: The type of this component."""
return ComponentType.radio_group
@property
def options(self) -> List[RadioGroupOption]:
"""List[:class:`discord.RadioGroupOption`]: A list of options that can be selected in this radio group."""
return self._underlying.options
@options.setter
def options(self, value: List[RadioGroupOption]) -> None:
if not isinstance(value, list) or not all(isinstance(obj, RadioGroupOption) for obj in value):
raise TypeError('options must be a list of RadioGroupOption')
self._underlying.options = value
def add_option(
self,
*,
label: str,
value: str = MISSING,
description: Optional[str] = None,
default: bool = False,
) -> None:
"""Adds an option to the group.
To append a pre-existing :class:`discord.RadioGroupOption` use the
:meth:`append_option` method instead.
Parameters
-----------
label: :class:`str`
The label of the option. This is displayed to users.
Can only be up to 100 characters.
value: :class:`str`
The value of the option. This is not displayed to users.
If not given, defaults to the label.
Can only be up to 100 characters.
description: Optional[:class:`str`]
An additional description of the option, if any.
Can only be up to 100 characters.
default: :class:`bool`
Whether this option is selected by default.
Raises
-------
ValueError
The number of options exceeds 10.
"""
option = RadioGroupOption(
label=label,
value=value,
description=description,
default=default,
)
self.append_option(option)
def append_option(self, option: RadioGroupOption) -> None:
"""Appends an option to the group.
Parameters
-----------
option: :class:`discord.RadioGroupOption`
The option to append to the group.
Raises
-------
ValueError
The number of options exceeds 10.
"""
if len(self._underlying.options) >= 10:
raise ValueError('maximum number of options already provided (10)')
self._underlying.options.append(option)
@property
def required(self) -> bool:
""":class:`bool`: Whether the component is required or not."""
return self._underlying.required
@required.setter
def required(self, value: bool) -> None:
self._underlying.required = bool(value)
@property
def width(self) -> int:
return 5
def to_component_dict(self) -> RadioGroupComponentPayload:
return self._underlying.to_dict()
def _refresh_component(self, component: RadioGroupComponent) -> None:
self._underlying = component
def _handle_submit(
self, interaction: Interaction, data: ModalSubmitRadioGroupInteractionDataPayload, resolved: Dict[ResolveKey, Any]
) -> None:
self._value = data.get('value')
@classmethod
def from_component(cls, component: RadioGroupComponent) -> Self:
self = cls(
id=component.id,
custom_id=component.custom_id,
options=component.options,
required=component.required,
)
return self
def is_dispatchable(self) -> bool:
return False

3
discord/ui/section.py

@ -55,7 +55,7 @@ class Section(Item[V]):
\*children: Union[:class:`str`, :class:`TextDisplay`]
The text displays of this section. Up to 3.
accessory: :class:`Item`
The section accessory.
The section accessory. This is usually either a :class:`Button` or :class:`Thumbnail`.
id: Optional[:class:`int`]
The ID of this component. This must be unique across the view.
"""
@ -117,6 +117,7 @@ class Section(Item[V]):
if not isinstance(value, Item):
raise TypeError(f'Expected an Item, got {value.__class__.__name__!r} instead')
value._update_view(self.view)
value._parent = self
self._accessory = value

72
discord/ui/select.py

@ -39,14 +39,14 @@ from typing import (
Sequence,
)
from contextvars import ContextVar
import inspect
import copy
import os
from .item import Item, ContainedItemCallbackType as ItemCallbackType
from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback
from ..enums import ChannelType, ComponentType, SelectDefaultValueType
from ..partial_emoji import PartialEmoji
from ..emoji import Emoji
from ..utils import MISSING, _human_join
from ..utils import MISSING, _human_join, _iscoroutinefunction
from ..components import (
SelectOption,
SelectMenu,
@ -70,7 +70,7 @@ __all__ = (
)
if TYPE_CHECKING:
from typing_extensions import TypeAlias, TypeGuard
from typing_extensions import TypeAlias, TypeGuard, Self
from .view import BaseView
from .action_row import ActionRow
@ -78,6 +78,7 @@ if TYPE_CHECKING:
from ..types.interactions import SelectMessageComponentInteractionData
from ..app_commands import AppCommandChannel, AppCommandThread
from ..interactions import Interaction
from ..app_commands.namespace import ResolveKey
ValidSelectType: TypeAlias = Literal[
ComponentType.string_select,
@ -239,7 +240,7 @@ class BaseSelect(Item[V]):
min_values: Optional[int] = None,
max_values: Optional[int] = None,
disabled: bool = False,
required: bool = False,
required: bool = True,
options: List[SelectOption] = MISSING,
channel_types: List[ChannelType] = MISSING,
default_values: Sequence[SelectDefaultValue] = MISSING,
@ -268,6 +269,14 @@ class BaseSelect(Item[V]):
self.row = row
self._values: List[PossibleValue] = []
def copy(self) -> Self:
new = copy.copy(self)
if isinstance(new.callback, _ItemCallback):
new.callback.item = new
new._parent = self._parent
new._update_view(self.view)
return new
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this select."""
@ -356,7 +365,24 @@ class BaseSelect(Item[V]):
def _refresh_component(self, component: SelectMenu) -> None:
self._underlying = component
def _refresh_state(self, interaction: Interaction, data: SelectMessageComponentInteractionData) -> None:
def _handle_submit(
self, interaction: Interaction, data: SelectMessageComponentInteractionData, resolved: Dict[ResolveKey, Any]
) -> None:
payload: List[PossibleValue]
values = selected_values.get({})
string_values = data.get('values', [])
payload = [v for k, v in resolved.items() if k.id in string_values]
if not payload:
payload = list(string_values)
self._values = values[self.custom_id] = payload
selected_values.set(values)
def _refresh_state(
self,
interaction: Interaction,
data: SelectMessageComponentInteractionData,
) -> None:
values = selected_values.get({})
payload: List[PossibleValue]
try:
@ -366,7 +392,7 @@ class BaseSelect(Item[V]):
)
payload = list(resolved.values())
except KeyError:
payload = data.get('values', []) # type: ignore
payload = list(data.get('values', []))
self._values = values[self.custom_id] = payload
selected_values.set(values)
@ -479,10 +505,8 @@ class Select(BaseSelect[V]):
@options.setter
def options(self, value: List[SelectOption]) -> None:
if not isinstance(value, list):
if not isinstance(value, list) or not all(isinstance(obj, SelectOption) for obj in value):
raise TypeError('options must be a list of SelectOption')
if not all(isinstance(obj, SelectOption) for obj in value):
raise TypeError('all list items must subclass SelectOption')
self._underlying.options = value
@ -549,7 +573,7 @@ class Select(BaseSelect[V]):
"""
if len(self._underlying.options) >= 25:
raise ValueError('maximum number of options already provided')
raise ValueError('maximum number of options already provided (25)')
self._underlying.options.append(option)
@ -580,6 +604,10 @@ class UserSelect(BaseSelect[V]):
Defaults to 1 and must be between 1 and 25.
disabled: :class:`bool`
Whether the select is disabled or not.
required: :class:`bool`
Whether the select is required. Only applicable within modals.
.. versionadded:: 2.6
default_values: Sequence[:class:`~discord.abc.Snowflake`]
A list of objects representing the users that should be selected by default.
Number of items must be in range of ``min_values`` and ``max_values``.
@ -611,6 +639,7 @@ class UserSelect(BaseSelect[V]):
min_values: int = 1,
max_values: int = 1,
disabled: bool = False,
required: bool = True,
row: Optional[int] = None,
default_values: Sequence[ValidDefaultValues] = MISSING,
id: Optional[int] = None,
@ -622,6 +651,7 @@ class UserSelect(BaseSelect[V]):
min_values=min_values,
max_values=max_values,
disabled=disabled,
required=required,
row=row,
default_values=_handle_select_defaults(default_values, self.type),
id=id,
@ -682,6 +712,10 @@ class RoleSelect(BaseSelect[V]):
Defaults to 1 and must be between 1 and 25.
disabled: :class:`bool`
Whether the select is disabled or not.
required: :class:`bool`
Whether the select is required. Only applicable within modals.
.. versionadded:: 2.6
default_values: Sequence[:class:`~discord.abc.Snowflake`]
A list of objects representing the roles that should be selected by default.
Number of items must be in range of ``min_values`` and ``max_values``.
@ -713,6 +747,7 @@ class RoleSelect(BaseSelect[V]):
min_values: int = 1,
max_values: int = 1,
disabled: bool = False,
required: bool = True,
row: Optional[int] = None,
default_values: Sequence[ValidDefaultValues] = MISSING,
id: Optional[int] = None,
@ -724,6 +759,7 @@ class RoleSelect(BaseSelect[V]):
min_values=min_values,
max_values=max_values,
disabled=disabled,
required=required,
row=row,
default_values=_handle_select_defaults(default_values, self.type),
id=id,
@ -779,6 +815,10 @@ class MentionableSelect(BaseSelect[V]):
Defaults to 1 and must be between 1 and 25.
disabled: :class:`bool`
Whether the select is disabled or not.
required: :class:`bool`
Whether the select is required. Only applicable within modals.
.. versionadded:: 2.6
default_values: Sequence[:class:`~discord.abc.Snowflake`]
A list of objects representing the users/roles that should be selected by default.
if :class:`.Object` is passed, then the type must be specified in the constructor.
@ -811,6 +851,7 @@ class MentionableSelect(BaseSelect[V]):
min_values: int = 1,
max_values: int = 1,
disabled: bool = False,
required: bool = True,
row: Optional[int] = None,
default_values: Sequence[ValidDefaultValues] = MISSING,
id: Optional[int] = None,
@ -822,6 +863,7 @@ class MentionableSelect(BaseSelect[V]):
min_values=min_values,
max_values=max_values,
disabled=disabled,
required=required,
row=row,
default_values=_handle_select_defaults(default_values, self.type),
id=id,
@ -884,6 +926,10 @@ class ChannelSelect(BaseSelect[V]):
Defaults to 1 and must be between 1 and 25.
disabled: :class:`bool`
Whether the select is disabled or not.
required: :class:`bool`
Whether the select is required. Only applicable within modals.
.. versionadded:: 2.6
default_values: Sequence[:class:`~discord.abc.Snowflake`]
A list of objects representing the channels that should be selected by default.
Number of items must be in range of ``min_values`` and ``max_values``.
@ -919,6 +965,7 @@ class ChannelSelect(BaseSelect[V]):
min_values: int = 1,
max_values: int = 1,
disabled: bool = False,
required: bool = True,
row: Optional[int] = None,
default_values: Sequence[ValidDefaultValues] = MISSING,
id: Optional[int] = None,
@ -930,6 +977,7 @@ class ChannelSelect(BaseSelect[V]):
min_values=min_values,
max_values=max_values,
disabled=disabled,
required=required,
row=row,
channel_types=channel_types,
default_values=_handle_select_defaults(default_values, self.type),
@ -1160,7 +1208,7 @@ def select(
"""
def decorator(func: ItemCallbackType[S, BaseSelectT]) -> ItemCallbackType[S, BaseSelectT]:
if not inspect.iscoroutinefunction(func):
if not _iscoroutinefunction(func):
raise TypeError('select function must be a coroutine function')
callback_cls = getattr(cls, '__origin__', cls)
if not issubclass(callback_cls, BaseSelect):

9
discord/ui/separator.py

@ -83,6 +83,15 @@ class Separator(Item[V]):
def _is_v2(self):
return True
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this separator."""
return self._underlying.id
@id.setter
def id(self, value: Optional[int]) -> None:
self._underlying.id = value
@property
def visible(self) -> bool:
""":class:`bool`: Whether this separator is visible.

3
discord/ui/text_display.py

@ -43,7 +43,8 @@ __all__ = ('TextDisplay',)
class TextDisplay(Item[V]):
"""Represents a UI text display.
This is a top-level layout component that can only be used on :class:`LayoutView` or :class:`Section`.
This is a top-level layout component that can only be used on :class:`LayoutView`,
:class:`Section`, :class:`Container`, or :class:`Modal`.
.. versionadded:: 2.6

12
discord/ui/text_input.py

@ -53,6 +53,8 @@ V = TypeVar('V', bound='BaseView', covariant=True)
class TextInput(Item[V]):
"""Represents a UI text input.
This a top-level layout component that can only be used in :class:`Label`.
.. container:: operations
.. describe:: str(x)
@ -144,11 +146,19 @@ class TextInput(Item[V]):
id=id,
)
self.row = row
self.id = id
def __str__(self) -> str:
return self.value
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this text input."""
return self._underlying.id
@id.setter
def id(self, value: Optional[int]) -> None:
self._underlying.id = value
@property
def custom_id(self) -> str:
""":class:`str`: The ID of the text input that gets received during an interaction."""

172
discord/ui/view.py

@ -28,7 +28,6 @@ from typing import (
Any,
Callable,
ClassVar,
Coroutine,
Dict,
Generator,
Iterator,
@ -50,7 +49,7 @@ import sys
import time
import os
from .item import Item, ItemCallbackType
from .item import Item, ItemCallbackType, _ItemCallback
from .select import Select
from .dynamic import DynamicItem
from ..components import (
@ -83,9 +82,13 @@ if TYPE_CHECKING:
import re
from ..interactions import Interaction
from .._types import ClientT
from ..message import Message
from ..types.components import ComponentBase as ComponentBasePayload
from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload
from ..types.interactions import (
ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload,
ResolvedData as ResolvedDataPayload,
)
from ..state import ConnectionState
from .modal import Modal
@ -204,16 +207,22 @@ class _ViewWeights:
self.weights = [0, 0, 0, 0, 0]
class _ViewCallback:
__slots__ = ('view', 'callback', 'item')
class _ViewCacheSnapshot:
__slots__ = ('items', 'dynamic_items')
def __init__(self) -> None:
self.items: Set[Tuple[int, str]] = set()
self.dynamic_items: Set[re.Pattern[str]] = set()
def __init__(self, callback: ItemCallbackType[Any, Any], view: BaseView, item: Item[BaseView]) -> None:
self.callback: ItemCallbackType[Any, Any] = callback
self.view: BaseView = view
self.item: Item[BaseView] = item
@classmethod
def diff(cls, older: _ViewCacheSnapshot, newer: _ViewCacheSnapshot) -> Self:
self = cls()
self.items = older.items - newer.items
self.dynamic_items = older.dynamic_items - newer.dynamic_items
return self
def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]:
return self.callback(self.view, interaction, self.item)
def __repr__(self) -> str:
return f'<_ViewCacheSnapshot items={self.items!r} dynamic_items={self.dynamic_items!r}>'
class BaseView:
@ -229,7 +238,15 @@ class BaseView:
self.__cancel_callback: Optional[Callable[[BaseView], None]] = None
self.__timeout_expiry: Optional[float] = None
self.__timeout_task: Optional[asyncio.Task[None]] = None
self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
self.__snapshot: Optional[_ViewCacheSnapshot] = None
try:
loop = asyncio.get_running_loop()
except RuntimeError:
self.__stopped: Optional[asyncio.Future[bool]] = None
else:
self.__stopped: Optional[asyncio.Future[bool]] = loop.create_future()
self._total_children: int = len(tuple(self.walk_children()))
def _is_layout(self) -> bool:
@ -249,13 +266,13 @@ class BaseView:
item._update_view(self)
parent = getattr(item, '__discord_ui_parent__', None)
if parent and parent._view is None:
parent._view = self
parent._update_view(self)
children.append(item)
parents[raw] = item
else:
item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__)
item.callback = _ViewCallback(raw, self, item) # type: ignore
item._view = self
item.callback = _ItemCallback(raw, self, item) # type: ignore
item._update_view(self)
if isinstance(item, Select):
item.options = [option.copy() for option in item.options]
setattr(self, raw.__name__, item)
@ -328,6 +345,31 @@ class BaseView:
def _add_count(self, value: int) -> None:
self._total_children = max(0, self._total_children + value)
@property
def _snapshot(self) -> Optional[_ViewCacheSnapshot]:
return self.__snapshot
def _get_snapshot_diff(self) -> Optional[_ViewCacheSnapshot]:
if self.__snapshot is None:
self.__snapshot = self._get_snapshot()
return None
newer = self._get_snapshot()
diff = _ViewCacheSnapshot.diff(older=self.__snapshot, newer=newer)
# Update our snapshot to the newer version after diffing it
self.__snapshot = newer
return diff
def _get_snapshot(self) -> _ViewCacheSnapshot:
snapshot = _ViewCacheSnapshot()
for item in self.walk_children():
if isinstance(item, DynamicItem):
snapshot.dynamic_items.add(item.__discord_ui_compiled_template__)
elif item.is_dispatchable():
custom_id = item.custom_id # type: ignore
snapshot.items.add((item.type.value, custom_id))
return snapshot
@property
def children(self) -> List[Item[Self]]:
"""List[:class:`Item`]: The list of children attached to this view."""
@ -449,6 +491,7 @@ class BaseView:
pass
else:
self._add_count(-item._total_count)
item._update_view(None)
return self
@ -458,6 +501,9 @@ class BaseView:
This function returns the class instance to allow for fluent-style
chaining.
"""
for child in self._children:
child._update_view(None)
self._children.clear()
self._total_children = 0
return self
@ -484,7 +530,7 @@ class BaseView:
"""
return _utils_get(self.walk_children(), id=id)
async def interaction_check(self, interaction: Interaction, /) -> bool:
async def interaction_check(self, interaction: Interaction[ClientT], /) -> bool:
"""|coro|
A callback that is called when an interaction happens within the view
@ -519,7 +565,7 @@ class BaseView:
"""
pass
async def on_error(self, interaction: Interaction, error: Exception, item: Item[Any], /) -> None:
async def on_error(self, interaction: Interaction[ClientT], error: Exception, item: Item[Any], /) -> None:
"""|coro|
A callback that is called when an item's callback or :meth:`interaction_check`
@ -538,7 +584,7 @@ class BaseView:
"""
_log.error('Ignoring exception in view %r for item %r', self, item, exc_info=error)
async def _scheduled_task(self, item: Item, interaction: Interaction):
async def _scheduled_task(self, item: Item[Any], interaction: Interaction[ClientT]):
try:
item._refresh_state(interaction, interaction.data) # type: ignore
@ -563,7 +609,7 @@ class BaseView:
self.__timeout_task = asyncio.create_task(self.__timeout_task_impl())
def _dispatch_timeout(self):
if self.__stopped.done():
if self.__stopped is None or self.__stopped.done():
return
if self.__cancel_callback:
@ -573,9 +619,9 @@ class BaseView:
self.__stopped.set_result(True)
asyncio.create_task(self.on_timeout(), name=f'discord-ui-view-timeout-{self.id}')
def _dispatch_item(self, item: Item, interaction: Interaction) -> Optional[asyncio.Task[None]]:
if self.__stopped.done():
return
def _dispatch_item(self, item: Item[Any], interaction: Interaction[ClientT]) -> Optional[asyncio.Task[None]]:
if self.__stopped is None or self.__stopped.done():
return None
return asyncio.create_task(self._scheduled_task(item, interaction), name=f'discord-ui-view-dispatch-{self.id}')
@ -606,7 +652,7 @@ class BaseView:
This operation cannot be undone.
"""
if not self.__stopped.done():
if self.__stopped is not None and not self.__stopped.done():
self.__stopped.set_result(False)
self.__timeout_expiry = None
@ -620,6 +666,9 @@ class BaseView:
def is_finished(self) -> bool:
""":class:`bool`: Whether the view has finished interacting."""
if self.__stopped is None:
return False
return self.__stopped.done()
def is_dispatching(self) -> bool:
@ -648,6 +697,9 @@ class BaseView:
If ``True``, then the view timed out. If ``False`` then
the view finished normally.
"""
if self.__stopped is None:
self.__stopped = asyncio.get_running_loop().create_future()
return await self.__stopped
def walk_children(self) -> Generator[Item[Any], None, None]:
@ -754,6 +806,8 @@ class View(BaseView):
pass
else:
self.__weights.remove_item(item)
item._update_view(None)
return self
def clear_items(self) -> Self:
@ -889,8 +943,9 @@ class ViewStore:
self._modals[view.custom_id] = view # type: ignore
return
dispatch_info = self._views.setdefault(message_id, {})
dispatch_info = self._views.get(message_id, {})
is_fully_dynamic = True
snapshot = view._get_snapshot_diff()
for item in view.walk_children():
if isinstance(item, DynamicItem):
pattern = item.__discord_ui_compiled_template__
@ -899,26 +954,34 @@ class ViewStore:
dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore
is_fully_dynamic = False
if snapshot is not None:
for key in snapshot.items:
dispatch_info.pop(key, None)
for key in snapshot.dynamic_items:
self._dynamic_items.pop(key, None)
view._cache_key = message_id
if dispatch_info:
self._views[message_id] = dispatch_info
if message_id is not None and not is_fully_dynamic:
self._synced_message_views[message_id] = view
def remove_view(self, view: View) -> None:
def remove_view(self, view: BaseView) -> None:
if view.__discord_ui_modal__:
self._modals.pop(view.custom_id, None) # type: ignore
return
dispatch_info = self._views.get(view._cache_key)
if dispatch_info:
for item in view._children:
if isinstance(item, DynamicItem):
pattern = item.__discord_ui_compiled_template__
self._dynamic_items.pop(pattern, None)
elif item.is_dispatchable():
dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore
snapshot = view._snapshot
if dispatch_info and snapshot:
for key in snapshot.items:
dispatch_info.pop(key, None)
for key in snapshot.dynamic_items:
self._dynamic_items.pop(key, None)
if len(dispatch_info) == 0:
self._views.pop(view._cache_key, None)
if dispatch_info is not None and len(dispatch_info) == 0:
self._views.pop(view._cache_key, None)
self._synced_message_views.pop(view._cache_key, None) # type: ignore
@ -926,7 +989,7 @@ class ViewStore:
self,
component_type: int,
factory: Type[DynamicItem[Item[Any]]],
interaction: Interaction,
interaction: Interaction[ClientT],
custom_id: str,
match: re.Match[str],
) -> None:
@ -977,7 +1040,7 @@ class ViewStore:
except Exception:
_log.exception('Ignoring exception in dynamic item callback for %r', item)
def dispatch_dynamic_items(self, component_type: int, custom_id: str, interaction: Interaction) -> None:
def dispatch_dynamic_items(self, component_type: int, custom_id: str, interaction: Interaction[ClientT]) -> None:
for pattern, item in self._dynamic_items.items():
match = pattern.fullmatch(custom_id)
if match is not None:
@ -988,17 +1051,14 @@ class ViewStore:
)
)
def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction) -> None:
def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction[ClientT]) -> None:
self.dispatch_dynamic_items(component_type, custom_id, interaction)
interaction_id: Optional[int] = None
message_id: Optional[int] = None
# Realistically, in a component based interaction the Interaction.message will never be None
# However, this guard is just in case Discord screws up somehow
msg = interaction.message
if msg is not None:
message_id = msg.id
if msg.interaction_metadata:
interaction_id = msg.interaction_metadata.id
key = (component_type, custom_id)
@ -1007,21 +1067,6 @@ class ViewStore:
if message_id is not None:
item = self._views.get(message_id, {}).get(key)
if item is None and interaction_id is not None:
try:
items = self._views.pop(interaction_id)
except KeyError:
item = None
else:
item = items.get(key)
# If we actually got the items, then these keys should probably be moved
# to the proper message_id instead of the interaction_id as they are now.
# An interaction_id is only used as a temporary stop gap for
# InteractionResponse.send_message so multiple view instances do not
# override each other.
# NOTE: Fix this mess if /callback endpoint ever gets proper return types
self._views.setdefault(message_id, {}).update(items)
if item is None:
# Fallback to None message_id searches in case a persistent view
# was added without an associated message_id
@ -1031,28 +1076,27 @@ class ViewStore:
if item is None:
return
# Note, at this point the View is *not* None
task = item.view._dispatch_item(item, interaction) # type: ignore
if item.view is None:
_log.warning('View interaction referencing unknown view for item %s. Discarding', item)
return
task = item.view._dispatch_item(item, interaction)
if task is not None:
self.add_task(task)
def dispatch_modal(
self,
custom_id: str,
interaction: Interaction,
interaction: Interaction[ClientT],
components: List[ModalSubmitComponentInteractionDataPayload],
resolved: ResolvedDataPayload,
) -> None:
modal = self._modals.get(custom_id)
if modal is None:
_log.debug('Modal interaction referencing unknown custom_id %s. Discarding', custom_id)
return
self.add_task(modal._dispatch_submit(interaction, components))
def remove_interaction_mapping(self, interaction_id: int) -> None:
# This is called before re-adding the view
self._views.pop(interaction_id, None)
self._synced_message_views.pop(interaction_id, None)
self.add_task(modal._dispatch_submit(interaction, components, resolved))
def is_message_tracked(self, message_id: int) -> bool:
return message_id in self._synced_message_views

109
discord/utils.py

@ -26,6 +26,7 @@ from __future__ import annotations
import array
import asyncio
import inspect
from textwrap import TextWrapper
from typing import (
Any,
@ -56,6 +57,8 @@ from typing import (
TYPE_CHECKING,
)
import unicodedata
import collections.abc
from itertools import islice
from base64 import b64encode, b64decode
from bisect import bisect_left
import datetime
@ -71,7 +74,6 @@ import types
import typing
import warnings
import logging
import zlib
import yarl
@ -82,12 +84,20 @@ except ModuleNotFoundError:
else:
HAS_ORJSON = True
_ZSTD_SOURCE: Literal['zstandard', 'compression.zstd'] | None = None
try:
import zstandard # type: ignore
from zstandard import ZstdDecompressor # type: ignore
_ZSTD_SOURCE = 'zstandard'
except ImportError:
_HAS_ZSTD = False
else:
_HAS_ZSTD = True
try:
from compression.zstd import ZstdDecompressor # type: ignore
_ZSTD_SOURCE = 'compression.zstd'
except ImportError:
import zlib
__all__ = (
'oauth_url',
@ -109,6 +119,7 @@ __all__ = (
DISCORD_EPOCH = 1420070400000
DEFAULT_FILE_SIZE_LIMIT_BYTES = 10485760
TIMESTAMP_PATTERN: re.Pattern[str] = re.compile(r'<t:(-?\d+)(?::[tTdDfFsSR])?>')
class _MissingSentinel:
@ -427,7 +438,7 @@ def time_snowflake(dt: datetime.datetime, /, *, high: bool = False) -> int:
def _find(predicate: Callable[[T], Any], iterable: Iterable[T], /) -> Optional[T]:
return next((element for element in iterable if predicate(element)), None)
return next(filter(predicate, iterable), None)
async def _afind(predicate: Callable[[T], Any], iterable: AsyncIterable[T], /) -> Optional[T]:
@ -1030,17 +1041,18 @@ def escape_mentions(text: str) -> str:
def _chunk(iterator: Iterable[T], max_size: int) -> Iterator[List[T]]:
ret = []
n = 0
for item in iterator:
ret.append(item)
n += 1
if n == max_size:
yield ret
ret = []
n = 0
if ret:
yield ret
# Specialise iterators that can be sliced as it is much faster
if isinstance(iterator, collections.abc.Sequence):
for i in range(0, len(iterator), max_size):
yield list(iterator[i : i + max_size])
else:
# Fallback to slower path
iterator = iter(iterator)
while True:
batch = list(islice(iterator, max_size))
if not batch:
break
yield batch
async def _achunk(iterator: AsyncIterable[T], max_size: int) -> AsyncIterator[List[T]]:
@ -1217,7 +1229,7 @@ def is_inside_class(func: Callable[..., Any]) -> bool:
return not remaining.endswith('<locals>')
TimestampStyle = Literal['f', 'F', 'd', 'D', 't', 'T', 'R']
TimestampStyle = Literal['f', 'F', 'd', 'D', 't', 'T', 's', 'S', 'R']
def format_dt(dt: datetime.datetime, /, style: Optional[TimestampStyle] = None) -> str:
@ -1225,23 +1237,27 @@ def format_dt(dt: datetime.datetime, /, style: Optional[TimestampStyle] = None)
This allows for a locale-independent way of presenting data using Discord specific Markdown.
+-------------+----------------------------+-----------------+
| Style | Example Output | Description |
+=============+============================+=================+
| t | 22:57 | Short Time |
+-------------+----------------------------+-----------------+
| T | 22:57:58 | Long Time |
+-------------+----------------------------+-----------------+
| d | 17/05/2016 | Short Date |
+-------------+----------------------------+-----------------+
| D | 17 May 2016 | Long Date |
+-------------+----------------------------+-----------------+
| f (default) | 17 May 2016 22:57 | Short Date Time |
+-------------+----------------------------+-----------------+
| F | Tuesday, 17 May 2016 22:57 | Long Date Time |
+-------------+----------------------------+-----------------+
| R | 5 years ago | Relative Time |
+-------------+----------------------------+-----------------+
+-------------+--------------------------------+-------------------------+
| Style | Example Output | Description |
+=============+================================+=========================+
| t | 22:57 | Short Time |
+-------------+--------------------------------+-------------------------+
| T | 22:57:58 | Medium Time |
+-------------+--------------------------------+-------------------------+
| d | 17/05/2016 | Short Date |
+-------------+--------------------------------+-------------------------+
| D | May 17, 2016 | Long Date |
+-------------+--------------------------------+-------------------------+
| f (default) | May 17, 2016 at 22:57 | Long Date, Short Time |
+-------------+--------------------------------+-------------------------+
| F | Tuesday, May 17, 2016 at 22:57 | Full Date, Short Time |
+-------------+--------------------------------+-------------------------+
| s | 17/05/2016, 22:57 | Short Date, Short Time |
+-------------+--------------------------------+-------------------------+
| S | 17/05/2016, 22:57:58 | Short Date, Medium Time |
+-------------+--------------------------------+-------------------------+
| R | 5 years ago | Relative Time |
+-------------+--------------------------------+-------------------------+
Note that the exact output depends on the user's locale setting in the client. The example output
presented is using the ``en-GB`` locale.
@ -1422,20 +1438,25 @@ def _human_join(seq: Sequence[str], /, *, delimiter: str = ', ', final: str = 'o
return delimiter.join(seq[:-1]) + f' {final} {seq[-1]}'
if _HAS_ZSTD:
if _ZSTD_SOURCE is not None:
class _ZstdDecompressionContext:
__slots__ = ('context',)
__slots__ = ('decompressor',)
COMPRESSION_TYPE: str = 'zstd-stream'
def __init__(self) -> None:
decompressor = zstandard.ZstdDecompressor()
self.context = decompressor.decompressobj()
self.decompressor = ZstdDecompressor()
if _ZSTD_SOURCE == 'zstandard':
# The default API for zstandard requires a size hint when
# the size is not included in the zstandard frame.
# This constructs an instance of zstandard.ZstdDecompressionObj
# which dynamically allocates a buffer, matching stdlib module's behavior.
self.decompressor = self.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')
return self.decompressor.decompress(data).decode('utf-8')
_ActiveDecompressionContext: Type[_DecompressionContext] = _ZstdDecompressionContext
else:
@ -1522,3 +1543,11 @@ class _RawReprMixin:
def __repr__(self) -> str:
value = ' '.join(f'{attr}={getattr(self, attr)!r}' for attr in self.__slots__)
return f'<{self.__class__.__name__} {value}>'
# `inspect.iscoroutinefunction()` only became equivalent to (now deprecated) `asyncio.iscoroutinefunction()` in Python 3.12
# https://github.com/python/cpython/issues/122858#issuecomment-2466239748
if sys.version_info >= (3, 12):
_iscoroutinefunction = inspect.iscoroutinefunction
else:
_iscoroutinefunction = asyncio.iscoroutinefunction

25
discord/voice_client.py

@ -34,7 +34,7 @@ from .gateway import *
from .errors import ClientException
from .player import AudioPlayer, AudioSource
from .utils import MISSING
from .voice_state import VoiceConnectionState
from .voice_state import VoiceConnectionState, has_dave
if TYPE_CHECKING:
from .gateway import DiscordVoiceWebSocket
@ -218,6 +218,8 @@ class VoiceClient(VoiceProtocol):
def __init__(self, client: Client, channel: abc.Connectable) -> None:
if not has_nacl:
raise RuntimeError('PyNaCl library needed in order to use voice')
if not has_dave:
raise RuntimeError('davey library needed in order to use voice')
super().__init__(client, channel)
state = client._connection
@ -235,6 +237,7 @@ class VoiceClient(VoiceProtocol):
self._connection: VoiceConnectionState = self.create_connection_state()
warn_nacl: bool = not has_nacl
warn_dave: bool = not has_dave
supported_modes: Tuple[SupportedModes, ...] = (
'aead_xchacha20_poly1305_rtpsize',
'xsalsa20_poly1305_lite',
@ -284,6 +287,17 @@ class VoiceClient(VoiceProtocol):
def timeout(self) -> float:
return self._connection.timeout
@property
def voice_privacy_code(self) -> Optional[str]:
""":class:`str`: Get the voice privacy code of this E2EE session's group.
A new privacy code is created and cached each time a new transition is executed.
This can be None if there is no active DAVE session happening.
.. versionadded:: 2.7
"""
return self._connection.dave_session.voice_privacy_code if self._connection.dave_session else None
def checked_add(self, attr: str, value: int, limit: int) -> None:
val = getattr(self, attr)
if val + value > limit:
@ -368,7 +382,12 @@ class VoiceClient(VoiceProtocol):
# audio related
def _get_voice_packet(self, data):
def _get_voice_packet(self, data: bytes):
packet = (
self._connection.dave_session.encrypt_opus(data)
if self._connection.dave_session and self._connection.can_encrypt
else data
)
header = bytearray(12)
# Formulate rtp header
@ -379,7 +398,7 @@ class VoiceClient(VoiceProtocol):
struct.pack_into('>I', header, 8, self.ssrc)
encrypt_packet = getattr(self, '_encrypt_' + self.mode)
return encrypt_packet(header, data)
return encrypt_packet(header, packet)
def _encrypt_aead_xchacha20_poly1305_rtpsize(self, header: bytes, data) -> bytes:
# Esentially the same as _lite

70
discord/voice_state.py

@ -69,6 +69,14 @@ if TYPE_CHECKING:
WebsocketHook = Optional[Callable[[DiscordVoiceWebSocket, Dict[str, Any]], Coroutine[Any, Any, Any]]]
SocketReaderCallback = Callable[[bytes], Any]
has_dave: bool
try:
import davey # type: ignore
has_dave = True
except ImportError:
has_dave = False
__all__ = ('VoiceConnectionState',)
@ -208,6 +216,10 @@ class VoiceConnectionState:
self.mode: SupportedModes = MISSING
self.socket: socket.socket = MISSING
self.ws: DiscordVoiceWebSocket = MISSING
self.dave_session: Optional[davey.DaveSession] = None
self.dave_protocol_version: int = 0
self.dave_pending_transitions: Dict[int, int] = {}
self.dave_downgraded: bool = False
self._state: ConnectionFlowState = ConnectionFlowState.disconnected
self._expecting_disconnect: bool = False
@ -252,6 +264,64 @@ class VoiceConnectionState:
def self_voice_state(self) -> Optional[VoiceState]:
return self.guild.me.voice
@property
def max_dave_protocol_version(self) -> int:
return davey.DAVE_PROTOCOL_VERSION if has_dave else 0
@property
def can_encrypt(self) -> bool:
return self.dave_protocol_version != 0 and self.dave_session != None and self.dave_session.ready
async def reinit_dave_session(self) -> None:
if self.dave_protocol_version > 0:
if not has_dave:
raise RuntimeError('davey library needed in order to use E2EE voice')
if self.dave_session is not None:
self.dave_session.reinit(self.dave_protocol_version, self.user.id, self.voice_client.channel.id)
else:
self.dave_session = davey.DaveSession(self.dave_protocol_version, self.user.id, self.voice_client.channel.id)
if self.dave_session is not None:
await self.voice_client.ws.send_binary(
DiscordVoiceWebSocket.MLS_KEY_PACKAGE, self.dave_session.get_serialized_key_package()
)
elif self.dave_session:
self.dave_session.reset()
self.dave_session.set_passthrough_mode(True, 10)
pass
async def _recover_from_invalid_commit(self, transition_id: int) -> None:
payload = {
'op': DiscordVoiceWebSocket.MLS_INVALID_COMMIT_WELCOME,
'd': {
'transition_id': transition_id,
},
}
await self.voice_client.ws.send_as_json(payload)
await self.reinit_dave_session()
async def _execute_transition(self, transition_id: int) -> None:
_log.debug('Executing transition id %d', transition_id)
if transition_id not in self.dave_pending_transitions:
_log.warning("Received execute transition, but we don't have a pending transition for id %d", transition_id)
return
old_version = self.dave_protocol_version
self.dave_protocol_version = self.dave_pending_transitions.pop(transition_id)
if old_version != self.dave_protocol_version and self.dave_protocol_version == 0:
self.dave_downgraded = True
_log.debug('DAVE Session downgraded')
elif transition_id > 0 and self.dave_downgraded:
self.dave_downgraded = False
if self.dave_session:
self.dave_session.set_passthrough_mode(True, 10)
_log.debug('DAVE Session upgraded')
# In the future, the session should be signaled too, but for now theres just v1
_log.debug('Transition id %d executed', transition_id)
async def voice_state_update(self, data: GuildVoiceStatePayload) -> None:
channel_id = data['channel_id']

38
discord/webhook/async_.py

@ -364,6 +364,7 @@ class AsyncWebhookAdapter:
multipart: Optional[List[Dict[str, Any]]] = None,
files: Optional[Sequence[File]] = None,
thread_id: Optional[int] = None,
with_components: bool = False,
) -> Response[MessagePayload]:
route = Route(
'PATCH',
@ -372,7 +373,9 @@ class AsyncWebhookAdapter:
webhook_token=token,
message_id=message_id,
)
params = None if thread_id is None else {'thread_id': thread_id}
params = {'with_components': int(with_components)}
if thread_id:
params['thread_id'] = thread_id
return self.request(
route,
session=session,
@ -848,7 +851,15 @@ class WebhookMessage(Message):
See :meth:`.abc.Messageable.send` for more information.
view: Optional[:class:`~discord.ui.View`]
The updated view to update this message with. If ``None`` is passed then
the view is removed.
the view is removed. If the webhook is partial or is not managed by the
library, then you can not send interactable components. Otherwise, you
can send views with any type of components.
.. note::
To update the message to add a :class:`~discord.ui.LayoutView`, you
must explicitly set the ``content``, ``embed``, ``embeds``, and
``attachments`` parameters to either ``None`` or an empty array, as appropriate.
.. versionadded:: 2.0
@ -1772,7 +1783,7 @@ class Webhook(BaseWebhook):
.. versionadded:: 1.4
view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`]
The view to send with the message. If the webhook is partial or
is not managed by the library, then you can only send URL buttons.
is not managed by the library, then you can not send interactable components.
Otherwise, you can send views with any type of components.
.. versionadded:: 2.0
@ -1857,12 +1868,10 @@ class Webhook(BaseWebhook):
if view is not MISSING:
if not hasattr(view, '__discord_ui_view__'):
raise TypeError(f'expected view parameter to be of type View not {view.__class__.__name__}')
raise TypeError(f'expected view parameter to be of type View or LayoutView, not {view.__class__.__name__}')
if isinstance(self._state, _WebhookState) and view.is_dispatchable():
raise ValueError(
'Webhook views with any component other than URL buttons require an associated state with the webhook'
)
raise ValueError('Webhook views with interactable components require an associated state with the webhook')
if ephemeral is True and view.timeout is None and view.is_dispatchable():
view.timeout = 15 * 60.0
@ -2048,8 +2057,9 @@ class Webhook(BaseWebhook):
See :meth:`.abc.Messageable.send` for more information.
view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]]
The updated view to update this message with. If ``None`` is passed then
the view is removed. The webhook must have state attached, similar to
:meth:`send`.
the view is removed. If the webhook is partial or is not managed by the
library, then you can not send interactable components. Otherwise, you
can send views with any type of components.
.. note::
@ -2085,11 +2095,12 @@ class Webhook(BaseWebhook):
if self.token is None:
raise ValueError('This webhook does not have a token associated with it')
if view is not MISSING:
if isinstance(self._state, _WebhookState):
raise ValueError('This webhook does not have state associated with it')
if view:
if not hasattr(view, '__discord_ui_view__'):
raise TypeError(f'expected view parameter to be of type View or LayoutView, not {view.__class__.__name__}')
self._state.prevent_view_updates_for(message_id)
if isinstance(self._state, _WebhookState) and view.is_dispatchable():
raise ValueError('Webhook views with interactable components require an associated state with the webhook')
previous_mentions: Optional[AllowedMentions] = getattr(self._state, 'allowed_mentions', None)
with handle_message_parameters(
@ -2117,6 +2128,7 @@ class Webhook(BaseWebhook):
multipart=params.multipart,
files=params.files,
thread_id=thread_id,
with_components=bool(view),
)
message = self._create_message(data, thread=thread)

37
discord/webhook/sync.py

@ -329,6 +329,7 @@ class WebhookAdapter:
multipart: Optional[List[Dict[str, Any]]] = None,
files: Optional[Sequence[File]] = None,
thread_id: Optional[int] = None,
with_components: bool = False,
) -> MessagePayload:
route = Route(
'PATCH',
@ -337,7 +338,9 @@ class WebhookAdapter:
webhook_token=token,
message_id=message_id,
)
params = None if thread_id is None else {'thread_id': thread_id}
params = {'with_components': int(with_components)}
if thread_id:
params['thread_id'] = thread_id
return self.request(route, session, payload=payload, multipart=multipart, files=files, params=params)
def delete_webhook_message(
@ -415,6 +418,7 @@ class SyncWebhookMessage(Message):
embed: Optional[Embed] = MISSING,
attachments: Sequence[Union[Attachment, File]] = MISSING,
allowed_mentions: Optional[AllowedMentions] = None,
view: Optional[BaseView] = MISSING,
) -> SyncWebhookMessage:
"""Edits the message.
@ -443,6 +447,19 @@ class SyncWebhookMessage(Message):
allowed_mentions: :class:`AllowedMentions`
Controls the mentions being processed in this message.
See :meth:`.abc.Messageable.send` for more information.
view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`]
The updated view to update this message with. This can only have non-interactible items, which do not
require a state to be attached to it. If ``None`` is passed then the view is removed.
If you want to edit a webhook message with any component attached to it, check :meth:`WebhookMessage.edit`.
.. note::
To update the message to add a :class:`~discord.ui.LayoutView`, you
must explicitly set the ``content``, ``embed``, ``embeds``, and
``attachments`` parameters to either ``None`` or an empty array, as appropriate.
.. versionadded:: 2.7
Raises
-------
@ -451,7 +468,7 @@ class SyncWebhookMessage(Message):
Forbidden
Edited a message that is not yours.
TypeError
You specified both ``embed`` and ``embeds``
You specified both ``embed`` and ``embeds``.
ValueError
The length of ``embeds`` was invalid or
there was no token associated with this webhook.
@ -469,6 +486,7 @@ class SyncWebhookMessage(Message):
attachments=attachments,
allowed_mentions=allowed_mentions,
thread=self._state._thread,
view=view,
)
def add_files(self, *files: File) -> SyncWebhookMessage:
@ -1245,6 +1263,12 @@ class SyncWebhook(BaseWebhook):
If you want to edit a webhook message with any component attached to it, check :meth:`WebhookMessage.edit`.
.. note::
To update the message to add a :class:`~discord.ui.LayoutView`, you
must explicitly set the ``content``, ``embed``, ``embeds``, and
``attachments`` parameters to either ``None`` or an empty array, as appropriate.
.. versionadded:: 2.6
allowed_mentions: :class:`AllowedMentions`
Controls the mentions being processed in this message.
@ -1270,6 +1294,13 @@ class SyncWebhook(BaseWebhook):
if self.token is None:
raise ValueError('This webhook does not have a token associated with it')
if view:
if not hasattr(view, '__discord_ui_view__'):
raise TypeError(f'expected view parameter to be of type View or LayoutView, not {view.__class__.__name__}')
if view.is_dispatchable():
raise ValueError('SyncWebhooks can not send interactable components')
previous_mentions: Optional[AllowedMentions] = getattr(self._state, 'allowed_mentions', None)
with handle_message_parameters(
content=content,
@ -1278,6 +1309,7 @@ class SyncWebhook(BaseWebhook):
embeds=embeds,
allowed_mentions=allowed_mentions,
previous_allowed_mentions=previous_mentions,
view=view,
) as params:
thread_id: Optional[int] = None
if thread is not MISSING:
@ -1293,6 +1325,7 @@ class SyncWebhook(BaseWebhook):
multipart=params.multipart,
files=params.files,
thread_id=thread_id,
with_components=bool(view),
)
return self._create_message(data, thread=thread)

4
docs/_static/style.css

@ -387,7 +387,7 @@ aside {
background-color: var(--mobile-nav-background);
color: var(--mobile-nav-text);
z-index: 2;
max-height: 100vh;
max-height: 100dvh;
overflow-y: auto;
overscroll-behavior-y: contain;
}
@ -1285,7 +1285,7 @@ div.code-block-caption {
display: inline-block;
position: sticky;
top: 1em;
max-height: calc(100vh - 2em);
max-height: calc(100dvh - 2em);
max-width: 100%;
overflow-y: auto;
margin: 1em;

16
docs/api.rst

@ -1732,6 +1732,15 @@ of :class:`enum.Enum`.
Checks if two messages are not equal.
.. method:: is_deletable()
Checks if the message type is deletable, as some system messages cannot be deleted.
.. versionadded:: 2.7
:return: A boolean denoting if the message type is deletable.
:rtype: :class:`bool`
.. attribute:: default
The default message type. This is the same as regular messages.
@ -2824,6 +2833,7 @@ of :class:`enum.Enum`.
which was created.
Possible attributes for :class:`AuditLogDiff`:
- :attr:`~AuditLogDiff.name`
- :attr:`~AuditLogDiff.channel`
- :attr:`~AuditLogDiff.description`
@ -2843,6 +2853,7 @@ of :class:`enum.Enum`.
which was updated.
Possible attributes for :class:`AuditLogDiff`:
- :attr:`~AuditLogDiff.name`
- :attr:`~AuditLogDiff.channel`
- :attr:`~AuditLogDiff.description`
@ -2862,6 +2873,7 @@ of :class:`enum.Enum`.
which was deleted.
Possible attributes for :class:`AuditLogDiff`:
- :attr:`~AuditLogDiff.name`
- :attr:`~AuditLogDiff.channel`
- :attr:`~AuditLogDiff.description`
@ -5441,6 +5453,7 @@ CategoryChannel
.. autoclass:: CategoryChannel()
:members:
:inherited-members:
:exclude-members: category
DMChannel
~~~~~~~~~
@ -6203,6 +6216,8 @@ The following exceptions are thrown by the library.
.. autoexception:: MissingApplicationID
.. autoexception:: FFmpegProcessError
.. autoexception:: discord.opus.OpusError
.. autoexception:: discord.opus.OpusNotLoaded
@ -6221,6 +6236,7 @@ Exception Hierarchy
- :exc:`PrivilegedIntentsRequired`
- :exc:`InteractionResponded`
- :exc:`MissingApplicationID`
- :exc:`FFmpegProcessError`
- :exc:`GatewayNotFound`
- :exc:`HTTPException`
- :exc:`Forbidden`

5
docs/ext/commands/api.rst

@ -536,6 +536,11 @@ Converters
.. autoclass:: discord.ext.commands.SoundboardSoundConverter
:members:
.. attributetable:: discord.ext.commands.Timestamp
.. autoclass:: discord.ext.commands.Timestamp
:members:
.. attributetable:: discord.ext.commands.clean_content
.. autoclass:: discord.ext.commands.clean_content

26
docs/ext/commands/commands.rst

@ -485,7 +485,7 @@ commands in an easy to use manner.
typing.Union
^^^^^^^^^^^^^^
A :data:`typing.Union` is a special type hint that allows for the command to take in any of the specific types instead of
A :obj:`typing.Union` is a special type hint that allows for the command to take in any of the specific types instead of
a singular type. For example, given the following:
.. code-block:: python3
@ -502,12 +502,12 @@ The way this works is through a left-to-right order. It first attempts to conver
:class:`discord.TextChannel`, and if it fails it tries to convert it to a :class:`discord.Member`. If all converters fail,
then a special error is raised, :exc:`~ext.commands.BadUnionArgument`.
Note that any valid converter discussed above can be passed in to the argument list of a :data:`typing.Union`.
Note that any valid converter discussed above can be passed in to the argument list of a :obj:`typing.Union`.
typing.Optional
^^^^^^^^^^^^^^^^^
A :data:`typing.Optional` is a special type hint that allows for "back-referencing" behaviour. If the converter fails to
A :obj:`typing.Optional` is a special type hint that allows for "back-referencing" behaviour. If the converter fails to
parse into the specified type, the parser will skip the parameter and then either ``None`` or the specified default will be
passed into the parameter instead. The parser will then continue on to the next parameters and converters, if any.
@ -536,7 +536,7 @@ typing.Literal
.. versionadded:: 2.0
A :data:`typing.Literal` is a special type hint that requires the passed parameter to be equal to one of the listed values
A :obj:`typing.Literal` is a special type hint that requires the passed parameter to be equal to one of the listed values
after being converted to the same type. For example, given the following:
.. code-block:: python3
@ -550,7 +550,7 @@ after being converted to the same type. For example, given the following:
The ``buy_sell`` parameter must be either the literal string ``"buy"`` or ``"sell"`` and ``amount`` must convert to the
``int`` ``1`` or ``2``. If ``buy_sell`` or ``amount`` don't match any value, then a special error is raised,
:exc:`~.ext.commands.BadLiteralArgument`. Any literal values can be mixed and matched within the same :data:`typing.Literal` converter.
:exc:`~.ext.commands.BadLiteralArgument`. Any literal values can be mixed and matched within the same :obj:`typing.Literal` converter.
Note that ``typing.Literal[True]`` and ``typing.Literal[False]`` still follow the :class:`bool` converter rules.
@ -559,7 +559,7 @@ typing.Annotated
.. versionadded:: 2.0
A :data:`typing.Annotated` is a special type introduced in Python 3.9 that allows the type checker to see one type, but allows the library to see another type. This is useful for appeasing the type checker for complicated converters. The second parameter of ``Annotated`` must be the converter that the library should use.
A :obj:`typing.Annotated` is a special type introduced in Python 3.9 that allows the type checker to see one type, but allows the library to see another type. This is useful for appeasing the type checker for complicated converters. The second parameter of ``Annotated`` must be the converter that the library should use.
For example, given the following:
@ -581,7 +581,7 @@ The type checker will see ``arg`` as a regular :class:`str` but the library will
Greedy
^^^^^^^^
The :class:`~ext.commands.Greedy` converter is a generalisation of the :data:`typing.Optional` converter, except applied
The :class:`~ext.commands.Greedy` converter is a generalisation of the :obj:`typing.Optional` converter, except applied
to a list of arguments. In simple terms, this means that it tries to convert as much as it can until it can't convert
any further.
@ -606,7 +606,7 @@ The type passed when using this converter depends on the parameter type that it
:class:`~ext.commands.Greedy` parameters can also be made optional by specifying an optional value.
When mixed with the :data:`typing.Optional` converter you can provide simple and expressive command invocation syntaxes:
When mixed with the :obj:`typing.Optional` converter you can provide simple and expressive command invocation syntaxes:
.. code-block:: python3
@ -632,16 +632,16 @@ This command can be invoked any of the following ways:
.. warning::
The usage of :class:`~ext.commands.Greedy` and :data:`typing.Optional` are powerful and useful, however as a
The usage of :class:`~ext.commands.Greedy` and :obj:`typing.Optional` are powerful and useful, however as a
price, they open you up to some parsing ambiguities that might surprise some people.
For example, a signature expecting a :data:`typing.Optional` of a :class:`discord.Member` followed by a
For example, a signature expecting a :obj:`typing.Optional` of a :class:`discord.Member` followed by a
:class:`int` could catch a member named after a number due to the different ways a
:class:`~ext.commands.MemberConverter` decides to fetch members. You should take care to not introduce
unintended parsing ambiguities in your code. One technique would be to clamp down the expected syntaxes
allowed through custom converters or reordering the parameters to minimise clashes.
To help aid with some parsing ambiguities, :class:`str`, ``None``, :data:`typing.Optional` and
To help aid with some parsing ambiguities, :class:`str`, ``None``, :obj:`typing.Optional` and
:class:`~ext.commands.Greedy` are forbidden as parameters for the :class:`~ext.commands.Greedy` converter.
@ -663,7 +663,7 @@ Consider the following example:
await ctx.send(f'You have uploaded <{attachment.url}>')
When this command is invoked, the user must directly upload a file for the command body to be executed. When combined with the :data:`typing.Optional` converter, the user does not have to provide an attachment.
When this command is invoked, the user must directly upload a file for the command body to be executed. When combined with the :obj:`typing.Optional` converter, the user does not have to provide an attachment.
.. code-block:: python3
@ -809,7 +809,7 @@ In order to customise the flag syntax we also have a few options that can be pas
topic: Optional[str]
nsfw: Optional[bool]
slowmode: Optional[int]
# Hello there --bold True
class Greeting(commands.FlagConverter):
text: str = commands.flag(positional=True)

128
docs/interactions/api.rst

@ -193,6 +193,43 @@ Container
:inherited-members:
FileUploadComponent
~~~~~~~~~~~~~~~~~~~~
.. attributetable:: FileUploadComponent
.. autoclass:: FileUploadComponent()
:members:
:inherited-members:
RadioGroupComponent
~~~~~~~~~~~~~~~~~~~
.. attributetable:: RadioGroupComponent
.. autoclass:: RadioGroupComponent()
:members:
:inherited-members:
CheckboxComponent
~~~~~~~~~~~~~~~~~
.. attributetable:: CheckboxComponent
.. autoclass:: CheckboxComponent()
:members:
:inherited-members:
CheckboxGroupComponent
~~~~~~~~~~~~~~~~~~~~~~
.. attributetable:: CheckboxGroupComponent
.. autoclass:: CheckboxGroupComponent()
:members:
:inherited-members:
AppCommand
~~~~~~~~~~~
@ -320,6 +357,21 @@ MediaGalleryItem
.. autoclass:: MediaGalleryItem
:members:
RadioGroupOption
~~~~~~~~~~~~~~~~
.. attributetable:: RadioGroupOption
.. autoclass:: RadioGroupOption()
:members:
CheckboxGroupOption
~~~~~~~~~~~~~~~~~~~
.. attributetable:: CheckboxGroupOption
.. autoclass:: CheckboxGroupOption()
:members:
Enumerations
-------------
@ -479,6 +531,30 @@ Enumerations
.. versionadded:: 2.6
.. attribute:: file_upload
Represents a file upload component, usually in a modal.
.. versionadded:: 2.7
.. attribute:: radio_group
Represents a radio group component.
.. versionadded:: 2.7
.. attribute:: checkbox_group
Represents a checkbox group component.
.. versionadded:: 2.7
.. attribute:: checkbox
Represents a checkbox component.
.. versionadded:: 2.7
.. class:: ButtonStyle
Represents the style of the button component.
@ -855,6 +931,50 @@ ActionRow
:inherited-members:
:exclude-members: callback
FileUpload
~~~~~~~~~~~
.. attributetable:: discord.ui.FileUpload
.. autoclass:: discord.ui.FileUpload
:members:
:inherited-members:
:exclude-members: callback, interaction_check
RadioGroup
~~~~~~~~~~~
.. attributetable:: discord.ui.RadioGroup
.. autoclass:: discord.ui.RadioGroup
:members:
:inherited-members:
:exclude-members: callback, interaction_check
Checkbox
~~~~~~~~~
.. attributetable:: discord.ui.Checkbox
.. autoclass:: discord.ui.Checkbox
:members:
:inherited-members:
:exclude-members: callback, interaction_check
CheckboxGroup
~~~~~~~~~~~~~~
.. attributetable:: discord.ui.CheckboxGroup
.. autoclass:: discord.ui.CheckboxGroup
:members:
:inherited-members:
:exclude-members: callback, interaction_check
.. _discord_app_commands:
Application Commands
@ -1049,6 +1169,14 @@ Range
.. autoclass:: discord.app_commands.Range
:members:
Timestamp
++++++++++
.. attributetable:: discord.app_commands.Timestamp
.. autoclass:: discord.app_commands.Timestamp
:members:
Translations
~~~~~~~~~~~~~

105
docs/whats_new.rst

@ -11,6 +11,111 @@ Changelog
This page keeps a detailed human friendly rendering of what's new and changed
in specific versions.
.. _vp2p7p1:
v2.7.1
-------
Bug Fixes
~~~~~~~~~~
- Fix memory leak when using :class:`ui.LayoutView` and removing items but those items not being removed from internal cache.
- Fix ``aiohttp`` deprecation warning for websocket timeouts (:issue:`10418`)
Miscellaneous
~~~~~~~~~~~~~~
- Show ``davey`` dependency output in ``python -m discord --version`` to debug DAVE issues
- Raise an error and warn when ``davey`` is not installed and using voice
- Change how views are bound to the internal cache when using interactions
.. _vp2p7p0:
v2.7.0
-------
New Features
~~~~~~~~~~~~~
- Add DAVE protocol support for voice connections (:issue:`10300`)
- Add support for new :class:`ui.Modal` components (:issue:`10390`)
- :class:`CheckboxGroupComponent` corresponds to :class:`ui.CheckboxGroup`
- :class:`CheckboxComponent` corresponds to :class:`ui.Checkbox`
- :class:`RadioGroupComponent` corresponds to :class:`ui.RadioGroup`
- :class:`CheckboxGroupOption` and :class:`RadioGroupOption` allow creating these options
- Add timestamp converter and transformer for use with new ``@time`` markdown option (:issue:`10388`)
- This is accessible via :class:`app_commands.Timestamp` and :class:`ext.commands.Timestamp` as an annotation
- Add several new permissions:
- :attr:`Permissions.bypass_slowmode` (:issue:`10350`)
- :attr:`Permissions.set_voice_channel_status` (:issue:`10279`)
- :attr:`Permissions.pin_messages`
- Add ``client`` parameter to :meth:`PartialEmoji.from_str` (:issue:`10407`)
- Add support for user collectibles accessible via :attr:`User.collectibles` and :attr:`Member.collectibles` (:issue:`10277`)
- Add :meth:`Message.is_forwardable` to check if a message can be forwarded (:issue:`10353`)
- Add support for getting an integration's scopes (:issue:`10352`)
- Add :attr:`Interaction.command_id` and :attr:`Interaction.custom_id` helpers (:issue:`10321`)
- Support new fields in :meth:`Member.edit` (:issue:`10303`)
- Add support for getting role member counts via :meth:`Guild.role_member_counts`
- Add :attr:`MessageType.is_deletable`
- Add ``reason`` keyword argument to :meth:`Client.delete_invite` (:issue:`10318`, :issue:`10340`)
- Add ``silent`` parameter to :meth:`ForumChannel.create_thread` (:issue:`10304`)
- Add support for :attr:`MessageType.emoji_added` (:issue:`10284`)
- Add channel attribute to automod quarantine user AuditLogAction (:issue:`10274`)
Bug Fixes
~~~~~~~~~~
- Fix FFmpeg errors not sent to after callback (:issue:`10387`)
- Fix :meth:`Webhook.edit_message` missing the view parameter (:issue:`10395`, :issue:`10398`)
- Fix :meth:`TextChannel.purge` failing when encountering certain system messages
- Fix :attr:`Message.call` raising an attribute error when accessed (:issue:`10404`)
- Fix certain component IDs not being able to be settable afterwards
- Fix :class:`ui.Modal` not raising when hitting the 5 item limit
- Fix :attr:`ui.Item.row` not being set appropriately when used in a :class:`ui.Modal` (:issue:`10397`)
- Fix ``compression.zstd`` not working as expected when Discord does not send encoding information (:issue:`10344`)
- Fix rare bug where :attr:`Client.latency` was incorrect due to not updating heartbeat state
- Fix overzealous exporting of symbols within an internal ``primary_guild`` module (:issue:`10295`)
- Close websocket when reconnecting websocket during polling (:issue:`10409`)
- Use :meth:`ui.View.walk_children` when removing items from the view cache (:issue:`10402`)
- |commands| Fix flag annotations not working under Python 3.14
- |commands| Fix decorator order mattering for hybrid commands
- |commands| Fix :meth:`~ext.commands.Context.from_interaction` derived :attr:`Message.type` being incorrect
Miscellaneous
~~~~~~~~~~~~~~
- Allow :class:`ui.View` initialization without a running event loop (:issue:`10367`)
- Optimise :func:`utils.find` and specialise :func:`utils.as_chunks` (:issue:`10351`)
- Detach :attr:`ui.Item.view` when the item is removed (:issue:`10348`)
- Change ``description`` to be optional when creating emoji (:issue:`10346`)
- Don't assume Python 3.14 always has ``compression.zstd`` (:issue:`10328`)
- Use webp as the default emoji URL format
- |tasks| Log handled exceptions before sleeping
.. _vp2p6p4:
v2.6.4
-------
Bug Fixes
~~~~~~~~~~
- Fix :class:`InviteType` and :class:`ReactionType` not being exported (:issue:`10310`)
- Fix :class:`ui.Modal` submits not working for components without a ``custom_id`` (:issue:`10307`)
- Fix ``required`` keyword argument missing in most :class:`ui.Select` classes (:issue:`10307`)
- Fix incorrect handling of :class:`ui.Modal` submit data when using selects (:issue:`10307`)
- Fix potential exception when assigning :attr:`ui.Container.children`
- Fix :attr:`ui.Section.accessory` setter not updating internal state leading to an exception
Miscellaneous
~~~~~~~~~~~~~~
- Use ``compression.zstd`` from the standard library if available on Python 3.14 (:issue:`10323`)
.. _vp2p6p3:
v2.6.3

143
examples/modals/report.py

@ -0,0 +1,143 @@
import discord
from discord import app_commands
import traceback
# The guild in which this slash command will be registered.
# It is recommended to have a test guild to separate from your "production" bot
TEST_GUILD = discord.Object(0)
# The ID of the channel where reports will be sent to
REPORTS_CHANNEL_ID = 0
class MyClient(discord.Client):
# Suppress error on the User attribute being None since it fills up later
user: discord.ClientUser
def __init__(self) -> None:
# Just default intents and a `discord.Client` instance
# We don't need a `commands.Bot` instance because we are not
# creating text-based commands.
intents = discord.Intents.default()
super().__init__(intents=intents)
# We need an `discord.app_commands.CommandTree` instance
# to register application commands (slash commands in this case)
self.tree = app_commands.CommandTree(self)
async def on_ready(self):
print(f'Logged in as {self.user} (ID: {self.user.id})')
print('------')
async def setup_hook(self) -> None:
await self.tree.sync(guild=TEST_GUILD)
# Define a modal dialog for reporting issues or feedback
class ReportModal(discord.ui.Modal, title='Your Report'):
topic = discord.ui.Label(
text='Topic',
description='Select the topic of the report.',
component=discord.ui.Select(
placeholder='Choose a topic...',
options=[
discord.SelectOption(label='Bug', description='Report a bug in the bot'),
discord.SelectOption(label='Feedback', description='Provide feedback or suggestions'),
discord.SelectOption(label='Feature Request', description='Request a new feature'),
discord.SelectOption(label='Performance', description='Report performance issues'),
discord.SelectOption(label='UI/UX', description='Report user interface or experience issues'),
discord.SelectOption(label='Security', description='Report security vulnerabilities'),
discord.SelectOption(label='Other', description='Other types of reports'),
],
),
)
report_title = discord.ui.Label(
text='Title',
description='A short title for the report.',
component=discord.ui.TextInput(
style=discord.TextStyle.short,
placeholder='The bot does not respond to commands',
max_length=120,
),
)
description = discord.ui.Label(
text='Description',
description='A detailed description of the report.',
component=discord.ui.TextInput(
style=discord.TextStyle.paragraph,
placeholder='When I use /ping, the bot does not respond at all. There are no error messages.',
max_length=2000,
),
)
images = discord.ui.Label(
text='Images',
description='Upload any relevant images for your report (optional).',
component=discord.ui.FileUpload(
max_values=10,
custom_id='report_images',
required=False,
),
)
footer = discord.ui.TextDisplay(
'Please ensure your report follows the server rules. Any kind of abuse will result in a ban.'
)
def to_view(self, interaction: discord.Interaction) -> discord.ui.LayoutView:
# Tell the type checker what our components are...
assert isinstance(self.topic.component, discord.ui.Select)
assert isinstance(self.description.component, discord.ui.TextInput)
assert isinstance(self.report_title.component, discord.ui.TextInput)
assert isinstance(self.images.component, discord.ui.FileUpload)
topic = self.topic.component.values[0]
title = self.report_title.component.value
description = self.description.component.value
files = self.images.component.values
view = discord.ui.LayoutView()
container = discord.ui.Container()
view.add_item(container)
container.add_item(discord.ui.TextDisplay(f'-# User Report\n## {topic}'))
timestamp = discord.utils.format_dt(interaction.created_at, 'F')
footer = discord.ui.TextDisplay(f'-# Reported by {interaction.user} (ID: {interaction.user.id}) | {timestamp}')
container.add_item(discord.ui.TextDisplay(f'### {title}'))
container.add_item(discord.ui.TextDisplay(f'>>> {description}'))
if files:
gallery = discord.ui.MediaGallery()
gallery.items = [discord.MediaGalleryItem(media=attachment.url) for attachment in files]
container.add_item(gallery)
container.add_item(footer)
return view
async def on_submit(self, interaction: discord.Interaction[MyClient]):
view = self.to_view(interaction)
# Send the report to the designated channel
reports_channel = interaction.client.get_partial_messageable(REPORTS_CHANNEL_ID)
await reports_channel.send(view=view)
await interaction.response.send_message('Thank you for your report! We will look into it shortly.', ephemeral=True)
async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:
await interaction.response.send_message('Oops! Something went wrong.', ephemeral=True)
# Make sure we know what the error actually is
traceback.print_exception(type(error), error, error.__traceback__)
client = MyClient()
@client.tree.command(guild=TEST_GUILD, description='Report an issue or provide feedback.')
async def report(interaction: discord.Interaction):
# Send the modal with an instance of our `ReportModal` class
# Since modals require an interaction, they cannot be done as a response to a text command.
# They can only be done as a response to either an application command or a button press.
await interaction.response.send_modal(ReportModal())
client.run('token')

21
pyproject.toml

@ -36,7 +36,10 @@ Documentation = "https://discordpy.readthedocs.io/en/latest/"
dependencies = { file = "requirements.txt" }
[project.optional-dependencies]
voice = ["PyNaCl>=1.5.0,<1.6"]
voice = [
"PyNaCl>=1.6.0,<1.7",
"davey>=0.1.0"
]
docs = [
"sphinx==4.4.0",
"sphinxcontrib_trio==1.1.2",
@ -58,7 +61,7 @@ speed = [
"aiodns>=1.1; sys_platform != 'win32'",
"Brotli",
"cchardet==2.1.7; python_version < '3.10'",
"zstandard>=0.23.0"
"zstandard>=0.23.0; python_version <= '3.13'"
]
test = [
"coverage[toml]",
@ -86,12 +89,12 @@ packages = [
]
include-package-data = true
[tool.black]
line-length = 125
skip-string-normalization = true
[tool.ruff]
line-length = 125
extend-exclude = ["docs", "tests"]
[tool.ruff.lint.isort]
combine-as-imports = true
[tool.ruff.format]
line-ending = "lf"
@ -110,12 +113,6 @@ exclude_lines = [
"@overload",
]
[tool.isort]
profile = "black"
combine_as_imports = true
combine_star = true
line_length = 125
[tool.pyright]
include = [
"discord",

Loading…
Cancel
Save