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. 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 ### Git Commit Guidelines
- Use present tense (e.g. "Add feature" not "Added feature") - Use present tense (e.g. "Add feature" not "Added feature")

2
.github/workflows/lint.yml

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

2
.gitignore

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

4
discord/__init__.py

@ -13,7 +13,7 @@ __title__ = 'discord'
__author__ = 'Rapptz' __author__ = 'Rapptz'
__license__ = 'MIT' __license__ = 'MIT'
__copyright__ = 'Copyright 2015-present Rapptz' __copyright__ = 'Copyright 2015-present Rapptz'
__version__ = '2.7.0a' __version__ = '2.8.0a'
__path__ = __import__('pkgutil').extend_path(__path__, __name__) __path__ = __import__('pkgutil').extend_path(__path__, __name__)
@ -86,7 +86,7 @@ class VersionInfo(NamedTuple):
serial: int 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()) 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' - discord.py metadata: v{version}')
entries.append(f'- aiohttp v{aiohttp.__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() uname = platform.uname()
entries.append('- system info: {0.system} {0.release} {0.version}'.format(uname)) entries.append('- system info: {0.system} {0.release} {0.version}'.format(uname))
print('\n'.join(entries)) print('\n'.join(entries))

5
discord/abc.py

@ -195,6 +195,9 @@ async def _purge_helper(
count = 0 count = 0
await asyncio.sleep(1) await asyncio.sleep(1)
if not message.type.is_deletable():
continue
if not check(message): if not check(message):
continue continue
@ -818,7 +821,7 @@ class GuildChannel:
if obj.is_default(): if obj.is_default():
return base 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: if overwrite is not None:
base.handle_overwrite(overwrite.allow, overwrite.deny) 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 ..user import User
from ..member import Member from ..member import Member
from ..permissions import Permissions 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: if TYPE_CHECKING:
from typing_extensions import ParamSpec, Concatenate, Unpack from typing_extensions import ParamSpec, Concatenate, Unpack
@ -346,7 +355,7 @@ def _populate_autocomplete(params: Dict[str, CommandParameter], autocomplete: Di
if callback is MISSING: if callback is MISSING:
continue continue
if not inspect.iscoroutinefunction(callback): if not _iscoroutinefunction(callback):
raise TypeError('autocomplete callback must be a coroutine function') raise TypeError('autocomplete callback must be a coroutine function')
if param.type not in (AppCommandOptionType.string, AppCommandOptionType.number, AppCommandOptionType.integer): 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. 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.') raise TypeError('The error handler must be a coroutine.')
self.on_error = coro self.on_error = coro
@ -1098,7 +1107,7 @@ class Command(Generic[GroupT, P, T]):
""" """
def decorator(coro: AutocompleteCallback[GroupT, ChoiceT]) -> AutocompleteCallback[GroupT, ChoiceT]: 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.') raise TypeError('The autocomplete callback must be a coroutine function.')
try: try:
@ -1347,7 +1356,7 @@ class ContextMenu:
The coroutine passed is not actually a coroutine. 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.') raise TypeError('The error handler must be a coroutine.')
self.on_error = coro self.on_error = coro
@ -1802,7 +1811,7 @@ class Group:
yield from command.walk_commands() yield from command.walk_commands()
@mark_overrideable @mark_overrideable
async def on_error(self, interaction: Interaction, error: AppCommandError, /) -> None: async def on_error(self, interaction: Interaction[ClientT], error: AppCommandError, /) -> None:
"""|coro| """|coro|
A callback that is called when a child's command raises an :exc:`AppCommandError`. 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. 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.') raise TypeError('The error handler must be a coroutine.')
params = inspect.signature(coro).parameters params = inspect.signature(coro).parameters
@ -1850,7 +1859,7 @@ class Group:
self.on_error = coro # type: ignore self.on_error = coro # type: ignore
return coro return coro
async def interaction_check(self, interaction: Interaction, /) -> bool: async def interaction_check(self, interaction: Interaction[ClientT], /) -> bool:
"""|coro| """|coro|
A callback that is called when an interaction happens within the group 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]: 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') raise TypeError('command function must be a coroutine function')
if description is MISSING: if description is MISSING:
@ -2051,7 +2060,7 @@ def command(
""" """
def decorator(func: CommandCallback[GroupT, P, T]) -> Command[GroupT, P, T]: 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') raise TypeError('command function must be a coroutine function')
if description is MISSING: if description is MISSING:
@ -2123,7 +2132,7 @@ def context_menu(
""" """
def decorator(func: ContextMenuCallback) -> ContextMenu: def decorator(func: ContextMenuCallback) -> ContextMenu:
if not inspect.iscoroutinefunction(func): if not _iscoroutinefunction(func):
raise TypeError('context menu function must be a coroutine function') raise TypeError('context menu function must be a coroutine function')
actual_name = func.__name__.title() if name is MISSING else name 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: def decorator(inner: T) -> T:
if isinstance(inner, Command): unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner
_populate_descriptions(inner._params, parameters) if isinstance(unwrapped, Command):
_populate_descriptions(unwrapped._params, parameters)
else: else:
try: try:
inner.__discord_app_commands_param_description__.update(parameters) # type: ignore # Runtime attribute access 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: def decorator(inner: T) -> T:
if isinstance(inner, Command): unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner
_populate_renames(inner._params, parameters) if isinstance(unwrapped, Command):
_populate_renames(unwrapped._params, parameters)
else: else:
try: try:
inner.__discord_app_commands_param_rename__.update(parameters) # type: ignore # Runtime attribute access 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: def decorator(inner: T) -> T:
if isinstance(inner, Command): unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner
_populate_choices(inner._params, parameters) if isinstance(unwrapped, Command):
_populate_choices(unwrapped._params, parameters)
else: else:
try: try:
inner.__discord_app_commands_param_choices__.update(parameters) # type: ignore # Runtime attribute access 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: def decorator(inner: T) -> T:
if isinstance(inner, Command): unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner
_populate_autocomplete(inner._params, parameters) if isinstance(unwrapped, Command):
_populate_autocomplete(unwrapped._params, parameters)
else: else:
try: try:
inner.__discord_app_commands_param_autocomplete__.update(parameters) # type: ignore # Runtime attribute access 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] defaults: List[int] = [g if isinstance(g, int) else g.id for g in guild_ids]
def decorator(inner: T) -> T: def decorator(inner: T) -> T:
if isinstance(inner, (Group, ContextMenu)): unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner
inner._guild_ids = defaults if isinstance(unwrapped, (Group, ContextMenu)):
elif isinstance(inner, Command): unwrapped._guild_ids = defaults
if inner.parent is not None: elif isinstance(unwrapped, Command):
if unwrapped.parent is not None:
raise ValueError('child commands of a group cannot have default guilds set') raise ValueError('child commands of a group cannot have default guilds set')
inner._guild_ids = defaults unwrapped._guild_ids = defaults
else: else:
# Runtime attribute assignment # Runtime attribute assignment
inner.__discord_app_commands_default_guilds__ = defaults # type: ignore 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: def decorator(func: CheckInputParameter) -> CheckInputParameter:
if isinstance(func, (Command, ContextMenu)): unwrapped = getattr(func, '__discord_app_commands_unwrap__', func) or func
func.checks.append(predicate) if isinstance(unwrapped, (Command, ContextMenu)):
unwrapped.checks.append(predicate)
else: else:
if not hasattr(func, '__discord_app_commands_checks__'): 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 return func
@ -2513,10 +2528,11 @@ def guild_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]:
""" """
def inner(f: T) -> T: def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)): unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
f.guild_only = True if isinstance(unwrapped, (Command, Group, ContextMenu)):
allowed_contexts = f.allowed_contexts or AppCommandContext() unwrapped.guild_only = True
f.allowed_contexts = allowed_contexts allowed_contexts = unwrapped.allowed_contexts or AppCommandContext()
unwrapped.allowed_contexts = allowed_contexts
else: else:
f.__discord_app_commands_guild_only__ = True # type: ignore # Runtime attribute assignment 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: def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)): unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
f.guild_only = False if isinstance(unwrapped, (Command, Group, ContextMenu)):
allowed_contexts = f.allowed_contexts or AppCommandContext() unwrapped.guild_only = False
f.allowed_contexts = allowed_contexts allowed_contexts = unwrapped.allowed_contexts or AppCommandContext()
unwrapped.allowed_contexts = allowed_contexts
else: else:
allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext()
f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment 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: def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)): unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
f.guild_only = False if isinstance(unwrapped, (Command, Group, ContextMenu)):
allowed_contexts = f.allowed_contexts or AppCommandContext() unwrapped.guild_only = False
f.allowed_contexts = allowed_contexts allowed_contexts = unwrapped.allowed_contexts or AppCommandContext()
unwrapped.allowed_contexts = allowed_contexts
else: else:
allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext()
f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment 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: def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)): unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
f.guild_only = False if isinstance(unwrapped, (Command, Group, ContextMenu)):
allowed_contexts = f.allowed_contexts or AppCommandContext() unwrapped.guild_only = False
f.allowed_contexts = allowed_contexts allowed_contexts = unwrapped.allowed_contexts or AppCommandContext()
unwrapped.allowed_contexts = allowed_contexts
else: else:
allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext()
f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment 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: def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)): unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
allowed_installs = f.allowed_installs or AppInstallationType() if isinstance(unwrapped, (Command, Group, ContextMenu)):
f.allowed_installs = allowed_installs allowed_installs = unwrapped.allowed_installs or AppInstallationType()
unwrapped.allowed_installs = allowed_installs
else: else:
allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType() 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 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: def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)): unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
allowed_installs = f.allowed_installs or AppInstallationType() if isinstance(unwrapped, (Command, Group, ContextMenu)):
f.allowed_installs = allowed_installs allowed_installs = unwrapped.allowed_installs or AppInstallationType()
unwrapped.allowed_installs = allowed_installs
else: else:
allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType() 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 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: def inner(f: T) -> T:
if isinstance(f, (Command, Group, ContextMenu)): unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f
allowed_installs = f.allowed_installs or AppInstallationType() if isinstance(unwrapped, (Command, Group, ContextMenu)):
f.allowed_installs = allowed_installs allowed_installs = unwrapped.allowed_installs or AppInstallationType()
unwrapped.allowed_installs = allowed_installs
else: else:
allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType() 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 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) permissions = Permissions(**perms)
def decorator(func: T) -> T: def decorator(func: T) -> T:
if isinstance(func, (Command, Group, ContextMenu)): unwrapped = getattr(func, '__discord_app_commands_unwrap__', func) or func
func.default_permissions = permissions if isinstance(unwrapped, (Command, Group, ContextMenu)):
unwrapped.default_permissions = permissions
else: else:
func.__discord_app_commands_default_permissions__ = permissions # type: ignore # Runtime attribute assignment 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` slowmode_delay: :class:`int`
The number of seconds a member must wait between sending messages The number of seconds a member must wait between sending messages
in this channel. A value of ``0`` denotes that it is disabled. in this channel. A value of ``0`` denotes that it is disabled.
Bots and users with :attr:`~discord.Permissions.manage_channels` or Bots and users with :attr:`~discord.Permissions.bypass_slowmode` bypass slowmode.
:attr:`~discord.Permissions.manage_messages` bypass slowmode.
.. versionadded:: 2.6 .. versionadded:: 2.6
nsfw: :class:`bool` nsfw: :class:`bool`
@ -779,8 +778,7 @@ class AppCommandThread(Hashable):
slowmode_delay: :class:`int` slowmode_delay: :class:`int`
The number of seconds a member must wait between sending messages The number of seconds a member must wait between sending messages
in this thread. A value of ``0`` denotes that it is disabled. in this thread. A value of ``0`` denotes that it is disabled.
Bots and users with :attr:`~discord.Permissions.manage_channels` or Bots and users with :attr:`~discord.Permissions.bypass_slowmode` bypass slowmode.
:attr:`~discord.Permissions.manage_messages` bypass slowmode.
.. versionadded:: 2.6 .. versionadded:: 2.6
message_count: :class:`int` message_count: :class:`int`

41
discord/app_commands/transformers.py

@ -23,6 +23,7 @@ DEALINGS IN THE SOFTWARE.
""" """
from __future__ import annotations from __future__ import annotations
import datetime
import inspect import inspect
from dataclasses import dataclass from dataclasses import dataclass
@ -52,7 +53,7 @@ from ..channel import StageChannel, VoiceChannel, TextChannel, CategoryChannel,
from ..abc import GuildChannel from ..abc import GuildChannel
from ..threads import Thread from ..threads import Thread
from ..enums import Enum as InternalEnum, AppCommandOptionType, ChannelType, Locale 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 ..user import User
from ..role import Role from ..role import Role
from ..member import Member from ..member import Member
@ -62,6 +63,7 @@ from .._types import ClientT
__all__ = ( __all__ = (
'Transformer', 'Transformer',
'Transform', 'Transform',
'Timestamp',
'Range', 'Range',
) )
@ -681,6 +683,41 @@ class UnionChannelTransformer(BaseChannelTransformer[ClientT]):
return resolved 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]] = { CHANNEL_TO_TYPES: Dict[Any, List[ChannelType]] = {
AppCommandChannel: [ AppCommandChannel: [
ChannelType.stage_voice, ChannelType.stage_voice,
@ -777,7 +814,7 @@ def get_supported_annotation(
params = inspect.signature(transform_classmethod.__func__).parameters params = inspect.signature(transform_classmethod.__func__).parameters
if len(params) != 3: if len(params) != 3:
raise TypeError('Inline transformer with transform classmethod requires 3 parameters') 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') raise TypeError('Inline transformer with transform classmethod must be a coroutine')
return (InlineTransformer(annotation), MISSING, False) 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 .translator import Translator, locale_str
from ..errors import ClientException, HTTPException from ..errors import ClientException, HTTPException
from ..enums import AppCommandType, InteractionType 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 from .._types import ClientT
@ -257,7 +257,7 @@ class CommandTree(Generic[ClientT]):
-------- --------
CommandLimitReached CommandLimitReached
The maximum number of commands was reached for that guild. 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: try:
@ -277,9 +277,9 @@ class CommandTree(Generic[ClientT]):
counter = Counter(cmd_type for _, _, cmd_type in ctx_menu) counter = Counter(cmd_type for _, _, cmd_type in ctx_menu)
for cmd_type, count in counter.items(): for cmd_type, count in counter.items():
if count > 5: if count > 15:
as_enum = AppCommandType(cmd_type) 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._context_menus.update(ctx_menu)
self._guild_commands[guild.id] = mapping self._guild_commands[guild.id] = mapping
@ -338,7 +338,7 @@ class CommandTree(Generic[ClientT]):
Or, ``guild`` and ``guilds`` were both given. Or, ``guild`` and ``guilds`` were both given.
CommandLimitReached CommandLimitReached
The maximum number of commands was reached globally or for that guild. 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) 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 # read as `0 if override and found else 1` if confusing
to_add = not (override and found) to_add = not (override and found)
total = sum(1 for _, g, t in self._context_menus if g == guild_id and t == type) total = sum(1 for _, g, t in self._context_menus if g == guild_id and t == type)
if total + to_add > 5: if total + to_add > 15:
raise CommandLimitReached(guild_id=guild_id, limit=5, type=AppCommandType(type)) raise CommandLimitReached(guild_id=guild_id, limit=15, type=AppCommandType(type))
data[key] = command data[key] = command
if guild_ids is None: if guild_ids is None:
@ -839,7 +839,7 @@ class CommandTree(Generic[ClientT]):
not match the signature. not match the signature.
""" """
if not inspect.iscoroutinefunction(coro): if not _iscoroutinefunction(coro):
raise TypeError('The error handler must be a coroutine.') raise TypeError('The error handler must be a coroutine.')
params = inspect.signature(coro).parameters params = inspect.signature(coro).parameters
@ -908,7 +908,7 @@ class CommandTree(Generic[ClientT]):
""" """
def decorator(func: CommandCallback[Group, P, T]) -> Command[Group, P, T]: 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') raise TypeError('command function must be a coroutine function')
if description is MISSING: if description is MISSING:
@ -1005,7 +1005,7 @@ class CommandTree(Generic[ClientT]):
""" """
def decorator(func: ContextMenuCallback) -> ContextMenu: def decorator(func: ContextMenuCallback) -> ContextMenu:
if not inspect.iscoroutinefunction(func): if not _iscoroutinefunction(func):
raise TypeError('context menu function must be a coroutine function') raise TypeError('context menu function must be a coroutine function')
actual_name = func.__name__.title() if name is MISSING else name 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) await command._invoke_autocomplete(interaction, focused, namespace)
except Exception: except Exception:
# Suppress exception since it can't be handled anyway. # 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 return

26
discord/channel.py

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

78
discord/client.py

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

433
discord/components.py

@ -72,6 +72,12 @@ if TYPE_CHECKING:
ContainerComponent as ContainerComponentPayload, ContainerComponent as ContainerComponentPayload,
UnfurledMediaItem as UnfurledMediaItemPayload, UnfurledMediaItem as UnfurledMediaItemPayload,
LabelComponent as LabelComponentPayload, 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 from .emoji import Emoji
@ -91,6 +97,7 @@ if TYPE_CHECKING:
'SectionComponent', 'SectionComponent',
'Component', 'Component',
] ]
OptionPayload = Union[SelectOptionPayload, RadioGroupOptionPayload, CheckboxGroupOptionPayload]
__all__ = ( __all__ = (
@ -112,6 +119,12 @@ __all__ = (
'TextDisplay', 'TextDisplay',
'SeparatorComponent', 'SeparatorComponent',
'LabelComponent', 'LabelComponent',
'FileUploadComponent',
'RadioGroupComponent',
'CheckboxGroupComponent',
'CheckboxComponent',
'RadioGroupOption',
'CheckboxGroupOption',
) )
@ -131,6 +144,8 @@ class Component:
- :class:`FileComponent` - :class:`FileComponent`
- :class:`SeparatorComponent` - :class:`SeparatorComponent`
- :class:`Container` - :class:`Container`
- :class:`LabelComponent`
- :class:`FileUploadComponent`
This class is abstract and cannot be instantiated. This class is abstract and cannot be instantiated.
@ -166,6 +181,71 @@ class Component:
raise NotImplementedError 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): class ActionRow(Component):
"""Represents a Discord Bot UI Kit Action Row. """Represents a Discord Bot UI Kit Action Row.
@ -412,7 +492,7 @@ class SelectMenu(Component):
return payload return payload
class SelectOption: class SelectOption(BaseOption):
"""Represents a select menu's option. """Represents a select menu's option.
These can be created by users. These can be created by users.
@ -450,13 +530,8 @@ class SelectOption:
Whether this option is selected by default. Whether this option is selected by default.
""" """
__slots__: Tuple[str, ...] = ( __slots__: Tuple[str, ...] = BaseOption.__slots__ + ('_emoji',)
'label', __repr_info__ = BaseOption.__repr_info__ + ('emoji',)
'value',
'description',
'_emoji',
'default',
)
def __init__( def __init__(
self, self,
@ -467,18 +542,9 @@ class SelectOption:
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
default: bool = False, default: bool = False,
) -> None: ) -> None:
self.label: str = label super().__init__(label=label, value=value, description=description, default=default)
self.value: str = label if value is MISSING else value
self.description: Optional[str] = description
self.emoji = emoji 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: def __str__(self) -> str:
if self.emoji: if self.emoji:
@ -508,7 +574,7 @@ class SelectOption:
self._emoji = None self._emoji = None
@classmethod @classmethod
def from_dict(cls, data: SelectOptionPayload) -> SelectOption: def from_dict(cls, data: SelectOptionPayload) -> Self:
try: try:
emoji = PartialEmoji.from_dict(data['emoji']) # pyright: ignore[reportTypedDictNotRequiredAccess] emoji = PartialEmoji.from_dict(data['emoji']) # pyright: ignore[reportTypedDictNotRequiredAccess]
except KeyError: except KeyError:
@ -518,28 +584,18 @@ class SelectOption:
label=data['label'], label=data['label'],
value=data['value'], value=data['value'],
description=data.get('description'), description=data.get('description'),
emoji=emoji,
default=data.get('default', False), default=data.get('default', False),
emoji=emoji,
) )
def to_dict(self) -> SelectOptionPayload: def to_dict(self) -> SelectOptionPayload:
payload: SelectOptionPayload = { payload: SelectOptionPayload = super().to_dict() # type: ignore
'label': self.label,
'value': self.value,
'default': self.default,
}
if self.emoji: if self.emoji:
payload['emoji'] = self.emoji.to_dict() payload['emoji'] = self.emoji.to_dict()
if self.description:
payload['description'] = self.description
return payload return payload
def copy(self) -> SelectOption:
return self.__class__.from_dict(self.to_dict())
class TextInput(Component): class TextInput(Component):
"""Represents a text input from the Discord Bot UI Kit. """Represents a text input from the Discord Bot UI Kit.
@ -1384,6 +1440,313 @@ class LabelComponent(Component):
return payload 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]: def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]:
if data['type'] == 1: if data['type'] == 1:
return ActionRow(data) return ActionRow(data)
@ -1409,3 +1772,11 @@ def _component_factory(data: ComponentPayload, state: Optional[ConnectionState]
return Container(data, state) return Container(data, state)
elif data['type'] == 18: elif data['type'] == 18:
return LabelComponent(data, state) 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 @property
def url(self) -> str: def url(self) -> str:
""":class:`str`: Returns the URL of the emoji.""" """:class:`str`: Returns the URL of the emoji."""
fmt = 'gif' if self.animated else 'png' end = 'webp?animated=true' if self.animated else 'png'
return f'{Asset.BASE}/emojis/{self.id}.{fmt}' return f'{Asset.BASE}/emojis/{self.id}.{end}'
@property @property
def roles(self) -> List[Role]: def roles(self) -> List[Role]:

17
discord/enums.py

@ -75,6 +75,8 @@ __all__ = (
'EntitlementType', 'EntitlementType',
'EntitlementOwnerType', 'EntitlementOwnerType',
'PollLayoutType', 'PollLayoutType',
'InviteType',
'ReactionType',
'VoiceChannelEffectAnimationType', 'VoiceChannelEffectAnimationType',
'SubscriptionStatus', 'SubscriptionStatus',
'MessageReferenceType', 'MessageReferenceType',
@ -277,6 +279,16 @@ class MessageType(Enum):
poll_result = 46 poll_result = 46
emoji_added = 63 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): class SpeakingState(Enum):
none = 0 none = 0
@ -681,6 +693,11 @@ class ComponentType(Enum):
separator = 14 separator = 14
container = 17 container = 17
label = 18 label = 18
file_upload = 19
# checkpoint = 20
radio_group = 21
checkbox_group = 22
checkbox = 23
def __int__(self) -> int: def __int__(self) -> int:
return self.value return self.value

10
discord/errors.py

@ -48,6 +48,7 @@ __all__ = (
'PrivilegedIntentsRequired', 'PrivilegedIntentsRequired',
'InteractionResponded', 'InteractionResponded',
'MissingApplicationID', 'MissingApplicationID',
'FFmpegProcessError',
) )
APP_ID_NOT_FOUND = ( APP_ID_NOT_FOUND = (
@ -74,6 +75,15 @@ class ClientException(DiscordException):
pass pass
class FFmpegProcessError(ClientException):
"""Exception that's raised when an FFmpeg process fails.
.. versionadded:: 2.7
"""
pass
class GatewayNotFound(DiscordException): class GatewayNotFound(DiscordException):
"""An exception that is raised when the gateway for Discord could not be found""" """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 from __future__ import annotations
import asyncio
import collections import collections
import collections.abc import collections.abc
import inspect import inspect
@ -53,7 +52,7 @@ from typing import (
import discord import discord
from discord import app_commands from discord import app_commands
from discord.app_commands.tree import _retrieve_guild_ids 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 .core import GroupMixin
from .view import StringView from .view import StringView
@ -89,8 +88,8 @@ if TYPE_CHECKING:
PrefixType = Union[_Prefix, _PrefixCallable[BotT]] PrefixType = Union[_Prefix, _PrefixCallable[BotT]]
class _BotOptions(_ClientOptions, total=False): class _BotOptions(_ClientOptions, total=False):
owner_id: int owner_id: Optional[int]
owner_ids: Collection[int] owner_ids: Optional[Collection[int]]
strip_after_prefix: bool strip_after_prefix: bool
case_insensitive: bool case_insensitive: bool
@ -581,7 +580,7 @@ class BotBase(GroupMixin[None]):
TypeError TypeError
The coroutine passed is not actually a coroutine. 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.') raise TypeError('The pre-invoke hook must be a coroutine.')
self._before_invoke = coro self._before_invoke = coro
@ -618,7 +617,7 @@ class BotBase(GroupMixin[None]):
TypeError TypeError
The coroutine passed is not actually a coroutine. 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.') raise TypeError('The post-invoke hook must be a coroutine.')
self._after_invoke = coro self._after_invoke = coro
@ -654,7 +653,7 @@ class BotBase(GroupMixin[None]):
""" """
name = func.__name__ if name is MISSING else name name = func.__name__ if name is MISSING else name
if not asyncio.iscoroutinefunction(func): if not _iscoroutinefunction(func):
raise TypeError('Listeners must be coroutines') raise TypeError('Listeners must be coroutines')
if name in self.extra_events: if name in self.extra_events:

37
discord/ext/commands/cog.py

@ -28,7 +28,7 @@ import inspect
import discord import discord
import logging import logging
from discord import app_commands 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 ( from typing import (
Any, Any,
@ -45,29 +45,18 @@ from typing import (
Tuple, Tuple,
TypeVar, TypeVar,
Union, Union,
TypedDict,
) )
from ._types import _BaseCommand, BotT from ._types import _BaseCommand, BotT, MaybeCoro
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Self, Unpack from typing_extensions import Self
from discord.abc import Snowflake from discord.abc import Snowflake
from discord._types import ClientT from discord._types import ClientT
from .bot import BotBase from .bot import BotBase
from .context import Context from .context import Context
from .core import Command, _CommandDecoratorKwargs from .core import Command
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
__all__ = ( __all__ = (
@ -182,7 +171,7 @@ class CogMeta(type):
__cog_app_commands__: List[Union[app_commands.Group, app_commands.Command[Any, ..., Any]]] __cog_app_commands__: List[Union[app_commands.Group, app_commands.Command[Any, ..., Any]]]
__cog_listeners__: List[Tuple[str, str]] __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 name, bases, attrs = args
if any(issubclass(base, app_commands.Group) for base in bases): if any(issubclass(base, app_commands.Group) for base in bases):
raise TypeError( raise TypeError(
@ -244,7 +233,7 @@ class CogMeta(type):
if elem.startswith(('cog_', 'bot_')): if elem.startswith(('cog_', 'bot_')):
raise TypeError(no_bot_cog.format(base, elem)) raise TypeError(no_bot_cog.format(base, elem))
cog_app_commands[elem] = value cog_app_commands[elem] = value
elif inspect.iscoroutinefunction(value): elif _iscoroutinefunction(value):
try: try:
getattr(value, '__cog_listener__') getattr(value, '__cog_listener__')
except AttributeError: except AttributeError:
@ -533,7 +522,7 @@ class Cog(metaclass=CogMeta):
actual = func actual = func
if isinstance(actual, staticmethod): if isinstance(actual, staticmethod):
actual = actual.__func__ actual = actual.__func__
if not inspect.iscoroutinefunction(actual): if not _iscoroutinefunction(actual):
raise TypeError('Listener function must be a coroutine function.') raise TypeError('Listener function must be a coroutine function.')
actual.__cog_listener__ = True actual.__cog_listener__ = True
to_assign = name or actual.__name__ to_assign = name or actual.__name__
@ -594,7 +583,7 @@ class Cog(metaclass=CogMeta):
pass pass
@_cog_special_method @_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` """A special method that registers as a :meth:`.Bot.check_once`
check. check.
@ -604,7 +593,7 @@ class Cog(metaclass=CogMeta):
return True return True
@_cog_special_method @_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` """A special method that registers as a :meth:`.Bot.check`
check. check.
@ -614,7 +603,7 @@ class Cog(metaclass=CogMeta):
return True return True
@_cog_special_method @_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` """A special method that registers as a :func:`~discord.ext.commands.check`
for every command and subcommand in this cog. for every command and subcommand in this cog.
@ -624,7 +613,7 @@ class Cog(metaclass=CogMeta):
return True return True
@_cog_special_method @_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` """A special method that registers as a :func:`discord.app_commands.check`
for every app command and subcommand in this cog. for every app command and subcommand in this cog.
@ -657,7 +646,9 @@ class Cog(metaclass=CogMeta):
pass pass
@_cog_special_method @_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| """|coro|
A special method that is called whenever an error within 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 bot: BotT = interaction.client
data: ApplicationCommandInteractionData = interaction.data # type: ignore data: ApplicationCommandInteractionData = interaction.data # type: ignore
type_ = data.get('type', 1)
if interaction.message is None: if interaction.message is None:
synthetic_payload = { synthetic_payload = {
'id': interaction.id, 'id': interaction.id,
@ -268,7 +269,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
'tts': False, 'tts': False,
'pinned': False, 'pinned': False,
'edited_timestamp': None, '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, 'flags': 64,
'content': '', 'content': '',
'mentions': [], 'mentions': [],
@ -288,7 +289,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
else: else:
message = interaction.message 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( ctx = cls(
message=message, message=message,
bot=bot, bot=bot,

24
discord/ext/commands/converter.py

@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations from __future__ import annotations
import datetime
import inspect import inspect
import re import re
from typing import ( from typing import (
@ -86,6 +87,7 @@ __all__ = (
'clean_content', 'clean_content',
'Greedy', 'Greedy',
'Range', 'Range',
'Timestamp',
'run_converters', 'run_converters',
) )
@ -893,6 +895,28 @@ class GuildStickerConverter(IDConverter[discord.GuildSticker]):
return result 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]): class ScheduledEventConverter(IDConverter[discord.ScheduledEvent]):
"""Converts to a :class:`~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): class _CommandDecoratorKwargs(TypedDict, total=False):
enabled: bool enabled: bool
help: str help: Optional[str]
brief: str brief: Optional[str]
usage: str usage: Optional[str]
rest_is_raw: bool rest_is_raw: bool
aliases: List[str] aliases: Union[List[str], Tuple[str, ...]]
description: str description: str
hidden: bool hidden: bool
checks: List[UserCheck[Context[Any]]] checks: List[UserCheck[Context[Any]]]
@ -427,7 +427,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
/, /,
**kwargs: Unpack[_CommandKwargs], **kwargs: Unpack[_CommandKwargs],
) -> None: ) -> None:
if not asyncio.iscoroutinefunction(func): if not discord.utils._iscoroutinefunction(func):
raise TypeError('Callback must be a coroutine.') raise TypeError('Callback must be a coroutine.')
name = kwargs.get('name') or func.__name__ 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.brief: Optional[str] = kwargs.get('brief')
self.usage: Optional[str] = kwargs.get('usage') self.usage: Optional[str] = kwargs.get('usage')
self.rest_is_raw: bool = kwargs.get('rest_is_raw', False) 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', {}) self.extras: Dict[Any, Any] = kwargs.get('extras', {})
if not isinstance(self.aliases, (list, tuple)): 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. 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.') raise TypeError('The error handler must be a coroutine.')
self.on_error: Error[CogT, Any] = coro self.on_error: Error[CogT, Any] = coro
@ -1140,7 +1140,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
TypeError TypeError
The coroutine passed is not actually a coroutine. 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.') raise TypeError('The pre-invoke hook must be a coroutine.')
self._before_invoke = coro self._before_invoke = coro
@ -1171,7 +1171,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
TypeError TypeError
The coroutine passed is not actually a coroutine. 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.') raise TypeError('The post-invoke hook must be a coroutine.')
self._after_invoke = coro self._after_invoke = coro
@ -1945,7 +1945,7 @@ def check(predicate: UserCheck[ContextT], /) -> Check[ContextT]:
return func return func
if inspect.iscoroutinefunction(predicate): if discord.utils._iscoroutinefunction(predicate):
decorator.predicate = predicate decorator.predicate = predicate
else: else:
@ -2369,7 +2369,7 @@ def guild_only() -> Check[Any]:
return func return func
if inspect.iscoroutinefunction(predicate): if discord.utils._iscoroutinefunction(predicate):
decorator.predicate = predicate decorator.predicate = predicate
else: else:
@ -2444,7 +2444,7 @@ def is_nsfw() -> Check[Any]:
return func return func
if inspect.iscoroutinefunction(predicate): if discord.utils._iscoroutinefunction(predicate):
decorator.predicate = predicate decorator.predicate = predicate
else: else:

21
discord/ext/commands/errors.py

@ -79,6 +79,7 @@ __all__ = (
'SoundboardSoundNotFound', 'SoundboardSoundNotFound',
'PartialEmojiConversionFailure', 'PartialEmojiConversionFailure',
'BadBoolArgument', 'BadBoolArgument',
'BadTimestampArgument',
'MissingRole', 'MissingRole',
'BotMissingRole', 'BotMissingRole',
'MissingAnyRole', 'MissingAnyRole',
@ -602,6 +603,24 @@ class BadBoolArgument(BadArgument):
super().__init__(f'{argument} is not a recognised boolean option') 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): class RangeError(BadArgument):
"""Exception raised when an argument is out of range. """Exception raised when an argument is out of range.
@ -870,7 +889,7 @@ class BotMissingPermissions(CheckFailure):
class BadUnionArgument(UserInputError): 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. its associated types.
This inherits from :exc:`UserInputError` This inherits from :exc:`UserInputError`

21
discord/ext/commands/flags.py

@ -50,6 +50,25 @@ if TYPE_CHECKING:
from .context import Context from .context import Context
from .parameters import Parameter 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 @dataclass
class Flag: 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]: 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__'] case_insensitive = namespace['__commands_flag_case_insensitive__']
flags: Dict[str, Flag] = {} flags: Dict[str, Flag] = {}
cache: Dict[str, Any] = {} cache: Dict[str, Any] = {}

10
discord/ext/commands/help.py

@ -69,12 +69,12 @@ if TYPE_CHECKING:
class _HelpCommandOptions(TypedDict, total=False): class _HelpCommandOptions(TypedDict, total=False):
show_hidden: bool show_hidden: bool
verify_checks: bool verify_checks: Optional[bool]
command_attrs: _CommandKwargs command_attrs: _CommandKwargs
class _BaseHelpCommandOptions(_HelpCommandOptions, total=False): class _BaseHelpCommandOptions(_HelpCommandOptions, total=False):
sort_commands: bool sort_commands: bool
dm_help: bool dm_help: Optional[bool]
dm_help_threshold: int dm_help_threshold: int
no_category: str no_category: str
paginator: Paginator paginator: Paginator
@ -394,7 +394,7 @@ class HelpCommand:
def __init__(self, **options: Unpack[_HelpCommandOptions]) -> None: def __init__(self, **options: Unpack[_HelpCommandOptions]) -> None:
self.show_hidden: bool = options.pop('show_hidden', False) 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', {}) self.command_attrs = attrs = options.pop('command_attrs', {})
attrs.setdefault('name', 'help') attrs.setdefault('name', 'help')
attrs.setdefault('help', 'Shows this message') attrs.setdefault('help', 'Shows this message')
@ -1070,7 +1070,7 @@ class DefaultHelpCommand(HelpCommand):
self.width: int = options.pop('width', 80) self.width: int = options.pop('width', 80)
self.indent: int = options.pop('indent', 2) self.indent: int = options.pop('indent', 2)
self.sort_commands: bool = options.pop('sort_commands', True) 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.dm_help_threshold: int = options.pop('dm_help_threshold', 1000)
self.arguments_heading: str = options.pop('arguments_heading', 'Arguments:') self.arguments_heading: str = options.pop('arguments_heading', 'Arguments:')
self.commands_heading: str = options.pop('commands_heading', 'Commands:') self.commands_heading: str = options.pop('commands_heading', 'Commands:')
@ -1364,7 +1364,7 @@ class MinimalHelpCommand(HelpCommand):
def __init__(self, **options: Unpack[_MinimalHelpCommandOptions]) -> None: def __init__(self, **options: Unpack[_MinimalHelpCommandOptions]) -> None:
self.sort_commands: bool = options.pop('sort_commands', True) self.sort_commands: bool = options.pop('sort_commands', True)
self.commands_heading: str = options.pop('commands_heading', 'Commands') 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.dm_help_threshold: int = options.pop('dm_help_threshold', 1000)
self.aliases_heading: str = options.pop('aliases_heading', 'Aliases:') self.aliases_heading: str = options.pop('aliases_heading', 'Aliases:')
self.no_category: str = options.pop('no_category', 'No Category') 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 default_permissions: bool
nsfw: bool nsfw: bool
description: str description: str
case_insensitive: bool
class _HybridGroupDecoratorKwargs(_HybridGroupKwargs, total=False): class _HybridGroupDecoratorKwargs(_HybridGroupKwargs, total=False):
description: Union[str, app_commands.locale_str] 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__ = ( __all__ = (
@ -532,6 +534,10 @@ class HybridCommand(Command[CogT, P, T]):
HybridAppCommand(self) if self.with_app_command else None 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 @property
def cog(self) -> CogT: def cog(self) -> CogT:
return self._cog return self._cog
@ -700,6 +706,10 @@ class HybridGroup(Group[CogT, P, T]):
return None return None
return self.app_command.get_command(self.fallback) # type: ignore 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 @property
def cog(self) -> CogT: def cog(self) -> CogT:
return self._cog return self._cog

51
discord/ext/tasks/__init__.py

@ -37,6 +37,7 @@ from typing import (
Type, Type,
TypeVar, TypeVar,
Union, Union,
overload,
) )
import aiohttp import aiohttp
@ -45,7 +46,7 @@ import inspect
from collections.abc import Sequence from collections.abc import Sequence
from discord.backoff import ExponentialBackoff from discord.backoff import ExponentialBackoff
from discord.utils import MISSING from discord.utils import MISSING, _iscoroutinefunction
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@ -176,12 +177,12 @@ class Loop(Generic[LF]):
if self.count is not None and self.count <= 0: if self.count is not None and self.count <= 0:
raise ValueError('count must be greater than 0 or None.') 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_failed = False
self._last_iteration: datetime.datetime = MISSING self._last_iteration: datetime.datetime = MISSING
self._next_iteration = None 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}.') 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: 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. 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__}.') raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.')
self._before_loop = coro self._before_loop = coro
@ -601,7 +602,7 @@ class Loop(Generic[LF]):
The function was not a coroutine. 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__}.') raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.')
self._after_loop = coro self._after_loop = coro
@ -631,7 +632,7 @@ class Loop(Generic[LF]):
TypeError TypeError
The function was not a coroutine. 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__}.') raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.')
self._error = coro # type: ignore self._error = coro # type: ignore
@ -710,6 +711,22 @@ class Loop(Generic[LF]):
ret = sorted(set(ret)) # de-dupe and sort times ret = sorted(set(ret)) # de-dupe and sort times
return ret 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( def change_interval(
self, self,
*, *,
@ -777,6 +794,28 @@ class Loop(Generic[LF]):
self._handle.recalculate(self._next_iteration) 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( def loop(
*, *,
seconds: float = MISSING, seconds: float = MISSING,

137
discord/gateway.py

@ -44,6 +44,11 @@ from .activity import BaseActivity
from .enums import SpeakingState from .enums import SpeakingState
from .errors import ConnectionClosed from .errors import ConnectionClosed
try:
import davey # type: ignore
except ImportError:
pass
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
__all__ = ( __all__ = (
@ -205,6 +210,10 @@ class KeepAliveHandler(threading.Thread):
def tick(self) -> None: def tick(self) -> None:
self._last_recv = time.perf_counter() 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: def ack(self) -> None:
ack_time = time.perf_counter() ack_time = time.perf_counter()
self._last_ack = ack_time self._last_ack = ack_time
@ -536,7 +545,7 @@ class DiscordWebSocket:
if op == self.HEARTBEAT: if op == self.HEARTBEAT:
if self._keep_alive: if self._keep_alive:
beat = self._keep_alive.get_payload() beat = self._keep_alive.beat()
await self.send_as_json(beat) await self.send_as_json(beat)
return return
@ -645,6 +654,7 @@ class DiscordWebSocket:
self._keep_alive.stop() self._keep_alive.stop()
self._keep_alive = None self._keep_alive = None
await self.socket.close(code=4000)
if isinstance(e, asyncio.TimeoutError): if isinstance(e, asyncio.TimeoutError):
_log.debug('Timed out receiving packet. Attempting a reconnect.') _log.debug('Timed out receiving packet. Attempting a reconnect.')
raise ReconnectWebSocket(self.shard_id) from None raise ReconnectWebSocket(self.shard_id) from None
@ -812,18 +822,30 @@ class DiscordVoiceWebSocket:
_max_heartbeat_timeout: float _max_heartbeat_timeout: float
# fmt: off # fmt: off
IDENTIFY = 0 IDENTIFY = 0
SELECT_PROTOCOL = 1 SELECT_PROTOCOL = 1
READY = 2 READY = 2
HEARTBEAT = 3 HEARTBEAT = 3
SESSION_DESCRIPTION = 4 SESSION_DESCRIPTION = 4
SPEAKING = 5 SPEAKING = 5
HEARTBEAT_ACK = 6 HEARTBEAT_ACK = 6
RESUME = 7 RESUME = 7
HELLO = 8 HELLO = 8
RESUMED = 9 RESUMED = 9
CLIENT_CONNECT = 12 CLIENTS_CONNECT = 11
CLIENT_DISCONNECT = 13 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 # fmt: on
def __init__( def __init__(
@ -850,6 +872,10 @@ class DiscordVoiceWebSocket:
_log.debug('Sending voice websocket frame: %s.', data) _log.debug('Sending voice websocket frame: %s.', data)
await self.ws.send_str(utils._to_json(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 send_heartbeat = send_as_json
async def resume(self) -> None: async def resume(self) -> None:
@ -874,6 +900,7 @@ class DiscordVoiceWebSocket:
'user_id': str(state.user.id), 'user_id': str(state.user.id),
'session_id': state.session_id, 'session_id': state.session_id,
'token': state.token, 'token': state.token,
'max_dave_protocol_version': state.max_dave_protocol_version,
}, },
} }
await self.send_as_json(payload) await self.send_as_json(payload)
@ -943,6 +970,16 @@ class DiscordVoiceWebSocket:
await self.send_as_json(payload) 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: async def received_message(self, msg: Dict[str, Any]) -> None:
_log.debug('Voice websocket frame received: %s', msg) _log.debug('Voice websocket frame received: %s', msg)
op = msg['op'] op = msg['op']
@ -959,13 +996,85 @@ class DiscordVoiceWebSocket:
elif op == self.SESSION_DESCRIPTION: elif op == self.SESSION_DESCRIPTION:
self._connection.mode = data['mode'] self._connection.mode = data['mode']
await self.load_secret_key(data) 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: elif op == self.HELLO:
interval = data['heartbeat_interval'] / 1000.0 interval = data['heartbeat_interval'] / 1000.0
self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=min(interval, 5.0)) self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=min(interval, 5.0))
self._keep_alive.start() 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) 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: async def initial_connection(self, data: Dict[str, Any]) -> None:
state = self._connection state = self._connection
state.ssrc = data['ssrc'] state.ssrc = data['ssrc']
@ -1045,6 +1154,8 @@ class DiscordVoiceWebSocket:
msg = await asyncio.wait_for(self.ws.receive(), timeout=30.0) msg = await asyncio.wait_for(self.ws.receive(), timeout=30.0)
if msg.type is aiohttp.WSMsgType.TEXT: if msg.type is aiohttp.WSMsgType.TEXT:
await self.received_message(utils._from_json(msg.data)) 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: elif msg.type is aiohttp.WSMsgType.ERROR:
_log.debug('Received voice %s', msg) _log.debug('Received voice %s', msg)
raise ConnectionClosed(self.ws, shard_id=None) from msg.data 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 copy
import datetime import datetime
import unicodedata
from typing import ( from typing import (
Any, Any,
AsyncIterator, AsyncIterator,
@ -678,7 +677,7 @@ class Guild(Hashable):
scheduled_event = ScheduledEvent(data=s, state=self._state) scheduled_event = ScheduledEvent(data=s, state=self._state)
self._scheduled_events[scheduled_event.id] = scheduled_event self._scheduled_events[scheduled_event.id] = scheduled_event
if 'soundboard_sounds' in guild: if 'soundboard_sounds' in guild and state.cache_guild_expressions:
for s in guild['soundboard_sounds']: for s in guild['soundboard_sounds']:
soundboard_sound = SoundboardSound(guild=self, data=s, state=self._state) soundboard_sound = SoundboardSound(guild=self, data=s, state=self._state)
self._add_soundboard_sound(soundboard_sound) self._add_soundboard_sound(soundboard_sound)
@ -3087,7 +3086,7 @@ class Guild(Hashable):
self, self,
*, *,
name: str, name: str,
description: str, description: str = MISSING,
emoji: str, emoji: str,
file: File, file: File,
reason: Optional[str] = None, reason: Optional[str] = None,
@ -3103,11 +3102,16 @@ class Guild(Hashable):
Parameters Parameters
----------- -----------
name: :class:`str` 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` 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` 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` file: :class:`File`
The file of the sticker to upload. The file of the sticker to upload.
reason: :class:`str` reason: :class:`str`
@ -3127,19 +3131,10 @@ class Guild(Hashable):
""" """
payload = { payload = {
'name': name, '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) data = await self._state.http.create_guild_sticker(self.id, payload, file, reason)
if self._state.cache_guild_expressions: if self._state.cache_guild_expressions:
return self._state.store_sticker(self, data) return self._state.store_sticker(self, data)
@ -3872,6 +3867,39 @@ class Guild(Hashable):
return roles 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: async def welcome_screen(self) -> WelcomeScreen:
"""|coro| """|coro|

25
discord/http.py

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

16
discord/integrations.py

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

83
discord/interactions.py

@ -65,6 +65,8 @@ if TYPE_CHECKING:
ApplicationCommandInteractionData, ApplicationCommandInteractionData,
InteractionCallback as InteractionCallbackPayload, InteractionCallback as InteractionCallbackPayload,
InteractionCallbackActivity as InteractionCallbackActivityPayload, InteractionCallbackActivity as InteractionCallbackActivityPayload,
MessageComponentInteractionData,
ModalSubmitInteractionData,
) )
from .types.webhook import ( from .types.webhook import (
Webhook as WebhookPayload, Webhook as WebhookPayload,
@ -191,6 +193,8 @@ class Interaction(Generic[ClientT]):
'channel', 'channel',
'_cs_namespace', '_cs_namespace',
'_cs_command', '_cs_command',
'_cs_command_id',
'_cs_custom_id',
) )
def __init__(self, *, data: InteractionPayload, state: ConnectionState[ClientT]): def __init__(self, *, data: InteractionPayload, state: ConnectionState[ClientT]):
@ -376,6 +380,21 @@ class Interaction(Generic[ClientT]):
else: else:
return tree._get_context_menu(data) 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') @utils.cached_slot_property('_cs_response')
def response(self) -> InteractionResponse[ClientT]: def response(self) -> InteractionResponse[ClientT]:
""":class:`InteractionResponse`: Returns an object responsible for handling responding to the interaction. """: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.""" """:class:`datetime.datetime`: When the interaction expires."""
return self.created_at + datetime.timedelta(minutes=15) 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: def is_expired(self) -> bool:
""":class:`bool`: Returns ``True`` if the interaction is expired.""" """:class:`bool`: Returns ``True`` if the interaction is expired."""
return utils.utcnow() >= self.expires_at return utils.utcnow() >= self.expires_at
@ -581,7 +615,7 @@ class Interaction(Generic[ClientT]):
state = _InteractionMessageState(self, self._state) state = _InteractionMessageState(self, self._state)
message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore
if view and not view.is_finished() and view.is_dispatchable(): 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 return message
async def delete_original_response(self) -> None: async def delete_original_response(self) -> None:
@ -1048,7 +1082,7 @@ class InteractionResponse(Generic[ClientT]):
) )
http = parent._state.http http = parent._state.http
response = await adapter.create_interaction_response( data = await adapter.create_interaction_response(
parent.id, parent.id,
parent.token, parent.token,
session=parent._session, session=parent._session,
@ -1056,17 +1090,19 @@ class InteractionResponse(Generic[ClientT]):
proxy_auth=http.proxy_auth, proxy_auth=http.proxy_auth,
params=params, 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 view is not MISSING and not view.is_finished():
if ephemeral and view.timeout is None: if ephemeral and view.timeout is None:
view.timeout = 15 * 60.0 view.timeout = 15 * 60.0
# If the interaction type isn't an application command then there's no way self._parent._state.store_view(view, response.message_id)
# 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
if delete_after is not None: if delete_after is not None:
@ -1079,12 +1115,7 @@ class InteractionResponse(Generic[ClientT]):
asyncio.create_task(inner_call()) asyncio.create_task(inner_call())
return InteractionCallbackResponse( return response
data=response,
parent=self._parent,
state=self._parent._state,
type=self._response_type,
)
async def edit_message( async def edit_message(
self, self,
@ -1171,12 +1202,8 @@ class InteractionResponse(Generic[ClientT]):
state = parent._state state = parent._state
if msg is not None: if msg is not None:
message_id = msg.id 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: else:
message_id = None message_id = None
original_interaction_id = None
if parent.type not in (InteractionType.component, InteractionType.modal_submit): if parent.type not in (InteractionType.component, InteractionType.modal_submit):
return return
@ -1204,7 +1231,7 @@ class InteractionResponse(Generic[ClientT]):
) )
http = parent._state.http http = parent._state.http
response = await adapter.create_interaction_response( data = await adapter.create_interaction_response(
parent.id, parent.id,
parent.token, parent.token,
session=parent._session, session=parent._session,
@ -1212,11 +1239,16 @@ class InteractionResponse(Generic[ClientT]):
proxy_auth=http.proxy_auth, proxy_auth=http.proxy_auth,
params=params, 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(): if view and not view.is_finished() and view.is_dispatchable():
state.store_view(view, message_id, interaction_id=original_interaction_id) state.store_view(view, message_id or response.message_id)
self._response_type = InteractionResponseType.message_update
if delete_after is not None: if delete_after is not None:
@ -1229,12 +1261,7 @@ class InteractionResponse(Generic[ClientT]):
asyncio.create_task(inner_call()) asyncio.create_task(inner_call())
return InteractionCallbackResponse( return response
data=response,
parent=self._parent,
state=self._parent._state,
type=self._response_type,
)
async def send_modal(self, modal: Modal, /) -> InteractionCallbackResponse[ClientT]: async def send_modal(self, modal: Modal, /) -> InteractionCallbackResponse[ClientT]:
"""|coro| """|coro|

65
discord/member.py

@ -25,7 +25,6 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import inspect
import itertools import itertools
from operator import attrgetter from operator import attrgetter
from typing import Any, Awaitable, Callable, Collection, Dict, List, Optional, TYPE_CHECKING, Tuple, TypeVar, Union 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 # probably a member function by now
def generate_function(x): def generate_function(x):
# We want sphinx to properly show coroutine functions as coroutines # 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 async def general(self, *args, **kwargs): # type: ignore
return await getattr(self._user, x)(*args, **kwargs) return await getattr(self._user, x)(*args, **kwargs)
@ -815,12 +814,22 @@ class Member(discord.abc.Messageable, _UserTag):
voice_channel: Optional[VocalGuildChannel] = MISSING, voice_channel: Optional[VocalGuildChannel] = MISSING,
timed_out_until: Optional[datetime.datetime] = MISSING, timed_out_until: Optional[datetime.datetime] = MISSING,
bypass_verification: bool = MISSING, bypass_verification: bool = MISSING,
avatar: Optional[bytes] = MISSING,
banner: Optional[bytes] = MISSING,
bio: Optional[str] = MISSING,
reason: Optional[str] = None, reason: Optional[str] = None,
) -> Optional[Member]: ) -> Optional[Member]:
"""|coro| """|coro|
Edits the member's data. 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: 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. Indicates if the member should be allowed to bypass the guild verification requirements.
.. versionadded:: 2.2 .. 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`] reason: Optional[:class:`str`]
The reason for editing this member. Shows up on the audit log. 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. The operation failed.
TypeError TypeError
The datetime object passed to ``timed_out_until`` was not timezone-aware. 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 Returns
-------- --------
@ -899,14 +928,33 @@ class Member(discord.abc.Messageable, _UserTag):
guild_id = self.guild.id guild_id = self.guild.id
me = self._state.self_id == self.id me = self._state.self_id == self.id
payload: Dict[str, Any] = {} payload: Dict[str, Any] = {}
self_payload: Dict[str, Any] = {}
if nick is not MISSING: if nick is not MISSING:
nick = nick or '' nick = nick or ''
if me: if me:
await http.change_my_nickname(guild_id, nick, reason=reason) self_payload['nick'] = nick
else: else:
payload['nick'] = nick 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: if deafen is not MISSING:
payload['deaf'] = deafen payload['deaf'] = deafen
@ -928,7 +976,7 @@ class Member(discord.abc.Messageable, _UserTag):
await http.edit_my_voice_state(guild_id, voice_state_payload) await http.edit_my_voice_state(guild_id, voice_state_payload)
else: else:
if not suppress: 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) await http.edit_voice_state(guild_id, self.id, voice_state_payload)
if voice_channel is not MISSING: if voice_channel is not MISSING:
@ -954,7 +1002,12 @@ class Member(discord.abc.Messageable, _UserTag):
if payload: if payload:
data = await http.edit_member(guild_id, self.id, reason=reason, **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: async def request_to_speak(self) -> None:
"""|coro| """|coro|
@ -984,7 +1037,7 @@ class Member(discord.abc.Messageable, _UserTag):
payload = { payload = {
'channel_id': self.voice.channel.id, '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: 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) message = Message(state=self._state, channel=self.channel, data=data)
if view and not view.is_finished() and view.is_dispatchable(): if view and not view.is_finished() and view.is_dispatchable():
interaction: Optional[MessageInteractionMetadata] = getattr(self, 'interaction_metadata', None) self._state.store_view(view, self.id)
if interaction is not None:
self._state.store_view(view, self.id, interaction_id=interaction.id)
else:
self._state.store_view(view, self.id)
if delete_after is not None: if delete_after is not None:
await self.delete(delay=delete_after) await self.delete(delay=delete_after)
@ -1453,7 +1449,7 @@ class PartialMessage(Hashable):
Pins the message. 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. this in a non-private channel context.
Parameters Parameters
@ -1471,7 +1467,7 @@ class PartialMessage(Hashable):
The message or channel was not found or deleted. The message or channel was not found or deleted.
HTTPException HTTPException
Pinning the message failed, probably due to the channel 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) await self._state.http.pin_message(self.channel.id, self.id, reason=reason)
@ -1483,7 +1479,7 @@ class PartialMessage(Hashable):
Unpins the message. 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. this in a non-private channel context.
Parameters Parameters
@ -2221,6 +2217,7 @@ class Message(PartialMessage, Hashable):
self.application_id: Optional[int] = utils._get_as_snowflake(data, 'application_id') self.application_id: Optional[int] = utils._get_as_snowflake(data, 'application_id')
self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])] self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])]
self.message_snapshots: List[MessageSnapshot] = MessageSnapshot._from_value(state, data.get('message_snapshots')) self.message_snapshots: List[MessageSnapshot] = MessageSnapshot._from_value(state, data.get('message_snapshots'))
self.call: Optional[CallMessage] = None
# Set by Messageable.pins # Set by Messageable.pins
self._pinned_at: Optional[datetime.datetime] = None 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) self.interaction_metadata = MessageInteractionMetadata(state=self._state, guild=self.guild, data=data)
def _handle_call(self, data: CallMessagePayload): def _handle_call(self, data: CallMessagePayload):
self.call: Optional[CallMessage]
if data is not None: if data is not None:
self.call = CallMessage(state=self._state, message=self, data=data) self.call = CallMessage(state=self._state, message=self, data=data)
else:
self.call = None
def _rebind_cached_references( def _rebind_cached_references(
self, self,
@ -3053,3 +3047,30 @@ class Message(PartialMessage, Hashable):
The newly edited message. The newly edited message.
""" """
return await self.edit(attachments=[a for a in self.attachments if a not in attachments]) 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: if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
from .client import Client
from .state import ConnectionState from .state import ConnectionState
from datetime import datetime from datetime import datetime
from .types.emoji import Emoji as EmojiPayload, PartialEmoji as PartialEmojiPayload from .types.emoji import Emoji as EmojiPayload, PartialEmoji as PartialEmojiPayload
@ -114,7 +115,7 @@ class PartialEmoji(_EmojiTag, AssetMixin):
) )
@classmethod @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`. """Converts a Discord string representation of an emoji to a :class:`PartialEmoji`.
The formats accepted are: The formats accepted are:
@ -132,6 +133,11 @@ class PartialEmoji(_EmojiTag, AssetMixin):
------------ ------------
value: :class:`str` value: :class:`str`
The string representation of an emoji. 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 Returns
-------- --------
@ -144,8 +150,12 @@ class PartialEmoji(_EmojiTag, AssetMixin):
animated = bool(groups['animated']) animated = bool(groups['animated'])
emoji_id = int(groups['id']) emoji_id = int(groups['id'])
name = groups['name'] 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) 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) return cls(name=value, id=None, animated=False)
def to_dict(self) -> EmojiPayload: def to_dict(self) -> EmojiPayload:
@ -245,8 +255,8 @@ class PartialEmoji(_EmojiTag, AssetMixin):
if self.is_unicode_emoji(): if self.is_unicode_emoji():
return '' return ''
fmt = 'gif' if self.animated else 'png' end = 'webp?animated=true' if self.animated else 'png'
return f'{Asset.BASE}/emojis/{self.id}.{fmt}' return f'{Asset.BASE}/emojis/{self.id}.{end}'
async def read(self) -> bytes: async def read(self) -> bytes:
"""|coro| """|coro|

24
discord/permissions.py

@ -95,6 +95,7 @@ if TYPE_CHECKING:
create_polls: BoolOrNoneT create_polls: BoolOrNoneT
use_external_apps: BoolOrNoneT use_external_apps: BoolOrNoneT
pin_messages: BoolOrNoneT pin_messages: BoolOrNoneT
bypass_slowmode: BoolOrNoneT
class _PermissionsKwargs(_BasePermissionsKwargs[bool]): ... class _PermissionsKwargs(_BasePermissionsKwargs[bool]): ...
@ -253,7 +254,7 @@ class Permissions(BaseFlags):
permissions set to ``True``. permissions set to ``True``.
""" """
# Some of these are 0 because we don't want to set unnecessary bits # 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 @classmethod
def _timeout_mask(cls) -> int: def _timeout_mask(cls) -> int:
@ -273,6 +274,7 @@ class Permissions(BaseFlags):
base.create_public_threads = False base.create_public_threads = False
base.manage_threads = False base.manage_threads = False
base.send_messages_in_threads = False base.send_messages_in_threads = False
base.bypass_slowmode = False
return base return base
@classmethod @classmethod
@ -326,8 +328,11 @@ class Permissions(BaseFlags):
.. versionchanged:: 2.4 .. versionchanged:: 2.4
Added :attr:`send_polls`, :attr:`send_voice_messages`, attr:`use_external_sounds`, Added :attr:`send_polls`, :attr:`send_voice_messages`, attr:`use_external_sounds`,
:attr:`use_embedded_activities`, and :attr:`use_external_apps` permissions. :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 @classmethod
def general(cls) -> Self: def general(cls) -> Self:
@ -377,9 +382,9 @@ class Permissions(BaseFlags):
Added :attr:`send_polls` and :attr:`use_external_apps` permissions. Added :attr:`send_polls` and :attr:`use_external_apps` permissions.
.. versionchanged:: 2.7 .. 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 @classmethod
def voice(cls) -> Self: def voice(cls) -> Self:
@ -577,7 +582,7 @@ class Permissions(BaseFlags):
@flag_value @flag_value
def manage_messages(self) -> int: 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:: .. note::
@ -884,6 +889,14 @@ class Permissions(BaseFlags):
""" """
return 1 << 51 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): def _augment_from_permissions(cls):
cls.VALID_NAMES = set(Permissions.VALID_FLAGS) cls.VALID_NAMES = set(Permissions.VALID_FLAGS)
@ -1009,6 +1022,7 @@ class PermissionOverwrite:
create_polls: Optional[bool] create_polls: Optional[bool]
use_external_apps: Optional[bool] use_external_apps: Optional[bool]
pin_messages: Optional[bool] pin_messages: Optional[bool]
bypass_slowmode: Optional[bool]
def __init__(self, **kwargs: Unpack[_PermissionOverwriteKwargs]) -> None: def __init__(self, **kwargs: Unpack[_PermissionOverwriteKwargs]) -> None:
self._values: Dict[str, Optional[bool]] = {} 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 typing import Any, Callable, Generic, IO, Optional, TYPE_CHECKING, Tuple, TypeVar, Union
from .enums import SpeakingState from .enums import SpeakingState
from .errors import ClientException from .errors import ClientException, FFmpegProcessError
from .opus import Encoder as OpusEncoder, OPUS_SILENCE from .opus import Encoder as OpusEncoder, OPUS_SILENCE
from .oggparse import OggStream from .oggparse import OggStream
from .utils import MISSING from .utils import MISSING
@ -186,6 +186,8 @@ class FFmpegAudio(AudioSource):
self._stderr: Optional[IO[bytes]] = None self._stderr: Optional[IO[bytes]] = None
self._pipe_writer_thread: Optional[threading.Thread] = None self._pipe_writer_thread: Optional[threading.Thread] = None
self._pipe_reader_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: if piping_stdin:
n = f'popen-stdin-writer:pid-{self._process.pid}' n = f'popen-stdin-writer:pid-{self._process.pid}'
@ -212,25 +214,72 @@ class FFmpegAudio(AudioSource):
else: else:
return process 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: 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 # this function gets called in __del__ so instance attributes might not even exist
proc = getattr(self, '_process', MISSING) proc = getattr(self, '_process', MISSING)
# Only proceed if proc is a subprocess.Popen instance
if proc is MISSING: if proc is MISSING:
return 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: try:
proc.kill() proc.kill()
except Exception: 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: if still_running:
_log.info('ffmpeg process %s has not terminated. Waiting to terminate...', proc.pid) _log.info('ffmpeg process %s has not terminated. Waiting to terminate...', pid)
proc.communicate() try:
_log.info('ffmpeg process %s should have terminated with a return code of %s.', proc.pid, proc.returncode) 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: 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: def _pipe_writer(self, source: io.BufferedIOBase) -> None:
while self._process: while self._process:
@ -267,6 +316,7 @@ class FFmpegAudio(AudioSource):
return return
def cleanup(self) -> None: def cleanup(self) -> None:
self._stopped = True
self._kill_process() self._kill_process()
self._process = self._stdout = self._stdin = self._stderr = MISSING self._process = self._stdout = self._stdin = self._stderr = MISSING
@ -348,6 +398,8 @@ class FFmpegPCMAudio(FFmpegAudio):
def read(self) -> bytes: def read(self) -> bytes:
ret = self._stdout.read(OpusEncoder.FRAME_SIZE) ret = self._stdout.read(OpusEncoder.FRAME_SIZE)
if len(ret) != 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 b''
return ret return ret
@ -646,7 +698,11 @@ class FFmpegOpusAudio(FFmpegAudio):
return codec, bitrate return codec, bitrate
def read(self) -> bytes: 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: def is_opus(self) -> bool:
return True return True
@ -745,6 +801,11 @@ class AudioPlayer(threading.Thread):
data = self.source.read() data = self.source.read()
if not data: 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() self.stop()
break break

5
discord/shard.py

@ -41,6 +41,7 @@ from .errors import (
ConnectionClosed, ConnectionClosed,
PrivilegedIntentsRequired, PrivilegedIntentsRequired,
) )
from .utils import MISSING
from .enums import Status from .enums import Status
@ -389,6 +390,7 @@ class AutoShardedClient(Client):
self.__shards = {} self.__shards = {}
self._connection._get_websocket = self._get_websocket self._connection._get_websocket = self._get_websocket
self._connection._get_client = lambda: self 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: def _get_websocket(self, guild_id: Optional[int] = None, *, shard_id: Optional[int] = None) -> DiscordWebSocket:
if shard_id is None: if shard_id is None:
@ -554,7 +556,8 @@ class AutoShardedClient(Client):
await asyncio.wait(to_close) await asyncio.wait(to_close)
await self.http.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()) self._closing_task = asyncio.create_task(_close())
await self._closing_task 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 # So this is checked instead, it's a small penalty to pay
@property @property
def cache_guild_expressions(self) -> bool: def cache_guild_expressions(self) -> bool:
return self._intents.emojis_and_stickers return self._intents.expressions
async def close(self) -> None: async def close(self) -> None:
for voice in self.voice_clients: for voice in self.voice_clients:
@ -412,9 +412,7 @@ class ConnectionState(Generic[ClientT]):
self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data)
return sticker return sticker
def store_view(self, view: BaseView, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None: def store_view(self, view: BaseView, message_id: Optional[int] = None) -> None:
if interaction_id is not None:
self._view_store.remove_interaction_mapping(interaction_id)
self._view_store.add_view(view, message_id) self._view_store.add_view(view, message_id)
def prevent_view_updates_for(self, message_id: int) -> Optional[BaseView]: def prevent_view_updates_for(self, message_id: int) -> Optional[BaseView]:
@ -828,7 +826,8 @@ class ConnectionState(Generic[ClientT]):
inner_data = data['data'] inner_data = data['data']
custom_id = inner_data['custom_id'] custom_id = inner_data['custom_id']
components = inner_data['components'] 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) self.dispatch('interaction', interaction)
def parse_presence_update(self, data: gw.PresenceUpdateEvent) -> None: 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` slowmode_delay: :class:`int`
The number of seconds a member must wait between sending messages The number of seconds a member must wait between sending messages
in this thread. A value of ``0`` denotes that it is disabled. in this thread. A value of ``0`` denotes that it is disabled.
Bots and users with :attr:`~Permissions.manage_channels` or Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode.
:attr:`~Permissions.manage_messages` bypass slowmode.
message_count: :class:`int` message_count: :class:`int`
An approximate number of messages in this thread. An approximate number of messages in this thread.
member_count: :class:`int` member_count: :class:`int`

70
discord/types/components.py

@ -30,7 +30,7 @@ from typing_extensions import NotRequired
from .emoji import PartialEmoji from .emoji import PartialEmoji
from .channel import ChannelType 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] ButtonStyle = Literal[1, 2, 3, 4, 5, 6]
TextStyle = Literal[1, 2] TextStyle = Literal[1, 2]
DefaultValueType = Literal['user', 'role', 'channel'] DefaultValueType = Literal['user', 'role', 'channel']
@ -43,6 +43,13 @@ class ComponentBase(TypedDict):
type: int type: int
class OptionBase(TypedDict):
label: str
value: str
default: NotRequired[bool]
description: NotRequired[str]
class ActionRow(ComponentBase): class ActionRow(ComponentBase):
type: Literal[1] type: Literal[1]
components: List[ActionRowChildComponent] components: List[ActionRowChildComponent]
@ -59,11 +66,7 @@ class ButtonComponent(ComponentBase):
sku_id: NotRequired[str] sku_id: NotRequired[str]
class SelectOption(TypedDict): class SelectOption(OptionBase):
label: str
value: str
default: bool
description: NotRequired[str]
emoji: NotRequired[PartialEmoji] emoji: NotRequired[PartialEmoji]
@ -192,7 +195,43 @@ class LabelComponent(ComponentBase):
type: Literal[18] type: Literal[18]
label: str label: str
description: NotRequired[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] ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput]
@ -203,8 +242,21 @@ ContainerChildComponent = Union[
FileComponent, FileComponent,
SectionComponent, SectionComponent,
SectionComponent, SectionComponent,
ContainerComponent,
SeparatorComponent, SeparatorComponent,
ThumbnailComponent, 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 import TYPE_CHECKING, Dict, List, Literal, TypedDict, Union, Optional
from typing_extensions import NotRequired from typing_extensions import NotRequired
from .channel import ChannelTypeWithoutThread, GuildChannel, InteractionDMChannel, GroupDMChannel from .channel import (
ChannelTypeWithoutThread,
GuildChannel,
InteractionDMChannel,
GroupDMChannel,
)
from .sku import Entitlement from .sku import Entitlement
from .threads import ThreadType, ThreadMetadata from .threads import ThreadType, ThreadMetadata
from .member import Member from .member import Member
@ -36,6 +41,7 @@ from .role import Role
from .snowflake import Snowflake from .snowflake import Snowflake
from .user import User from .user import User
from .guild import GuildFeature from .guild import GuildFeature
from .components import ComponentBase
if TYPE_CHECKING: if TYPE_CHECKING:
from .message import Message from .message import Message
@ -204,39 +210,81 @@ class SelectMessageComponentInteractionData(_BaseMessageComponentInteractionData
MessageComponentInteractionData = Union[ButtonMessageComponentInteractionData, SelectMessageComponentInteractionData] MessageComponentInteractionData = Union[ButtonMessageComponentInteractionData, SelectMessageComponentInteractionData]
class ModalSubmitTextInputInteractionData(TypedDict): class ModalSubmitTextInputInteractionData(ComponentBase):
type: Literal[4] type: Literal[4]
custom_id: str custom_id: str
value: str value: str
class ModalSubmitStringSelectInteractionData(TypedDict): class ModalSubmitSelectInteractionData(ComponentBase):
type: Literal[3] 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 custom_id: str
id: int
values: List[str] 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): class ModalSubmitActionRowInteractionData(TypedDict):
type: Literal[1] 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] type: Literal[18]
component: ModalSubmitComponentItemInteractionData component: ModalSubmitLabelComponentItemInteractionData
ModalSubmitComponentInteractionData = Union[ ModalSubmitComponentInteractionData = Union[
ModalSubmitLabelInteractionData, ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData ModalSubmitActionRowInteractionData,
ModalSubmitTextDisplayInteractionData,
ModalSubmitLabelInteractionData,
] ]
class ModalSubmitInteractionData(TypedDict): class ModalSubmitInteractionData(TypedDict):
custom_id: str custom_id: str
components: List[ModalSubmitComponentInteractionData] components: List[ModalSubmitComponentInteractionData]
resolved: NotRequired[ResolvedData]
InteractionData = Union[ InteractionData = Union[
@ -284,7 +332,12 @@ class ModalSubmitInteraction(_BaseInteraction):
data: ModalSubmitInteractionData data: ModalSubmitInteractionData
Interaction = Union[PingInteraction, ApplicationCommandInteraction, MessageComponentInteraction, ModalSubmitInteraction] Interaction = Union[
PingInteraction,
ApplicationCommandInteraction,
MessageComponentInteraction,
ModalSubmitInteraction,
]
class MessageInteraction(TypedDict): class MessageInteraction(TypedDict):
@ -332,7 +385,8 @@ class MessageComponentMessageInteractionMetadata(_MessageInteractionMetadata):
class ModalSubmitMessageInteractionMetadata(_MessageInteractionMetadata): class ModalSubmitMessageInteractionMetadata(_MessageInteractionMetadata):
type: Literal[5] type: Literal[5]
triggering_interaction_metadata: Union[ 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 .thumbnail import *
from .action_row import * from .action_row import *
from .label 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 from __future__ import annotations
import copy
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
Callable, Callable,
ClassVar, ClassVar,
Coroutine,
Dict, Dict,
Generator, Generator,
List, List,
@ -42,7 +42,7 @@ from typing import (
overload, overload,
) )
from .item import Item, ContainedItemCallbackType as ItemCallbackType from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback
from .button import Button, button as _button from .button import Button, button as _button
from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect
from ..components import ActionRow as ActionRowComponent from ..components import ActionRow as ActionRowComponent
@ -65,7 +65,6 @@ if TYPE_CHECKING:
) )
from ..emoji import Emoji from ..emoji import Emoji
from ..components import SelectOption from ..components import SelectOption
from ..interactions import Interaction
from .container import Container from .container import Container
from .dynamic import DynamicItem from .dynamic import DynamicItem
@ -77,18 +76,6 @@ V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('ActionRow',) __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]): class ActionRow(Item[V]):
r"""Represents a UI action row. r"""Represents a UI action row.
@ -143,8 +130,9 @@ class ActionRow(Item[V]):
) -> None: ) -> None:
super().__init__() super().__init__()
self._children: List[Item[V]] = self._init_children() self._children: List[Item[V]] = self._init_children()
self._children.extend(children)
self._weight: int = sum(i.width for i in self._children) self._weight: int = sum(i.width for i in self._children)
for child in children:
self.add_item(child)
if self._weight > 5: if self._weight > 5:
raise ValueError('maximum number of children exceeded') raise ValueError('maximum number of children exceeded')
@ -173,8 +161,8 @@ class ActionRow(Item[V]):
for func in self.__action_row_children_items__: for func in self.__action_row_children_items__:
item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__)
item.callback = _ActionRowCallback(func, self, item) # type: ignore item.callback = _ItemCallback(func, self, item) # type: ignore
item._parent = getattr(func, '__discord_ui_parent__', self) item._parent = self
setattr(self, func.__name__, item) setattr(self, func.__name__, item)
children.append(item) children.append(item)
return children return children
@ -184,6 +172,23 @@ class ActionRow(Item[V]):
for child in self._children: for child in self._children:
child._view = view 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): def _has_children(self):
return True return True
@ -269,7 +274,7 @@ class ActionRow(Item[V]):
item._update_view(self.view) item._update_view(self.view)
item._parent = self item._parent = self
self._weight += 1 self._weight += item.width
self._children.append(item) self._children.append(item)
return self return self
@ -293,7 +298,7 @@ class ActionRow(Item[V]):
else: else:
if self._view: if self._view:
self._view._add_count(-1) self._view._add_count(-1)
self._weight -= 1 self._weight -= item.width
return self return self

9
discord/ui/button.py

@ -26,14 +26,14 @@ from __future__ import annotations
import copy import copy
from typing import Callable, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union from typing import Callable, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union
import inspect
import os import os
from .item import Item, ContainedItemCallbackType as ItemCallbackType from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback
from ..enums import ButtonStyle, ComponentType from ..enums import ButtonStyle, ComponentType
from ..partial_emoji import PartialEmoji, _EmojiTag from ..partial_emoji import PartialEmoji, _EmojiTag
from ..components import Button as ButtonComponent from ..components import Button as ButtonComponent
from ..utils import _iscoroutinefunction
__all__ = ( __all__ = (
'Button', 'Button',
@ -304,6 +304,9 @@ class Button(Item[V]):
sku_id=self.sku_id, sku_id=self.sku_id,
id=self.id, id=self.id,
) )
if isinstance(new.callback, _ItemCallback):
new.callback.item = new
new._update_view(self.view)
return new return new
def __deepcopy__(self, memo) -> Self: def __deepcopy__(self, memo) -> Self:
@ -367,7 +370,7 @@ def button(
""" """
def decorator(func: ItemCallbackType[S, Button[V]]) -> ItemCallbackType[S, Button[V]]: 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') raise TypeError('button function must be a coroutine function')
func.__discord_ui_model_type__ = Button 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, TYPE_CHECKING,
Any, Any,
ClassVar, ClassVar,
Coroutine,
Dict, Dict,
Generator, Generator,
List, List,
@ -39,7 +38,7 @@ from typing import (
Union, Union,
) )
from .item import Item, ContainedItemCallbackType as ItemCallbackType from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback
from .view import _component_to_item, LayoutView from .view import _component_to_item, LayoutView
from ..enums import ComponentType from ..enums import ComponentType
from ..utils import get as _utils_get from ..utils import get as _utils_get
@ -49,7 +48,6 @@ if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
from ..components import Container as ContainerComponent from ..components import Container as ContainerComponent
from ..interactions import Interaction
from .dynamic import DynamicItem from .dynamic import DynamicItem
S = TypeVar('S', bound='Container', covariant=True) S = TypeVar('S', bound='Container', covariant=True)
@ -58,18 +56,6 @@ V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('Container',) __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]): class Container(Item[V]):
r"""Represents a UI container. 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 # action rows can be created inside containers, and then callbacks can exist here
# so we create items based off them # so we create items based off them
item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) 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) setattr(self, raw.__name__, item)
# this should not fail because in order for a function to be here it should be from # 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 # 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) child._update_view(view)
return True 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): def _has_children(self):
return True return True
@ -208,10 +203,6 @@ class Container(Item[V]):
"""List[:class:`Item`]: The children of this container.""" """List[:class:`Item`]: The children of this container."""
return self._children.copy() return self._children.copy()
@children.setter
def children(self, value: List[Item[V]]) -> None:
self._children = value
@property @property
def accent_colour(self) -> Optional[Union[Colour, int]]: def accent_colour(self) -> Optional[Union[Colour, int]]:
"""Optional[Union[:class:`discord.Colour`, :class:`int`]]: The colour of the container, or ``None``.""" """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), spoiler=bool(spoiler),
id=id, 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): def _is_v2(self):
return True 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 .action_row import ActionRow
from .container import Container from .container import Container
from .dynamic import DynamicItem from .dynamic import DynamicItem
from ..app_commands.namespace import ResolveKey
I = TypeVar('I', bound='Item[Any]') I = TypeVar('I', bound='Item[Any]')
V = TypeVar('V', bound='BaseView', covariant=True) 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]] 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]): class Item(Generic[V]):
"""Represents the base UI item that all UI components inherit from. """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.TextDisplay`
- :class:`discord.ui.Thumbnail` - :class:`discord.ui.Thumbnail`
- :class:`discord.ui.Label` - :class:`discord.ui.Label`
- :class:`discord.ui.RadioGroup`
- :class:`discord.ui.CheckboxGroup`
- :class:`discord.ui.Checkbox`
.. versionadded:: 2.0 .. versionadded:: 2.0
""" """
@ -97,6 +116,9 @@ class Item(Generic[V]):
def _refresh_component(self, component: Component) -> None: def _refresh_component(self, component: Component) -> None:
return 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: def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None:
return None return None

12
discord/ui/label.py

@ -50,6 +50,8 @@ V = TypeVar('V', bound='BaseView', covariant=True)
class Label(Item[V]): class Label(Item[V]):
"""Represents a UI label within a modal. """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 .. versionadded:: 2.6
Parameters Parameters
@ -60,7 +62,7 @@ class Label(Item[V]):
description: Optional[:class:`str`] description: Optional[:class:`str`]
The description text to display right below the label text. The description text to display right below the label text.
Can only be up to 100 characters. 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. The component to display below the label.
id: Optional[:class:`int`] id: Optional[:class:`int`]
The ID of the component. This must be unique across the view. 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. The description text to display right below the label text.
Can only be up to 100 characters. Can only be up to 100 characters.
component: :class:`Item` component: :class:`Item`
The component to display below the label. Currently only The component to display below the label.
supports :class:`TextInput` and :class:`Select`.
""" """
__item_repr_attributes__: Tuple[str, ...] = ( __item_repr_attributes__: Tuple[str, ...] = (
@ -138,3 +139,8 @@ class Label(Item[V]):
def is_dispatchable(self) -> bool: def is_dispatchable(self) -> bool:
return False 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 .view import BaseView
from .select import BaseSelect from .select import BaseSelect
from .text_input import TextInput from .text_input import TextInput
from ..interactions import Namespace
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
from ..interactions import Interaction 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 # fmt: off
@ -168,23 +173,41 @@ class Modal(BaseView):
""" """
_log.error('Ignoring exception in modal %r:', self, exc_info=error) _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: for component in components:
if component['type'] == 1: if component['type'] == 1:
self._refresh(interaction, component['components']) self._refresh(interaction, component['components'], resolved) # type: ignore
elif component['type'] == 18: elif component['type'] == 18:
self._refresh(interaction, [component['component']]) self._refresh(interaction, [component['component']], resolved) # type: ignore
else: 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: 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 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: try:
self._refresh_timeout() self._refresh_timeout()
self._refresh(interaction, components) self._refresh(interaction, components, resolved)
allow = await self.interaction_check(interaction) allow = await self.interaction_check(interaction)
if not allow: if not allow:
@ -200,7 +223,7 @@ class Modal(BaseView):
def to_components(self) -> List[Dict[str, Any]]: def to_components(self) -> List[Dict[str, Any]]:
def key(item: Item) -> int: 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) children = sorted(self._children, key=key)
components: List[Dict[str, Any]] = [] components: List[Dict[str, Any]] = []
@ -221,10 +244,18 @@ class Modal(BaseView):
return components return components
def _dispatch_submit( def _dispatch_submit(
self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload] self,
interaction: Interaction,
components: List[ModalSubmitComponentInteractionDataPayload],
resolved: ResolvedDataPayload,
) -> asyncio.Task[None]: ) -> asyncio.Task[None]:
try:
namespace = Namespace._get_resolved_items(interaction, resolved)
except KeyError:
namespace = {}
return asyncio.create_task( 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]: 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`] \*children: Union[:class:`str`, :class:`TextDisplay`]
The text displays of this section. Up to 3. The text displays of this section. Up to 3.
accessory: :class:`Item` accessory: :class:`Item`
The section accessory. The section accessory. This is usually either a :class:`Button` or :class:`Thumbnail`.
id: Optional[:class:`int`] id: Optional[:class:`int`]
The ID of this component. This must be unique across the view. 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): if not isinstance(value, Item):
raise TypeError(f'Expected an Item, got {value.__class__.__name__!r} instead') raise TypeError(f'Expected an Item, got {value.__class__.__name__!r} instead')
value._update_view(self.view)
value._parent = self value._parent = self
self._accessory = value self._accessory = value

72
discord/ui/select.py

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

9
discord/ui/separator.py

@ -83,6 +83,15 @@ class Separator(Item[V]):
def _is_v2(self): def _is_v2(self):
return True 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 @property
def visible(self) -> bool: def visible(self) -> bool:
""":class:`bool`: Whether this separator is visible. """:class:`bool`: Whether this separator is visible.

3
discord/ui/text_display.py

@ -43,7 +43,8 @@ __all__ = ('TextDisplay',)
class TextDisplay(Item[V]): class TextDisplay(Item[V]):
"""Represents a UI text display. """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 .. versionadded:: 2.6

12
discord/ui/text_input.py

@ -53,6 +53,8 @@ V = TypeVar('V', bound='BaseView', covariant=True)
class TextInput(Item[V]): class TextInput(Item[V]):
"""Represents a UI text input. """Represents a UI text input.
This a top-level layout component that can only be used in :class:`Label`.
.. container:: operations .. container:: operations
.. describe:: str(x) .. describe:: str(x)
@ -144,11 +146,19 @@ class TextInput(Item[V]):
id=id, id=id,
) )
self.row = row self.row = row
self.id = id
def __str__(self) -> str: def __str__(self) -> str:
return self.value 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 @property
def custom_id(self) -> str: def custom_id(self) -> str:
""":class:`str`: The ID of the text input that gets received during an interaction.""" """: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, Any,
Callable, Callable,
ClassVar, ClassVar,
Coroutine,
Dict, Dict,
Generator, Generator,
Iterator, Iterator,
@ -50,7 +49,7 @@ import sys
import time import time
import os import os
from .item import Item, ItemCallbackType from .item import Item, ItemCallbackType, _ItemCallback
from .select import Select from .select import Select
from .dynamic import DynamicItem from .dynamic import DynamicItem
from ..components import ( from ..components import (
@ -83,9 +82,13 @@ if TYPE_CHECKING:
import re import re
from ..interactions import Interaction from ..interactions import Interaction
from .._types import ClientT
from ..message import Message from ..message import Message
from ..types.components import ComponentBase as ComponentBasePayload 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 ..state import ConnectionState
from .modal import Modal from .modal import Modal
@ -204,16 +207,22 @@ class _ViewWeights:
self.weights = [0, 0, 0, 0, 0] self.weights = [0, 0, 0, 0, 0]
class _ViewCallback: class _ViewCacheSnapshot:
__slots__ = ('view', 'callback', 'item') __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: @classmethod
self.callback: ItemCallbackType[Any, Any] = callback def diff(cls, older: _ViewCacheSnapshot, newer: _ViewCacheSnapshot) -> Self:
self.view: BaseView = view self = cls()
self.item: Item[BaseView] = item 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]: def __repr__(self) -> str:
return self.callback(self.view, interaction, self.item) return f'<_ViewCacheSnapshot items={self.items!r} dynamic_items={self.dynamic_items!r}>'
class BaseView: class BaseView:
@ -229,7 +238,15 @@ class BaseView:
self.__cancel_callback: Optional[Callable[[BaseView], None]] = None self.__cancel_callback: Optional[Callable[[BaseView], None]] = None
self.__timeout_expiry: Optional[float] = None self.__timeout_expiry: Optional[float] = None
self.__timeout_task: Optional[asyncio.Task[None]] = 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())) self._total_children: int = len(tuple(self.walk_children()))
def _is_layout(self) -> bool: def _is_layout(self) -> bool:
@ -249,13 +266,13 @@ class BaseView:
item._update_view(self) item._update_view(self)
parent = getattr(item, '__discord_ui_parent__', None) parent = getattr(item, '__discord_ui_parent__', None)
if parent and parent._view is None: if parent and parent._view is None:
parent._view = self parent._update_view(self)
children.append(item) children.append(item)
parents[raw] = item parents[raw] = item
else: else:
item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__)
item.callback = _ViewCallback(raw, self, item) # type: ignore item.callback = _ItemCallback(raw, self, item) # type: ignore
item._view = self item._update_view(self)
if isinstance(item, Select): if isinstance(item, Select):
item.options = [option.copy() for option in item.options] item.options = [option.copy() for option in item.options]
setattr(self, raw.__name__, item) setattr(self, raw.__name__, item)
@ -328,6 +345,31 @@ class BaseView:
def _add_count(self, value: int) -> None: def _add_count(self, value: int) -> None:
self._total_children = max(0, self._total_children + value) 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 @property
def children(self) -> List[Item[Self]]: def children(self) -> List[Item[Self]]:
"""List[:class:`Item`]: The list of children attached to this view.""" """List[:class:`Item`]: The list of children attached to this view."""
@ -449,6 +491,7 @@ class BaseView:
pass pass
else: else:
self._add_count(-item._total_count) self._add_count(-item._total_count)
item._update_view(None)
return self return self
@ -458,6 +501,9 @@ class BaseView:
This function returns the class instance to allow for fluent-style This function returns the class instance to allow for fluent-style
chaining. chaining.
""" """
for child in self._children:
child._update_view(None)
self._children.clear() self._children.clear()
self._total_children = 0 self._total_children = 0
return self return self
@ -484,7 +530,7 @@ class BaseView:
""" """
return _utils_get(self.walk_children(), id=id) 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| """|coro|
A callback that is called when an interaction happens within the view A callback that is called when an interaction happens within the view
@ -519,7 +565,7 @@ class BaseView:
""" """
pass 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| """|coro|
A callback that is called when an item's callback or :meth:`interaction_check` 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) _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: try:
item._refresh_state(interaction, interaction.data) # type: ignore item._refresh_state(interaction, interaction.data) # type: ignore
@ -563,7 +609,7 @@ class BaseView:
self.__timeout_task = asyncio.create_task(self.__timeout_task_impl()) self.__timeout_task = asyncio.create_task(self.__timeout_task_impl())
def _dispatch_timeout(self): def _dispatch_timeout(self):
if self.__stopped.done(): if self.__stopped is None or self.__stopped.done():
return return
if self.__cancel_callback: if self.__cancel_callback:
@ -573,9 +619,9 @@ class BaseView:
self.__stopped.set_result(True) self.__stopped.set_result(True)
asyncio.create_task(self.on_timeout(), name=f'discord-ui-view-timeout-{self.id}') 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]]: def _dispatch_item(self, item: Item[Any], interaction: Interaction[ClientT]) -> Optional[asyncio.Task[None]]:
if self.__stopped.done(): if self.__stopped is None or self.__stopped.done():
return return None
return asyncio.create_task(self._scheduled_task(item, interaction), name=f'discord-ui-view-dispatch-{self.id}') 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. 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.__stopped.set_result(False)
self.__timeout_expiry = None self.__timeout_expiry = None
@ -620,6 +666,9 @@ class BaseView:
def is_finished(self) -> bool: def is_finished(self) -> bool:
""":class:`bool`: Whether the view has finished interacting.""" """:class:`bool`: Whether the view has finished interacting."""
if self.__stopped is None:
return False
return self.__stopped.done() return self.__stopped.done()
def is_dispatching(self) -> bool: def is_dispatching(self) -> bool:
@ -648,6 +697,9 @@ class BaseView:
If ``True``, then the view timed out. If ``False`` then If ``True``, then the view timed out. If ``False`` then
the view finished normally. the view finished normally.
""" """
if self.__stopped is None:
self.__stopped = asyncio.get_running_loop().create_future()
return await self.__stopped return await self.__stopped
def walk_children(self) -> Generator[Item[Any], None, None]: def walk_children(self) -> Generator[Item[Any], None, None]:
@ -754,6 +806,8 @@ class View(BaseView):
pass pass
else: else:
self.__weights.remove_item(item) self.__weights.remove_item(item)
item._update_view(None)
return self return self
def clear_items(self) -> Self: def clear_items(self) -> Self:
@ -889,8 +943,9 @@ class ViewStore:
self._modals[view.custom_id] = view # type: ignore self._modals[view.custom_id] = view # type: ignore
return return
dispatch_info = self._views.setdefault(message_id, {}) dispatch_info = self._views.get(message_id, {})
is_fully_dynamic = True is_fully_dynamic = True
snapshot = view._get_snapshot_diff()
for item in view.walk_children(): for item in view.walk_children():
if isinstance(item, DynamicItem): if isinstance(item, DynamicItem):
pattern = item.__discord_ui_compiled_template__ pattern = item.__discord_ui_compiled_template__
@ -899,26 +954,34 @@ class ViewStore:
dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore
is_fully_dynamic = False 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 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: if message_id is not None and not is_fully_dynamic:
self._synced_message_views[message_id] = view 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__: if view.__discord_ui_modal__:
self._modals.pop(view.custom_id, None) # type: ignore self._modals.pop(view.custom_id, None) # type: ignore
return return
dispatch_info = self._views.get(view._cache_key) dispatch_info = self._views.get(view._cache_key)
if dispatch_info: snapshot = view._snapshot
for item in view._children: if dispatch_info and snapshot:
if isinstance(item, DynamicItem): for key in snapshot.items:
pattern = item.__discord_ui_compiled_template__ dispatch_info.pop(key, None)
self._dynamic_items.pop(pattern, None) for key in snapshot.dynamic_items:
elif item.is_dispatchable(): self._dynamic_items.pop(key, None)
dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore
if len(dispatch_info) == 0: if dispatch_info is not None and len(dispatch_info) == 0:
self._views.pop(view._cache_key, None) self._views.pop(view._cache_key, None)
self._synced_message_views.pop(view._cache_key, None) # type: ignore self._synced_message_views.pop(view._cache_key, None) # type: ignore
@ -926,7 +989,7 @@ class ViewStore:
self, self,
component_type: int, component_type: int,
factory: Type[DynamicItem[Item[Any]]], factory: Type[DynamicItem[Item[Any]]],
interaction: Interaction, interaction: Interaction[ClientT],
custom_id: str, custom_id: str,
match: re.Match[str], match: re.Match[str],
) -> None: ) -> None:
@ -977,7 +1040,7 @@ class ViewStore:
except Exception: except Exception:
_log.exception('Ignoring exception in dynamic item callback for %r', item) _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(): for pattern, item in self._dynamic_items.items():
match = pattern.fullmatch(custom_id) match = pattern.fullmatch(custom_id)
if match is not None: 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) self.dispatch_dynamic_items(component_type, custom_id, interaction)
interaction_id: Optional[int] = None
message_id: Optional[int] = None message_id: Optional[int] = None
# Realistically, in a component based interaction the Interaction.message will never be 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 # However, this guard is just in case Discord screws up somehow
msg = interaction.message msg = interaction.message
if msg is not None: if msg is not None:
message_id = msg.id message_id = msg.id
if msg.interaction_metadata:
interaction_id = msg.interaction_metadata.id
key = (component_type, custom_id) key = (component_type, custom_id)
@ -1007,21 +1067,6 @@ class ViewStore:
if message_id is not None: if message_id is not None:
item = self._views.get(message_id, {}).get(key) 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: if item is None:
# Fallback to None message_id searches in case a persistent view # Fallback to None message_id searches in case a persistent view
# was added without an associated message_id # was added without an associated message_id
@ -1031,28 +1076,27 @@ class ViewStore:
if item is None: if item is None:
return return
# Note, at this point the View is *not* None if item.view is None:
task = item.view._dispatch_item(item, interaction) # type: ignore _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: if task is not None:
self.add_task(task) self.add_task(task)
def dispatch_modal( def dispatch_modal(
self, self,
custom_id: str, custom_id: str,
interaction: Interaction, interaction: Interaction[ClientT],
components: List[ModalSubmitComponentInteractionDataPayload], components: List[ModalSubmitComponentInteractionDataPayload],
resolved: ResolvedDataPayload,
) -> None: ) -> None:
modal = self._modals.get(custom_id) modal = self._modals.get(custom_id)
if modal is None: if modal is None:
_log.debug('Modal interaction referencing unknown custom_id %s. Discarding', custom_id) _log.debug('Modal interaction referencing unknown custom_id %s. Discarding', custom_id)
return return
self.add_task(modal._dispatch_submit(interaction, components)) self.add_task(modal._dispatch_submit(interaction, components, resolved))
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)
def is_message_tracked(self, message_id: int) -> bool: def is_message_tracked(self, message_id: int) -> bool:
return message_id in self._synced_message_views return message_id in self._synced_message_views

109
discord/utils.py

@ -26,6 +26,7 @@ from __future__ import annotations
import array import array
import asyncio import asyncio
import inspect
from textwrap import TextWrapper from textwrap import TextWrapper
from typing import ( from typing import (
Any, Any,
@ -56,6 +57,8 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
) )
import unicodedata import unicodedata
import collections.abc
from itertools import islice
from base64 import b64encode, b64decode from base64 import b64encode, b64decode
from bisect import bisect_left from bisect import bisect_left
import datetime import datetime
@ -71,7 +74,6 @@ import types
import typing import typing
import warnings import warnings
import logging import logging
import zlib
import yarl import yarl
@ -82,12 +84,20 @@ except ModuleNotFoundError:
else: else:
HAS_ORJSON = True HAS_ORJSON = True
_ZSTD_SOURCE: Literal['zstandard', 'compression.zstd'] | None = None
try: try:
import zstandard # type: ignore from zstandard import ZstdDecompressor # type: ignore
_ZSTD_SOURCE = 'zstandard'
except ImportError: except ImportError:
_HAS_ZSTD = False try:
else: from compression.zstd import ZstdDecompressor # type: ignore
_HAS_ZSTD = True
_ZSTD_SOURCE = 'compression.zstd'
except ImportError:
import zlib
__all__ = ( __all__ = (
'oauth_url', 'oauth_url',
@ -109,6 +119,7 @@ __all__ = (
DISCORD_EPOCH = 1420070400000 DISCORD_EPOCH = 1420070400000
DEFAULT_FILE_SIZE_LIMIT_BYTES = 10485760 DEFAULT_FILE_SIZE_LIMIT_BYTES = 10485760
TIMESTAMP_PATTERN: re.Pattern[str] = re.compile(r'<t:(-?\d+)(?::[tTdDfFsSR])?>')
class _MissingSentinel: 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]: 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]: 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]]: def _chunk(iterator: Iterable[T], max_size: int) -> Iterator[List[T]]:
ret = [] # Specialise iterators that can be sliced as it is much faster
n = 0 if isinstance(iterator, collections.abc.Sequence):
for item in iterator: for i in range(0, len(iterator), max_size):
ret.append(item) yield list(iterator[i : i + max_size])
n += 1 else:
if n == max_size: # Fallback to slower path
yield ret iterator = iter(iterator)
ret = [] while True:
n = 0 batch = list(islice(iterator, max_size))
if ret: if not batch:
yield ret break
yield batch
async def _achunk(iterator: AsyncIterable[T], max_size: int) -> AsyncIterator[List[T]]: 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>') 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: 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. This allows for a locale-independent way of presenting data using Discord specific Markdown.
+-------------+----------------------------+-----------------+ +-------------+--------------------------------+-------------------------+
| Style | Example Output | Description | | Style | Example Output | Description |
+=============+============================+=================+ +=============+================================+=========================+
| t | 22:57 | Short Time | | t | 22:57 | Short Time |
+-------------+----------------------------+-----------------+ +-------------+--------------------------------+-------------------------+
| T | 22:57:58 | Long Time | | T | 22:57:58 | Medium Time |
+-------------+----------------------------+-----------------+ +-------------+--------------------------------+-------------------------+
| d | 17/05/2016 | Short Date | | d | 17/05/2016 | Short Date |
+-------------+----------------------------+-----------------+ +-------------+--------------------------------+-------------------------+
| D | 17 May 2016 | Long Date | | D | May 17, 2016 | Long Date |
+-------------+----------------------------+-----------------+ +-------------+--------------------------------+-------------------------+
| f (default) | 17 May 2016 22:57 | Short Date Time | | f (default) | May 17, 2016 at 22:57 | Long Date, Short Time |
+-------------+----------------------------+-----------------+ +-------------+--------------------------------+-------------------------+
| F | Tuesday, 17 May 2016 22:57 | Long Date Time | | F | Tuesday, May 17, 2016 at 22:57 | Full Date, Short Time |
+-------------+----------------------------+-----------------+ +-------------+--------------------------------+-------------------------+
| R | 5 years ago | Relative 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 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. 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]}' return delimiter.join(seq[:-1]) + f' {final} {seq[-1]}'
if _HAS_ZSTD: if _ZSTD_SOURCE is not None:
class _ZstdDecompressionContext: class _ZstdDecompressionContext:
__slots__ = ('context',) __slots__ = ('decompressor',)
COMPRESSION_TYPE: str = 'zstd-stream' COMPRESSION_TYPE: str = 'zstd-stream'
def __init__(self) -> None: def __init__(self) -> None:
decompressor = zstandard.ZstdDecompressor() self.decompressor = ZstdDecompressor()
self.context = decompressor.decompressobj() 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: def decompress(self, data: bytes, /) -> str | None:
# Each WS message is a complete gateway message # 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 _ActiveDecompressionContext: Type[_DecompressionContext] = _ZstdDecompressionContext
else: else:
@ -1522,3 +1543,11 @@ class _RawReprMixin:
def __repr__(self) -> str: def __repr__(self) -> str:
value = ' '.join(f'{attr}={getattr(self, attr)!r}' for attr in self.__slots__) value = ' '.join(f'{attr}={getattr(self, attr)!r}' for attr in self.__slots__)
return f'<{self.__class__.__name__} {value}>' 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 .errors import ClientException
from .player import AudioPlayer, AudioSource from .player import AudioPlayer, AudioSource
from .utils import MISSING from .utils import MISSING
from .voice_state import VoiceConnectionState from .voice_state import VoiceConnectionState, has_dave
if TYPE_CHECKING: if TYPE_CHECKING:
from .gateway import DiscordVoiceWebSocket from .gateway import DiscordVoiceWebSocket
@ -218,6 +218,8 @@ class VoiceClient(VoiceProtocol):
def __init__(self, client: Client, channel: abc.Connectable) -> None: def __init__(self, client: Client, channel: abc.Connectable) -> None:
if not has_nacl: if not has_nacl:
raise RuntimeError('PyNaCl library needed in order to use voice') 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) super().__init__(client, channel)
state = client._connection state = client._connection
@ -235,6 +237,7 @@ class VoiceClient(VoiceProtocol):
self._connection: VoiceConnectionState = self.create_connection_state() self._connection: VoiceConnectionState = self.create_connection_state()
warn_nacl: bool = not has_nacl warn_nacl: bool = not has_nacl
warn_dave: bool = not has_dave
supported_modes: Tuple[SupportedModes, ...] = ( supported_modes: Tuple[SupportedModes, ...] = (
'aead_xchacha20_poly1305_rtpsize', 'aead_xchacha20_poly1305_rtpsize',
'xsalsa20_poly1305_lite', 'xsalsa20_poly1305_lite',
@ -284,6 +287,17 @@ class VoiceClient(VoiceProtocol):
def timeout(self) -> float: def timeout(self) -> float:
return self._connection.timeout 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: def checked_add(self, attr: str, value: int, limit: int) -> None:
val = getattr(self, attr) val = getattr(self, attr)
if val + value > limit: if val + value > limit:
@ -368,7 +382,12 @@ class VoiceClient(VoiceProtocol):
# audio related # 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) header = bytearray(12)
# Formulate rtp header # Formulate rtp header
@ -379,7 +398,7 @@ class VoiceClient(VoiceProtocol):
struct.pack_into('>I', header, 8, self.ssrc) struct.pack_into('>I', header, 8, self.ssrc)
encrypt_packet = getattr(self, '_encrypt_' + self.mode) 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: def _encrypt_aead_xchacha20_poly1305_rtpsize(self, header: bytes, data) -> bytes:
# Esentially the same as _lite # 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]]] WebsocketHook = Optional[Callable[[DiscordVoiceWebSocket, Dict[str, Any]], Coroutine[Any, Any, Any]]]
SocketReaderCallback = Callable[[bytes], Any] SocketReaderCallback = Callable[[bytes], Any]
has_dave: bool
try:
import davey # type: ignore
has_dave = True
except ImportError:
has_dave = False
__all__ = ('VoiceConnectionState',) __all__ = ('VoiceConnectionState',)
@ -208,6 +216,10 @@ class VoiceConnectionState:
self.mode: SupportedModes = MISSING self.mode: SupportedModes = MISSING
self.socket: socket.socket = MISSING self.socket: socket.socket = MISSING
self.ws: DiscordVoiceWebSocket = 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._state: ConnectionFlowState = ConnectionFlowState.disconnected
self._expecting_disconnect: bool = False self._expecting_disconnect: bool = False
@ -252,6 +264,64 @@ class VoiceConnectionState:
def self_voice_state(self) -> Optional[VoiceState]: def self_voice_state(self) -> Optional[VoiceState]:
return self.guild.me.voice 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: async def voice_state_update(self, data: GuildVoiceStatePayload) -> None:
channel_id = data['channel_id'] channel_id = data['channel_id']

38
discord/webhook/async_.py

@ -364,6 +364,7 @@ class AsyncWebhookAdapter:
multipart: Optional[List[Dict[str, Any]]] = None, multipart: Optional[List[Dict[str, Any]]] = None,
files: Optional[Sequence[File]] = None, files: Optional[Sequence[File]] = None,
thread_id: Optional[int] = None, thread_id: Optional[int] = None,
with_components: bool = False,
) -> Response[MessagePayload]: ) -> Response[MessagePayload]:
route = Route( route = Route(
'PATCH', 'PATCH',
@ -372,7 +373,9 @@ class AsyncWebhookAdapter:
webhook_token=token, webhook_token=token,
message_id=message_id, 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( return self.request(
route, route,
session=session, session=session,
@ -848,7 +851,15 @@ class WebhookMessage(Message):
See :meth:`.abc.Messageable.send` for more information. See :meth:`.abc.Messageable.send` for more information.
view: Optional[:class:`~discord.ui.View`] view: Optional[:class:`~discord.ui.View`]
The updated view to update this message with. If ``None`` is passed then 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 .. versionadded:: 2.0
@ -1772,7 +1783,7 @@ class Webhook(BaseWebhook):
.. versionadded:: 1.4 .. versionadded:: 1.4
view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`]
The view to send with the message. If the webhook is partial or 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. Otherwise, you can send views with any type of components.
.. versionadded:: 2.0 .. versionadded:: 2.0
@ -1857,12 +1868,10 @@ class Webhook(BaseWebhook):
if view is not MISSING: if view is not MISSING:
if not hasattr(view, '__discord_ui_view__'): 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(): if isinstance(self._state, _WebhookState) and view.is_dispatchable():
raise ValueError( raise ValueError('Webhook views with interactable components require an associated state with the webhook')
'Webhook views with any component other than URL buttons require an associated state with the webhook'
)
if ephemeral is True and view.timeout is None and view.is_dispatchable(): if ephemeral is True and view.timeout is None and view.is_dispatchable():
view.timeout = 15 * 60.0 view.timeout = 15 * 60.0
@ -2048,8 +2057,9 @@ class Webhook(BaseWebhook):
See :meth:`.abc.Messageable.send` for more information. See :meth:`.abc.Messageable.send` for more information.
view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] 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 updated view to update this message with. If ``None`` is passed then
the view is removed. The webhook must have state attached, similar to the view is removed. If the webhook is partial or is not managed by the
:meth:`send`. library, then you can not send interactable components. Otherwise, you
can send views with any type of components.
.. note:: .. note::
@ -2085,11 +2095,12 @@ class Webhook(BaseWebhook):
if self.token is None: if self.token is None:
raise ValueError('This webhook does not have a token associated with it') raise ValueError('This webhook does not have a token associated with it')
if view is not MISSING: if view:
if isinstance(self._state, _WebhookState): if not hasattr(view, '__discord_ui_view__'):
raise ValueError('This webhook does not have state associated with it') 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) previous_mentions: Optional[AllowedMentions] = getattr(self._state, 'allowed_mentions', None)
with handle_message_parameters( with handle_message_parameters(
@ -2117,6 +2128,7 @@ class Webhook(BaseWebhook):
multipart=params.multipart, multipart=params.multipart,
files=params.files, files=params.files,
thread_id=thread_id, thread_id=thread_id,
with_components=bool(view),
) )
message = self._create_message(data, thread=thread) 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, multipart: Optional[List[Dict[str, Any]]] = None,
files: Optional[Sequence[File]] = None, files: Optional[Sequence[File]] = None,
thread_id: Optional[int] = None, thread_id: Optional[int] = None,
with_components: bool = False,
) -> MessagePayload: ) -> MessagePayload:
route = Route( route = Route(
'PATCH', 'PATCH',
@ -337,7 +338,9 @@ class WebhookAdapter:
webhook_token=token, webhook_token=token,
message_id=message_id, 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) return self.request(route, session, payload=payload, multipart=multipart, files=files, params=params)
def delete_webhook_message( def delete_webhook_message(
@ -415,6 +418,7 @@ class SyncWebhookMessage(Message):
embed: Optional[Embed] = MISSING, embed: Optional[Embed] = MISSING,
attachments: Sequence[Union[Attachment, File]] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING,
allowed_mentions: Optional[AllowedMentions] = None, allowed_mentions: Optional[AllowedMentions] = None,
view: Optional[BaseView] = MISSING,
) -> SyncWebhookMessage: ) -> SyncWebhookMessage:
"""Edits the message. """Edits the message.
@ -443,6 +447,19 @@ class SyncWebhookMessage(Message):
allowed_mentions: :class:`AllowedMentions` allowed_mentions: :class:`AllowedMentions`
Controls the mentions being processed in this message. Controls the mentions being processed in this message.
See :meth:`.abc.Messageable.send` for more information. 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 Raises
------- -------
@ -451,7 +468,7 @@ class SyncWebhookMessage(Message):
Forbidden Forbidden
Edited a message that is not yours. Edited a message that is not yours.
TypeError TypeError
You specified both ``embed`` and ``embeds`` You specified both ``embed`` and ``embeds``.
ValueError ValueError
The length of ``embeds`` was invalid or The length of ``embeds`` was invalid or
there was no token associated with this webhook. there was no token associated with this webhook.
@ -469,6 +486,7 @@ class SyncWebhookMessage(Message):
attachments=attachments, attachments=attachments,
allowed_mentions=allowed_mentions, allowed_mentions=allowed_mentions,
thread=self._state._thread, thread=self._state._thread,
view=view,
) )
def add_files(self, *files: File) -> SyncWebhookMessage: 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`. 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 .. versionadded:: 2.6
allowed_mentions: :class:`AllowedMentions` allowed_mentions: :class:`AllowedMentions`
Controls the mentions being processed in this message. Controls the mentions being processed in this message.
@ -1270,6 +1294,13 @@ class SyncWebhook(BaseWebhook):
if self.token is None: if self.token is None:
raise ValueError('This webhook does not have a token associated with it') 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) previous_mentions: Optional[AllowedMentions] = getattr(self._state, 'allowed_mentions', None)
with handle_message_parameters( with handle_message_parameters(
content=content, content=content,
@ -1278,6 +1309,7 @@ class SyncWebhook(BaseWebhook):
embeds=embeds, embeds=embeds,
allowed_mentions=allowed_mentions, allowed_mentions=allowed_mentions,
previous_allowed_mentions=previous_mentions, previous_allowed_mentions=previous_mentions,
view=view,
) as params: ) as params:
thread_id: Optional[int] = None thread_id: Optional[int] = None
if thread is not MISSING: if thread is not MISSING:
@ -1293,6 +1325,7 @@ class SyncWebhook(BaseWebhook):
multipart=params.multipart, multipart=params.multipart,
files=params.files, files=params.files,
thread_id=thread_id, thread_id=thread_id,
with_components=bool(view),
) )
return self._create_message(data, thread=thread) return self._create_message(data, thread=thread)

4
docs/_static/style.css

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

16
docs/api.rst

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

5
docs/ext/commands/api.rst

@ -536,6 +536,11 @@ Converters
.. autoclass:: discord.ext.commands.SoundboardSoundConverter .. autoclass:: discord.ext.commands.SoundboardSoundConverter
:members: :members:
.. attributetable:: discord.ext.commands.Timestamp
.. autoclass:: discord.ext.commands.Timestamp
:members:
.. attributetable:: discord.ext.commands.clean_content .. attributetable:: discord.ext.commands.clean_content
.. autoclass:: 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 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: a singular type. For example, given the following:
.. code-block:: python3 .. 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, :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`. 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 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 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. 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 .. 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: after being converted to the same type. For example, given the following:
.. code-block:: python3 .. 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 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, ``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. 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 .. 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: 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 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 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. 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. :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 .. code-block:: python3
@ -632,16 +632,16 @@ This command can be invoked any of the following ways:
.. warning:: .. 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. 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:`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 :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 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. 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. :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}>') 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 .. 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] topic: Optional[str]
nsfw: Optional[bool] nsfw: Optional[bool]
slowmode: Optional[int] slowmode: Optional[int]
# Hello there --bold True # Hello there --bold True
class Greeting(commands.FlagConverter): class Greeting(commands.FlagConverter):
text: str = commands.flag(positional=True) text: str = commands.flag(positional=True)

128
docs/interactions/api.rst

@ -193,6 +193,43 @@ Container
:inherited-members: :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 AppCommand
~~~~~~~~~~~ ~~~~~~~~~~~
@ -320,6 +357,21 @@ MediaGalleryItem
.. autoclass:: MediaGalleryItem .. autoclass:: MediaGalleryItem
:members: :members:
RadioGroupOption
~~~~~~~~~~~~~~~~
.. attributetable:: RadioGroupOption
.. autoclass:: RadioGroupOption()
:members:
CheckboxGroupOption
~~~~~~~~~~~~~~~~~~~
.. attributetable:: CheckboxGroupOption
.. autoclass:: CheckboxGroupOption()
:members:
Enumerations Enumerations
------------- -------------
@ -479,6 +531,30 @@ Enumerations
.. versionadded:: 2.6 .. 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 .. class:: ButtonStyle
Represents the style of the button component. Represents the style of the button component.
@ -855,6 +931,50 @@ ActionRow
:inherited-members: :inherited-members:
:exclude-members: callback :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: .. _discord_app_commands:
Application Commands Application Commands
@ -1049,6 +1169,14 @@ Range
.. autoclass:: discord.app_commands.Range .. autoclass:: discord.app_commands.Range
:members: :members:
Timestamp
++++++++++
.. attributetable:: discord.app_commands.Timestamp
.. autoclass:: discord.app_commands.Timestamp
:members:
Translations 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 This page keeps a detailed human friendly rendering of what's new and changed
in specific versions. 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: .. _vp2p6p3:
v2.6.3 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" } dependencies = { file = "requirements.txt" }
[project.optional-dependencies] [project.optional-dependencies]
voice = ["PyNaCl>=1.5.0,<1.6"] voice = [
"PyNaCl>=1.6.0,<1.7",
"davey>=0.1.0"
]
docs = [ docs = [
"sphinx==4.4.0", "sphinx==4.4.0",
"sphinxcontrib_trio==1.1.2", "sphinxcontrib_trio==1.1.2",
@ -58,7 +61,7 @@ speed = [
"aiodns>=1.1; sys_platform != 'win32'", "aiodns>=1.1; sys_platform != 'win32'",
"Brotli", "Brotli",
"cchardet==2.1.7; python_version < '3.10'", "cchardet==2.1.7; python_version < '3.10'",
"zstandard>=0.23.0" "zstandard>=0.23.0; python_version <= '3.13'"
] ]
test = [ test = [
"coverage[toml]", "coverage[toml]",
@ -86,12 +89,12 @@ packages = [
] ]
include-package-data = true include-package-data = true
[tool.black]
line-length = 125
skip-string-normalization = true
[tool.ruff] [tool.ruff]
line-length = 125 line-length = 125
extend-exclude = ["docs", "tests"]
[tool.ruff.lint.isort]
combine-as-imports = true
[tool.ruff.format] [tool.ruff.format]
line-ending = "lf" line-ending = "lf"
@ -110,12 +113,6 @@ exclude_lines = [
"@overload", "@overload",
] ]
[tool.isort]
profile = "black"
combine_as_imports = true
combine_star = true
line_length = 125
[tool.pyright] [tool.pyright]
include = [ include = [
"discord", "discord",

Loading…
Cancel
Save