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.
### 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")

4
discord/__init__.py

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

8
discord/__main__.py

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

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

4
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

10
discord/errors.py

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

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

1
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

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

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

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

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

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

79
discord/player.py

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

5
discord/shard.py

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

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

87
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

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

3
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`

85
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

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

Loading…
Cancel
Save