diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 68f037c31..9b631df15 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -34,6 +34,10 @@ If the bug report is missing this information then it'll take us longer to fix t Submitting a pull request is fairly simple, just make sure it focuses on a single aspect and doesn't manage to have scope creep and it's probably good to go. It would be incredibly lovely if the style is consistent to that found in the project. This project follows PEP-8 guidelines (mostly) with a column limit of 125. +### AI Contributions + +This repository does not accept any AI contributions at all. Using tools like Claude Code, Copilot, Gemini, ChatGPT, OpenAI Codex, etc. are simply blanket banned. AI contributions are typically nonsensical and just take up very valuable review time and thus are banned. Pull requests that are made with AI tools will be instantly closed without review, no matter how small the changeset is. + ### Git Commit Guidelines - Use present tense (e.g. "Add feature" not "Added feature") diff --git a/discord/__init__.py b/discord/__init__.py index 3279f8b8c..bfa652b91 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __title__ = 'discord' __author__ = 'Rapptz' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.7.0a' +__version__ = '2.8.0a' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -86,7 +86,7 @@ class VersionInfo(NamedTuple): serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=7, micro=0, releaselevel='alpha', serial=0) +version_info: VersionInfo = VersionInfo(major=2, minor=8, micro=0, releaselevel='alpha', serial=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/discord/__main__.py b/discord/__main__.py index 455c5e8ed..1b8d50211 100644 --- a/discord/__main__.py +++ b/discord/__main__.py @@ -48,6 +48,14 @@ def show_version() -> None: entries.append(f' - discord.py metadata: v{version}') entries.append(f'- aiohttp v{aiohttp.__version__}') + + try: + import davey # type: ignore + except ImportError: + entries.append('- davey not found') + else: + entries.append(f'- davey v{davey.__version__}') + uname = platform.uname() entries.append('- system info: {0.system} {0.release} {0.version}'.format(uname)) print('\n'.join(entries)) diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py index ce756306a..4db1d83f6 100644 --- a/discord/app_commands/tree.py +++ b/discord/app_commands/tree.py @@ -257,7 +257,7 @@ class CommandTree(Generic[ClientT]): -------- CommandLimitReached The maximum number of commands was reached for that guild. - This is currently 100 for slash commands and 5 for context menu commands. + This is currently 100 for slash commands and 15 for context menu commands. """ try: @@ -277,9 +277,9 @@ class CommandTree(Generic[ClientT]): counter = Counter(cmd_type for _, _, cmd_type in ctx_menu) for cmd_type, count in counter.items(): - if count > 5: + if count > 15: as_enum = AppCommandType(cmd_type) - raise CommandLimitReached(guild_id=guild.id, limit=5, type=as_enum) + raise CommandLimitReached(guild_id=guild.id, limit=15, type=as_enum) self._context_menus.update(ctx_menu) self._guild_commands[guild.id] = mapping @@ -338,7 +338,7 @@ class CommandTree(Generic[ClientT]): Or, ``guild`` and ``guilds`` were both given. CommandLimitReached The maximum number of commands was reached globally or for that guild. - This is currently 100 for slash commands and 5 for context menu commands. + This is currently 100 for slash commands and 15 for context menu commands. """ guild_ids = _retrieve_guild_ids(command, guild, guilds) @@ -361,8 +361,8 @@ class CommandTree(Generic[ClientT]): # read as `0 if override and found else 1` if confusing to_add = not (override and found) total = sum(1 for _, g, t in self._context_menus if g == guild_id and t == type) - if total + to_add > 5: - raise CommandLimitReached(guild_id=guild_id, limit=5, type=AppCommandType(type)) + if total + to_add > 15: + raise CommandLimitReached(guild_id=guild_id, limit=15, type=AppCommandType(type)) data[key] = command if guild_ids is None: diff --git a/discord/client.py b/discord/client.py index 2d78fc2f4..1c1ed9f48 100644 --- a/discord/client.py +++ b/discord/client.py @@ -340,6 +340,10 @@ class Client: VoiceClient.warn_nacl = False _log.warning('PyNaCl is not installed, voice will NOT be supported') + if VoiceClient.warn_dave: + VoiceClient.warn_dave = False + _log.warning('davey is not installed, voice will NOT be supported') + async def __aenter__(self) -> Self: await self._async_setup_hook() return self diff --git a/discord/errors.py b/discord/errors.py index c07a7ed15..11f5cfaa2 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -48,6 +48,7 @@ __all__ = ( 'PrivilegedIntentsRequired', 'InteractionResponded', 'MissingApplicationID', + 'FFmpegProcessError', ) APP_ID_NOT_FOUND = ( @@ -74,6 +75,15 @@ class ClientException(DiscordException): pass +class FFmpegProcessError(ClientException): + """Exception that's raised when an FFmpeg process fails. + + .. versionadded:: 2.7 + """ + + pass + + class GatewayNotFound(DiscordException): """An exception that is raised when the gateway for Discord could not be found""" diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 6ed0273e3..ba11d6322 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -37,6 +37,7 @@ from typing import ( Type, TypeVar, Union, + overload, ) import aiohttp @@ -176,7 +177,7 @@ class Loop(Generic[LF]): if self.count is not None and self.count <= 0: raise ValueError('count must be greater than 0 or None.') - self.change_interval(seconds=seconds, minutes=minutes, hours=hours, time=time) + self.change_interval(seconds=seconds, minutes=minutes, hours=hours, time=time) # type: ignore self._last_iteration_failed = False self._last_iteration: datetime.datetime = MISSING self._next_iteration = None @@ -710,6 +711,22 @@ class Loop(Generic[LF]): ret = sorted(set(ret)) # de-dupe and sort times return ret + @overload + def change_interval( + self, + *, + seconds: float = 0, + minutes: float = 0, + hours: float = 0, + ) -> None: ... + + @overload + def change_interval( + self, + *, + time: Union[datetime.time, Sequence[datetime.time]], + ) -> None: ... + def change_interval( self, *, @@ -777,6 +794,28 @@ class Loop(Generic[LF]): self._handle.recalculate(self._next_iteration) +@overload +def loop( + *, + seconds: float = 0, + minutes: float = 0, + hours: float = 0, + count: Optional[int] = None, + reconnect: bool = True, + name: Optional[str] = None, +) -> Callable[[LF], Loop[LF]]: ... + + +@overload +def loop( + *, + time: Union[datetime.time, Sequence[datetime.time]], + count: Optional[int] = None, + reconnect: bool = True, + name: Optional[str] = None, +) -> Callable[[LF], Loop[LF]]: ... + + def loop( *, seconds: float = MISSING, diff --git a/discord/gateway.py b/discord/gateway.py index 3f283ef71..45034d01a 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -654,6 +654,7 @@ class DiscordWebSocket: self._keep_alive.stop() self._keep_alive = None + await self.socket.close(code=4000) if isinstance(e, asyncio.TimeoutError): _log.debug('Timed out receiving packet. Attempting a reconnect.') raise ReconnectWebSocket(self.shard_id) from None diff --git a/discord/guild.py b/discord/guild.py index f93cf175d..0dd999de8 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -677,7 +677,7 @@ class Guild(Hashable): scheduled_event = ScheduledEvent(data=s, state=self._state) self._scheduled_events[scheduled_event.id] = scheduled_event - if 'soundboard_sounds' in guild: + if 'soundboard_sounds' in guild and state.cache_guild_expressions: for s in guild['soundboard_sounds']: soundboard_sound = SoundboardSound(guild=self, data=s, state=self._state) self._add_soundboard_sound(soundboard_sound) diff --git a/discord/http.py b/discord/http.py index 33825c925..4a06cf0f4 100644 --- a/discord/http.py +++ b/discord/http.py @@ -551,11 +551,16 @@ class HTTPClient: self.__session = MISSING async def ws_connect(self, url: str, *, compress: int = 0) -> aiohttp.ClientWebSocketResponse: + try: + timeout: Any = aiohttp.ClientWSTimeout(ws_close=30.0) # pyright: ignore[reportCallIssue] + except (AttributeError, TypeError): + timeout = 30.0 + kwargs = { 'proxy_auth': self.proxy_auth, 'proxy': self.proxy, 'max_msg_size': 0, - 'timeout': 30.0, + 'timeout': timeout, 'autoclose': False, 'headers': { 'User-Agent': self.user_agent, diff --git a/discord/interactions.py b/discord/interactions.py index 2724b38b8..ebcec1a6b 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -615,7 +615,7 @@ class Interaction(Generic[ClientT]): state = _InteractionMessageState(self, self._state) message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore if view and not view.is_finished() and view.is_dispatchable(): - self._state.store_view(view, message.id, interaction_id=self.id) + self._state.store_view(view, message.id) return message async def delete_original_response(self) -> None: @@ -1082,7 +1082,7 @@ class InteractionResponse(Generic[ClientT]): ) http = parent._state.http - response = await adapter.create_interaction_response( + data = await adapter.create_interaction_response( parent.id, parent.token, session=parent._session, @@ -1090,17 +1090,19 @@ class InteractionResponse(Generic[ClientT]): proxy_auth=http.proxy_auth, params=params, ) + self._response_type = InteractionResponseType.channel_message + response = InteractionCallbackResponse( + data=data, + parent=self._parent, + state=self._parent._state, + type=self._response_type, + ) if view is not MISSING and not view.is_finished(): if ephemeral and view.timeout is None: view.timeout = 15 * 60.0 - # If the interaction type isn't an application command then there's no way - # to obtain this interaction_id again, so just default to None - entity_id = parent.id if parent.type is InteractionType.application_command else None - self._parent._state.store_view(view, entity_id) - - self._response_type = InteractionResponseType.channel_message + self._parent._state.store_view(view, response.message_id) if delete_after is not None: @@ -1113,12 +1115,7 @@ class InteractionResponse(Generic[ClientT]): asyncio.create_task(inner_call()) - return InteractionCallbackResponse( - data=response, - parent=self._parent, - state=self._parent._state, - type=self._response_type, - ) + return response async def edit_message( self, @@ -1205,12 +1202,8 @@ class InteractionResponse(Generic[ClientT]): state = parent._state if msg is not None: message_id = msg.id - # If this was invoked via an application command then we can use its original interaction ID - # Since this is used as a cache key for view updates - original_interaction_id = msg.interaction_metadata.id if msg.interaction_metadata is not None else None else: message_id = None - original_interaction_id = None if parent.type not in (InteractionType.component, InteractionType.modal_submit): return @@ -1238,7 +1231,7 @@ class InteractionResponse(Generic[ClientT]): ) http = parent._state.http - response = await adapter.create_interaction_response( + data = await adapter.create_interaction_response( parent.id, parent.token, session=parent._session, @@ -1246,11 +1239,16 @@ class InteractionResponse(Generic[ClientT]): proxy_auth=http.proxy_auth, params=params, ) + self._response_type = InteractionResponseType.message_update + response = InteractionCallbackResponse( + data=data, + parent=self._parent, + state=self._parent._state, + type=self._response_type, + ) if view and not view.is_finished() and view.is_dispatchable(): - state.store_view(view, message_id, interaction_id=original_interaction_id) - - self._response_type = InteractionResponseType.message_update + state.store_view(view, message_id or response.message_id) if delete_after is not None: @@ -1263,12 +1261,7 @@ class InteractionResponse(Generic[ClientT]): asyncio.create_task(inner_call()) - return InteractionCallbackResponse( - data=response, - parent=self._parent, - state=self._parent._state, - type=self._response_type, - ) + return response async def send_modal(self, modal: Modal, /) -> InteractionCallbackResponse[ClientT]: """|coro| diff --git a/discord/message.py b/discord/message.py index 19d78dd53..5192a54d2 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1415,11 +1415,7 @@ class PartialMessage(Hashable): message = Message(state=self._state, channel=self.channel, data=data) if view and not view.is_finished() and view.is_dispatchable(): - interaction: Optional[MessageInteractionMetadata] = getattr(self, 'interaction_metadata', None) - if interaction is not None: - self._state.store_view(view, self.id, interaction_id=interaction.id) - else: - self._state.store_view(view, self.id) + self._state.store_view(view, self.id) if delete_after is not None: await self.delete(delay=delete_after) diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index d028244c0..3301864a6 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -39,6 +39,7 @@ __all__ = ( if TYPE_CHECKING: from typing_extensions import Self + from .client import Client from .state import ConnectionState from datetime import datetime from .types.emoji import Emoji as EmojiPayload, PartialEmoji as PartialEmojiPayload @@ -114,7 +115,7 @@ class PartialEmoji(_EmojiTag, AssetMixin): ) @classmethod - def from_str(cls, value: str) -> Self: + def from_str(cls, value: str, *, client: Client = utils.MISSING) -> Self: """Converts a Discord string representation of an emoji to a :class:`PartialEmoji`. The formats accepted are: @@ -132,6 +133,11 @@ class PartialEmoji(_EmojiTag, AssetMixin): ------------ value: :class:`str` The string representation of an emoji. + client: :class:`Client` + The client to initialise this emoji with. This allows it to + attach the client's internal state. + + .. versionadded:: 2.7 Returns -------- @@ -144,8 +150,12 @@ class PartialEmoji(_EmojiTag, AssetMixin): animated = bool(groups['animated']) emoji_id = int(groups['id']) name = groups['name'] + if client is not utils.MISSING: + return cls.with_state(name=name, animated=animated, id=emoji_id, state=client._connection) return cls(name=name, animated=animated, id=emoji_id) + if client is not utils.MISSING: + return cls.with_state(name=value, animated=False, id=None, state=client._connection) return cls(name=value, id=None, animated=False) def to_dict(self) -> EmojiPayload: diff --git a/discord/player.py b/discord/player.py index 6243c0417..2a1fdf95d 100644 --- a/discord/player.py +++ b/discord/player.py @@ -40,7 +40,7 @@ import io from typing import Any, Callable, Generic, IO, Optional, TYPE_CHECKING, Tuple, TypeVar, Union from .enums import SpeakingState -from .errors import ClientException +from .errors import ClientException, FFmpegProcessError from .opus import Encoder as OpusEncoder, OPUS_SILENCE from .oggparse import OggStream from .utils import MISSING @@ -186,6 +186,8 @@ class FFmpegAudio(AudioSource): self._stderr: Optional[IO[bytes]] = None self._pipe_writer_thread: Optional[threading.Thread] = None self._pipe_reader_thread: Optional[threading.Thread] = None + self._current_error: Optional[Exception] = None + self._stopped: bool = False if piping_stdin: n = f'popen-stdin-writer:pid-{self._process.pid}' @@ -212,25 +214,72 @@ class FFmpegAudio(AudioSource): else: return process + def _check_process_returncode(self) -> None: + """Set _current_error if FFmpeg exited with a non-zero code.""" + if self._process is MISSING: + return + + ret = self._process.poll() + if ret is None: + return # still running + + if self._stopped: + return # intentionally stopped + + if ret != 0 and self._current_error is None: + # Only set error once, on first detection + # read stderr if available + stderr_text = None + if self._stderr: + try: + stderr_text = self._stderr.read(8192).decode(errors='ignore') + except Exception: + stderr_text = '' + + stderr_info = stderr_text if stderr_text else '' + self._current_error = FFmpegProcessError(f'FFmpeg exited with code {ret}. Stderr: {stderr_info}') + def _kill_process(self) -> None: + # check if FFmpeg process failed + self._check_process_returncode() + # this function gets called in __del__ so instance attributes might not even exist proc = getattr(self, '_process', MISSING) + # Only proceed if proc is a subprocess.Popen instance if proc is MISSING: return - _log.debug('Preparing to terminate ffmpeg process %s.', proc.pid) + pid = getattr(proc, 'pid', 'unknown') + _log.debug('Preparing to terminate ffmpeg process %s.', pid) try: proc.kill() except Exception: - _log.exception('Ignoring error attempting to kill ffmpeg process %s', proc.pid) + _log.exception('Ignoring error attempting to kill ffmpeg process %s', pid) + + try: + still_running = proc.poll() is None + except Exception: + _log.exception('Error checking poll() on ffmpeg process %s', pid) + still_running = False - if proc.poll() is None: - _log.info('ffmpeg process %s has not terminated. Waiting to terminate...', proc.pid) - proc.communicate() - _log.info('ffmpeg process %s should have terminated with a return code of %s.', proc.pid, proc.returncode) + if still_running: + _log.info('ffmpeg process %s has not terminated. Waiting to terminate...', pid) + try: + proc.communicate() + except Exception: + pass + _log.info( + 'ffmpeg process %s should have terminated with a return code of %s.', + pid, + getattr(proc, 'returncode', 'unknown'), + ) else: - _log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode) + _log.info( + 'ffmpeg process %s successfully terminated with return code of %s.', + pid, + getattr(proc, 'returncode', 'unknown'), + ) def _pipe_writer(self, source: io.BufferedIOBase) -> None: while self._process: @@ -267,6 +316,7 @@ class FFmpegAudio(AudioSource): return def cleanup(self) -> None: + self._stopped = True self._kill_process() self._process = self._stdout = self._stdin = self._stderr = MISSING @@ -348,6 +398,8 @@ class FFmpegPCMAudio(FFmpegAudio): def read(self) -> bytes: ret = self._stdout.read(OpusEncoder.FRAME_SIZE) if len(ret) != OpusEncoder.FRAME_SIZE: + # Check for FFmpeg process failure when read returns incomplete data + self._check_process_returncode() return b'' return ret @@ -646,7 +698,11 @@ class FFmpegOpusAudio(FFmpegAudio): return codec, bitrate def read(self) -> bytes: - return next(self._packet_iter, b'') + data = next(self._packet_iter, b'') + if not data: + # Check for FFmpeg process failure when read returns empty + self._check_process_returncode() + return data def is_opus(self) -> bool: return True @@ -745,6 +801,11 @@ class AudioPlayer(threading.Thread): data = self.source.read() if not data: + # Check if the source has an error (e.g., from FFmpegAudio process failure) + if self._current_error is None: + source_error = getattr(self.source, '_current_error', None) + if source_error: + self._current_error = source_error self.stop() break diff --git a/discord/shard.py b/discord/shard.py index 7198887cf..b01818bf8 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -41,6 +41,7 @@ from .errors import ( ConnectionClosed, PrivilegedIntentsRequired, ) +from .utils import MISSING from .enums import Status @@ -389,6 +390,7 @@ class AutoShardedClient(Client): self.__shards = {} self._connection._get_websocket = self._get_websocket self._connection._get_client = lambda: self + self.__queue: asyncio.PriorityQueue = MISSING def _get_websocket(self, guild_id: Optional[int] = None, *, shard_id: Optional[int] = None) -> DiscordWebSocket: if shard_id is None: @@ -554,7 +556,8 @@ class AutoShardedClient(Client): await asyncio.wait(to_close) await self.http.close() - self.__queue.put_nowait(EventItem(EventType.clean_close, None, None)) + if self.__queue is not MISSING: + self.__queue.put_nowait(EventItem(EventType.clean_close, None, None)) self._closing_task = asyncio.create_task(_close()) await self._closing_task diff --git a/discord/state.py b/discord/state.py index 7ef3bbd15..9c119acfc 100644 --- a/discord/state.py +++ b/discord/state.py @@ -279,7 +279,7 @@ class ConnectionState(Generic[ClientT]): # So this is checked instead, it's a small penalty to pay @property def cache_guild_expressions(self) -> bool: - return self._intents.emojis_and_stickers + return self._intents.expressions async def close(self) -> None: for voice in self.voice_clients: @@ -412,9 +412,7 @@ class ConnectionState(Generic[ClientT]): self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) return sticker - def store_view(self, view: BaseView, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None: - if interaction_id is not None: - self._view_store.remove_interaction_mapping(interaction_id) + def store_view(self, view: BaseView, message_id: Optional[int] = None) -> None: self._view_store.add_view(view, message_id) def prevent_view_updates_for(self, message_id: int) -> Optional[BaseView]: diff --git a/discord/ui/view.py b/discord/ui/view.py index 8dc6b01aa..582c619d7 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -207,6 +207,24 @@ class _ViewWeights: 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: __discord_ui_view__: ClassVar[bool] = False __discord_ui_modal__: ClassVar[bool] = False @@ -220,6 +238,7 @@ class BaseView: self.__cancel_callback: Optional[Callable[[BaseView], None]] = None self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None + self.__snapshot: Optional[_ViewCacheSnapshot] = None try: loop = asyncio.get_running_loop() @@ -326,6 +345,31 @@ class BaseView: def _add_count(self, value: int) -> None: self._total_children = max(0, self._total_children + value) + @property + def _snapshot(self) -> Optional[_ViewCacheSnapshot]: + return self.__snapshot + + def _get_snapshot_diff(self) -> Optional[_ViewCacheSnapshot]: + if self.__snapshot is None: + self.__snapshot = self._get_snapshot() + return None + + newer = self._get_snapshot() + diff = _ViewCacheSnapshot.diff(older=self.__snapshot, newer=newer) + # Update our snapshot to the newer version after diffing it + self.__snapshot = newer + return diff + + def _get_snapshot(self) -> _ViewCacheSnapshot: + snapshot = _ViewCacheSnapshot() + for item in self.walk_children(): + if isinstance(item, DynamicItem): + snapshot.dynamic_items.add(item.__discord_ui_compiled_template__) + elif item.is_dispatchable(): + custom_id = item.custom_id # type: ignore + snapshot.items.add((item.type.value, custom_id)) + return snapshot + @property def children(self) -> List[Item[Self]]: """List[:class:`Item`]: The list of children attached to this view.""" @@ -901,6 +945,7 @@ class ViewStore: dispatch_info = self._views.get(message_id, {}) is_fully_dynamic = True + snapshot = view._get_snapshot_diff() for item in view.walk_children(): if isinstance(item, DynamicItem): pattern = item.__discord_ui_compiled_template__ @@ -909,6 +954,12 @@ class ViewStore: dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore is_fully_dynamic = False + if snapshot is not None: + for key in snapshot.items: + dispatch_info.pop(key, None) + for key in snapshot.dynamic_items: + self._dynamic_items.pop(key, None) + view._cache_key = message_id if dispatch_info: self._views[message_id] = dispatch_info @@ -922,13 +973,12 @@ class ViewStore: return dispatch_info = self._views.get(view._cache_key) - if dispatch_info: - for item in view.walk_children(): - if isinstance(item, DynamicItem): - pattern = item.__discord_ui_compiled_template__ - self._dynamic_items.pop(pattern, None) - elif item.is_dispatchable(): - dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore + snapshot = view._snapshot + if dispatch_info and snapshot: + for key in snapshot.items: + dispatch_info.pop(key, None) + for key in snapshot.dynamic_items: + self._dynamic_items.pop(key, None) if dispatch_info is not None and len(dispatch_info) == 0: 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: self.dispatch_dynamic_items(component_type, custom_id, interaction) - interaction_id: Optional[int] = None message_id: Optional[int] = None # Realistically, in a component based interaction the Interaction.message will never be None # However, this guard is just in case Discord screws up somehow msg = interaction.message if msg is not None: message_id = msg.id - if msg.interaction_metadata: - interaction_id = msg.interaction_metadata.id key = (component_type, custom_id) @@ -1020,21 +1067,6 @@ class ViewStore: if message_id is not None: item = self._views.get(message_id, {}).get(key) - if item is None and interaction_id is not None: - try: - items = self._views.pop(interaction_id) - except KeyError: - item = None - else: - item = items.get(key) - # If we actually got the items, then these keys should probably be moved - # to the proper message_id instead of the interaction_id as they are now. - # An interaction_id is only used as a temporary stop gap for - # InteractionResponse.send_message so multiple view instances do not - # override each other. - # NOTE: Fix this mess if /callback endpoint ever gets proper return types - self._views.setdefault(message_id, {}).update(items) - if item is None: # Fallback to None message_id searches in case a persistent view # was added without an associated message_id @@ -1066,11 +1098,6 @@ class ViewStore: 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: return message_id in self._synced_message_views diff --git a/discord/voice_client.py b/discord/voice_client.py index 3b489b7d0..f138a8c8a 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -34,7 +34,7 @@ from .gateway import * from .errors import ClientException from .player import AudioPlayer, AudioSource from .utils import MISSING -from .voice_state import VoiceConnectionState +from .voice_state import VoiceConnectionState, has_dave if TYPE_CHECKING: from .gateway import DiscordVoiceWebSocket @@ -218,6 +218,8 @@ class VoiceClient(VoiceProtocol): def __init__(self, client: Client, channel: abc.Connectable) -> None: if not has_nacl: raise RuntimeError('PyNaCl library needed in order to use voice') + if not has_dave: + raise RuntimeError('davey library needed in order to use voice') super().__init__(client, channel) state = client._connection @@ -235,6 +237,7 @@ class VoiceClient(VoiceProtocol): self._connection: VoiceConnectionState = self.create_connection_state() warn_nacl: bool = not has_nacl + warn_dave: bool = not has_dave supported_modes: Tuple[SupportedModes, ...] = ( 'aead_xchacha20_poly1305_rtpsize', 'xsalsa20_poly1305_lite', diff --git a/docs/api.rst b/docs/api.rst index 755ad0d1b..6da2629cc 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6254,6 +6254,8 @@ The following exceptions are thrown by the library. .. autoexception:: MissingApplicationID +.. autoexception:: FFmpegProcessError + .. autoexception:: discord.opus.OpusError .. autoexception:: discord.opus.OpusNotLoaded @@ -6272,6 +6274,7 @@ Exception Hierarchy - :exc:`PrivilegedIntentsRequired` - :exc:`InteractionResponded` - :exc:`MissingApplicationID` + - :exc:`FFmpegProcessError` - :exc:`GatewayNotFound` - :exc:`HTTPException` - :exc:`Forbidden` diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 33b6ec090..cda9eca44 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,91 @@ Changelog This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +.. _vp2p7p1: + +v2.7.1 +------- + +Bug Fixes +~~~~~~~~~~ + +- Fix memory leak when using :class:`ui.LayoutView` and removing items but those items not being removed from internal cache. +- Fix ``aiohttp`` deprecation warning for websocket timeouts (:issue:`10418`) + +Miscellaneous +~~~~~~~~~~~~~~ + +- Show ``davey`` dependency output in ``python -m discord --version`` to debug DAVE issues +- Raise an error and warn when ``davey`` is not installed and using voice +- Change how views are bound to the internal cache when using interactions + +.. _vp2p7p0: + +v2.7.0 +------- + +New Features +~~~~~~~~~~~~~ + +- Add DAVE protocol support for voice connections (:issue:`10300`) +- Add support for new :class:`ui.Modal` components (:issue:`10390`) + - :class:`CheckboxGroupComponent` corresponds to :class:`ui.CheckboxGroup` + - :class:`CheckboxComponent` corresponds to :class:`ui.Checkbox` + - :class:`RadioGroupComponent` corresponds to :class:`ui.RadioGroup` + - :class:`CheckboxGroupOption` and :class:`RadioGroupOption` allow creating these options + +- Add timestamp converter and transformer for use with new ``@time`` markdown option (:issue:`10388`) + - This is accessible via :class:`app_commands.Timestamp` and :class:`ext.commands.Timestamp` as an annotation + +- Add several new permissions: + - :attr:`Permissions.bypass_slowmode` (:issue:`10350`) + - :attr:`Permissions.set_voice_channel_status` (:issue:`10279`) + - :attr:`Permissions.pin_messages` + +- Add ``client`` parameter to :meth:`PartialEmoji.from_str` (:issue:`10407`) +- Add support for user collectibles accessible via :attr:`User.collectibles` and :attr:`Member.collectibles` (:issue:`10277`) +- Add :meth:`Message.is_forwardable` to check if a message can be forwarded (:issue:`10353`) +- Add support for getting an integration's scopes (:issue:`10352`) +- Add :attr:`Interaction.command_id` and :attr:`Interaction.custom_id` helpers (:issue:`10321`) +- Support new fields in :meth:`Member.edit` (:issue:`10303`) +- Add support for getting role member counts via :meth:`Guild.role_member_counts` +- Add :attr:`MessageType.is_deletable` +- Add ``reason`` keyword argument to :meth:`Client.delete_invite` (:issue:`10318`, :issue:`10340`) +- Add ``silent`` parameter to :meth:`ForumChannel.create_thread` (:issue:`10304`) +- Add support for :attr:`MessageType.emoji_added` (:issue:`10284`) +- Add channel attribute to automod quarantine user AuditLogAction (:issue:`10274`) + +Bug Fixes +~~~~~~~~~~ + +- Fix FFmpeg errors not sent to after callback (:issue:`10387`) +- Fix :meth:`Webhook.edit_message` missing the view parameter (:issue:`10395`, :issue:`10398`) +- Fix :meth:`TextChannel.purge` failing when encountering certain system messages +- Fix :attr:`Message.call` raising an attribute error when accessed (:issue:`10404`) +- Fix certain component IDs not being able to be settable afterwards +- Fix :class:`ui.Modal` not raising when hitting the 5 item limit +- Fix :attr:`ui.Item.row` not being set appropriately when used in a :class:`ui.Modal` (:issue:`10397`) +- Fix ``compression.zstd`` not working as expected when Discord does not send encoding information (:issue:`10344`) +- Fix rare bug where :attr:`Client.latency` was incorrect due to not updating heartbeat state +- Fix overzealous exporting of symbols within an internal ``primary_guild`` module (:issue:`10295`) +- Close websocket when reconnecting websocket during polling (:issue:`10409`) +- Use :meth:`ui.View.walk_children` when removing items from the view cache (:issue:`10402`) +- |commands| Fix flag annotations not working under Python 3.14 +- |commands| Fix decorator order mattering for hybrid commands +- |commands| Fix :meth:`~ext.commands.Context.from_interaction` derived :attr:`Message.type` being incorrect + +Miscellaneous +~~~~~~~~~~~~~~ + +- Allow :class:`ui.View` initialization without a running event loop (:issue:`10367`) +- Optimise :func:`utils.find` and specialise :func:`utils.as_chunks` (:issue:`10351`) +- Detach :attr:`ui.Item.view` when the item is removed (:issue:`10348`) +- Change ``description`` to be optional when creating emoji (:issue:`10346`) +- Don't assume Python 3.14 always has ``compression.zstd`` (:issue:`10328`) +- Use webp as the default emoji URL format +- |tasks| Log handled exceptions before sleeping + + .. _vp2p6p4: v2.6.4 diff --git a/pyproject.toml b/pyproject.toml index d18e35277..37b5f5bc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,8 +37,8 @@ dependencies = { file = "requirements.txt" } [project.optional-dependencies] voice = [ - "PyNaCl>=1.5.0,<1.6", - "davey==0.1.0" + "PyNaCl>=1.6.0,<1.7", + "davey>=0.1.0" ] docs = [ "sphinx==4.4.0",