Browse Source

Merge branch 'master' into feat/community-invite

pull/10386/head
Soheab 4 months ago
parent
commit
ce86ba395f
  1. 4
      .github/CONTRIBUTING.md
  2. 4
      discord/__init__.py
  3. 8
      discord/__main__.py
  4. 12
      discord/app_commands/tree.py
  5. 4
      discord/client.py
  6. 10
      discord/errors.py
  7. 41
      discord/ext/tasks/__init__.py
  8. 1
      discord/gateway.py
  9. 2
      discord/guild.py
  10. 7
      discord/http.py
  11. 49
      discord/interactions.py
  12. 6
      discord/message.py
  13. 12
      discord/partial_emoji.py
  14. 79
      discord/player.py
  15. 5
      discord/shard.py
  16. 6
      discord/state.py
  17. 87
      discord/ui/view.py
  18. 5
      discord/voice_client.py
  19. 3
      docs/api.rst
  20. 85
      docs/whats_new.rst
  21. 4
      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")

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))

12
discord/app_commands/tree.py

@ -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:

4
discord/client.py

@ -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

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"""

41
discord/ext/tasks/__init__.py

@ -37,6 +37,7 @@ from typing import (
Type, Type,
TypeVar, TypeVar,
Union, Union,
overload,
) )
import aiohttp import aiohttp
@ -176,7 +177,7 @@ 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
@ -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,

1
discord/gateway.py

@ -654,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

2
discord/guild.py

@ -677,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)

7
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,

49
discord/interactions.py

@ -615,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:
@ -1082,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,
@ -1090,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:
@ -1113,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,
@ -1205,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
@ -1238,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,
@ -1246,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:
@ -1263,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|

6
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)

12
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:

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

6
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]:

87
discord/ui/view.py

@ -207,6 +207,24 @@ class _ViewWeights:
self.weights = [0, 0, 0, 0, 0] self.weights = [0, 0, 0, 0, 0]
class _ViewCacheSnapshot:
__slots__ = ('items', 'dynamic_items')
def __init__(self) -> None:
self.items: Set[Tuple[int, str]] = set()
self.dynamic_items: Set[re.Pattern[str]] = set()
@classmethod
def diff(cls, older: _ViewCacheSnapshot, newer: _ViewCacheSnapshot) -> Self:
self = cls()
self.items = older.items - newer.items
self.dynamic_items = older.dynamic_items - newer.dynamic_items
return self
def __repr__(self) -> str:
return f'<_ViewCacheSnapshot items={self.items!r} dynamic_items={self.dynamic_items!r}>'
class BaseView: class BaseView:
__discord_ui_view__: ClassVar[bool] = False __discord_ui_view__: ClassVar[bool] = False
__discord_ui_modal__: ClassVar[bool] = False __discord_ui_modal__: ClassVar[bool] = False
@ -220,6 +238,7 @@ 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.__snapshot: Optional[_ViewCacheSnapshot] = None
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@ -326,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."""
@ -901,6 +945,7 @@ class ViewStore:
dispatch_info = self._views.get(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__
@ -909,6 +954,12 @@ 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: if dispatch_info:
self._views[message_id] = dispatch_info self._views[message_id] = dispatch_info
@ -922,13 +973,12 @@ class ViewStore:
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.walk_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 dispatch_info is not None and 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)
@ -1003,15 +1053,12 @@ class ViewStore:
def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction[ClientT]) -> 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)
@ -1020,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
@ -1066,11 +1098,6 @@ class ViewStore:
self.add_task(modal._dispatch_submit(interaction, components, resolved)) 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

5
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',

3
docs/api.rst

@ -6254,6 +6254,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
@ -6272,6 +6274,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`

85
docs/whats_new.rst

@ -11,6 +11,91 @@ 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: .. _vp2p6p4:
v2.6.4 v2.6.4

4
pyproject.toml

@ -37,8 +37,8 @@ dependencies = { file = "requirements.txt" }
[project.optional-dependencies] [project.optional-dependencies]
voice = [ voice = [
"PyNaCl>=1.5.0,<1.6", "PyNaCl>=1.6.0,<1.7",
"davey==0.1.0" "davey>=0.1.0"
] ]
docs = [ docs = [
"sphinx==4.4.0", "sphinx==4.4.0",

Loading…
Cancel
Save