Browse Source

Normalize upstream changes for the library

pull/10109/head
dolfies 5 months ago
parent
commit
ac641ea98b
  1. 1
      discord/audit_logs.py
  2. 47
      discord/client.py
  3. 3
      discord/flags.py
  4. 15
      discord/gateway.py
  5. 82
      discord/guild.py
  6. 22
      discord/http.py
  7. 80
      discord/message.py
  8. 11
      discord/permissions.py
  9. 2
      discord/player.py
  10. 2
      discord/raw_models.py
  11. 23
      discord/reaction.py
  12. 11
      discord/state.py
  13. 10
      discord/types/channel.py
  14. 7
      discord/types/guild.py
  15. 4
      discord/types/message.py
  16. 2
      discord/types/poll.py
  17. 2
      discord/types/voice.py
  18. 23
      discord/voice_client.py
  19. 98
      discord/voice_state.py
  20. 3
      discord/webhook/async_.py
  21. 45
      docs/api.rst

1
discord/audit_logs.py

@ -70,7 +70,6 @@ if TYPE_CHECKING:
from .types.invite import Invite as InvitePayload
from .types.role import Role as RolePayload
from .types.snowflake import Snowflake
from .types.command import ApplicationCommandPermissions
from .types.automod import AutoModerationAction
from .user import User
from .webhook import Webhook

47
discord/client.py

@ -274,6 +274,11 @@ class Client:
set to is ``30.0`` seconds.
.. versionadded:: 2.0
preferred_rtc_regions: List[:class:`str`]
A list of preferred RTC regions to connect to. This overrides Discord's suggested list.
.. versionadded:: 2.1
Attributes
-----------
@ -533,13 +538,24 @@ class Client:
def preferred_rtc_regions(self) -> List[str]:
"""List[:class:`str`]: Geo-ordered list of voice regions the connected client can use.
This value is determined by Discord by default, but can be set to override it.
.. versionadded:: 2.0
.. versionchanged:: 2.1
Rename from ``preferred_voice_regions`` to ``preferred_rtc_regions``.
"""
return self._connection.preferred_rtc_regions
return (
self._connection.overriden_rtc_regions
if self._connection.overriden_rtc_regions is not None
else self._connection.preferred_rtc_regions
)
@preferred_rtc_regions.setter
def preferred_rtc_regions(self, value: List[str]) -> None:
values = [str(x).lower() for x in value]
self._connection.overriden_rtc_regions = values
@property
def pending_payments(self) -> Sequence[Payment]:
@ -1808,7 +1824,6 @@ class Client:
self_mute: bool = False,
self_deaf: bool = False,
self_video: bool = False,
preferred_region: Optional[str] = MISSING,
) -> None:
"""|coro|
@ -1816,6 +1831,10 @@ class Client:
.. versionadded:: 2.0
.. versionchanged:: 2.1
Removed the ``preferred_region`` parameter.
Parameters
-----------
channel: Optional[:class:`~discord.abc.Snowflake`]
@ -1827,19 +1846,12 @@ class Client:
self_video: :class:`bool`
Indicates if the client is using video. Untested & unconfirmed
(do not use).
preferred_region: Optional[:class:`str`]
The preferred region to connect to.
"""
state = self._connection
ws = self.ws
channel_id = channel.id if channel else None
if preferred_region is None or channel_id is None:
region = None
else:
region = str(preferred_region) if preferred_region else state.preferred_rtc_region
await ws.voice_state(None, channel_id, self_mute, self_deaf, self_video, preferred_region=region)
await ws.voice_state(None, channel_id, self_mute, self_deaf, self_video)
# Guild stuff
@ -3092,13 +3104,18 @@ class Client:
data = await self.http.get_country_code()
return data['country_code']
async def fetch_preferred_voice_regions(self) -> List[str]:
async def fetch_preferred_rtc_regions(self) -> List[Tuple[str, List[str]]]:
"""|coro|
Retrieves the preferred voice regions of the client.
Retrieves the preferred RTC regions of the client.
.. versionadded:: 2.0
.. versionchanged:: 2.1
Changed the name of the method from ``fetch_preferred_voice_regions`` to ``fetch_preferred_rtc_regions``.
The method now returns a list of tuples instead of a list of strings.
Raises
-------
HTTPException
@ -3106,11 +3123,11 @@ class Client:
Returns
-------
List[:class:`str`]
The preferred voice regions of the client.
List[Tuple[:class:`str`, List[:class:`str`]]]
The region name and list of IPs for the closest voice regions.
"""
data = await self.http.get_preferred_voice_regions()
return [v['region'] for v in data]
return [(v['region'], v['ips']) for v in data]
async def create_dm(self, user: Snowflake, /) -> DMChannel:
"""|coro|

3
discord/flags.py

@ -2750,8 +2750,6 @@ class AttachmentFlags(BaseFlags):
class RoleFlags(BaseFlags):
r"""Wraps up the Discord Role flags
.. versionadded:: 2.1
.. container:: operations
.. describe:: x == y
@ -2795,6 +2793,7 @@ class RoleFlags(BaseFlags):
Returns whether any flag is set to ``True``.
.. versionadded:: 2.1
Attributes
-----------

15
discord/gateway.py

@ -775,8 +775,6 @@ class DiscordWebSocket:
self_mute: bool = False,
self_deaf: bool = False,
self_video: bool = False,
*,
preferred_region: Optional[str] = None,
) -> None:
payload = {
'op': self.VOICE_STATE,
@ -789,8 +787,8 @@ class DiscordWebSocket:
},
}
if preferred_region is not None:
payload['d']['preferred_region'] = preferred_region
if channel_id:
payload['d'].update(self._connection._get_preferred_regions())
_log.debug('Updating %s voice state to %s.', guild_id or 'client', payload)
await self.send_as_json(payload)
@ -1025,7 +1023,6 @@ class DiscordVoiceWebSocket:
await self.loop.sock_connect(state.socket, (state.endpoint_ip, state.voice_port))
state.ip, state.port = await self.discover_ip()
# there *should* always be at least one supported mode (xsalsa20_poly1305)
modes = [mode for mode in data['modes'] if mode in self._connection.supported_modes]
_log.debug('Received supported encryption modes: %s.', ', '.join(modes))
@ -1055,7 +1052,7 @@ class DiscordVoiceWebSocket:
_log.debug('Received IP discovery packet: %s.', recv)
# the ip is ascii starting at the 8th byte and ending at the first null
# The IP is ascii starting at the 8th byte and ending at the first null
ip_start = 8
ip_end = recv.index(0, ip_start)
ip = recv[ip_start:ip_end].decode('ascii')
@ -1084,9 +1081,9 @@ class DiscordVoiceWebSocket:
_log.debug('Received secret key for voice connection.')
self.secret_key = self._connection.secret_key = data['secret_key']
# Send a speak command with the "not speaking" state.
# Send a speak command with the "not speaking" state
# This also tells Discord our SSRC value, which Discord requires before
# sending any voice data (and is the real reason why we call this here).
# sending any voice data (and is the real reason why we call this here)
await self.speak(SpeakingState.none)
async def poll_event(self) -> None:
@ -1098,7 +1095,7 @@ class DiscordVoiceWebSocket:
_log.debug('Voice received %s.', msg)
raise ConnectionClosed(self.ws) from msg.data
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING):
_log.debug('Voice received %s', msg)
_log.debug('Voice received %s.', msg)
raise ConnectionClosed(self.ws, code=self._close_code)
async def close(self, code: int = 1000) -> None:

82
discord/guild.py

@ -377,9 +377,13 @@ class Guild(Hashable):
max_members: Optional[:class:`int`]
The maximum amount of members for the guild.
max_video_channel_users: Optional[:class:`int`]
The maximum amount of users in a video channel.
The maximum amount of users in a video stream.
.. versionadded:: 1.4
max_stage_video_channel_users: Optional[:class:`int`]
The maximum amount of users in a stage video stream.
.. versionadded:: 2.1
description: Optional[:class:`str`]
The guild's description.
verification_level: :class:`VerificationLevel`
@ -453,6 +457,7 @@ class Guild(Hashable):
'max_presences',
'max_members',
'max_video_channel_users',
'max_stage_video_channel_users',
'_premium_tier',
'premium_subscription_count',
'preferred_locale',
@ -478,6 +483,7 @@ class Guild(Hashable):
'_discovery_splash',
'_rules_channel_id',
'_public_updates_channel_id',
'_safety_alerts_channel_id',
'_stage_instances',
'_scheduled_events',
'_threads',
@ -671,6 +677,7 @@ class Guild(Hashable):
self.max_presences: Optional[int] = guild.get('max_presences')
self.max_members: Optional[int] = guild.get('max_members')
self.max_video_channel_users: Optional[int] = guild.get('max_video_channel_users')
self.max_stage_video_channel_users: Optional[int] = guild.get('max_stage_video_channel_users')
self._premium_tier = guild.get('premium_tier')
self.premium_subscription_count: int = guild.get('premium_subscription_count') or 0
self.vanity_url_code: Optional[str] = guild.get('vanity_url_code')
@ -681,6 +688,7 @@ class Guild(Hashable):
self._discovery_splash: Optional[str] = guild.get('discovery_splash')
self._rules_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'rules_channel_id')
self._public_updates_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'public_updates_channel_id')
self._safety_alerts_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'safety_alerts_channel_id')
self._afk_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'afk_channel_id')
self.nsfw_level: NSFWLevel = try_enum(NSFWLevel, guild.get('nsfw_level', 0))
self.mfa_level: MFALevel = try_enum(MFALevel, guild.get('mfa_level', 0))
@ -747,6 +755,16 @@ class Guild(Hashable):
def _extra_large(self) -> bool:
return self._member_count is not None and self._member_count >= 75000
@property
def max_stage_video_users(self) -> Optional[int]:
"""Optional[:class:`int`]: The maximum amount of users in a stage video stream.
An alias for :attr:`max_stage_video_channel_users`.
.. versionadded:: 2.1
"""
return self.max_stage_video_channel_users
def is_hub(self) -> bool:
""":class:`bool`: Whether the guild is a Student Hub.
@ -1047,6 +1065,17 @@ class Guild(Hashable):
channel_id = self._public_updates_channel_id
return channel_id and self._channels.get(channel_id) # type: ignore
@property
def safety_alerts_channel(self) -> Optional[TextChannel]:
"""Optional[:class:`TextChannel`]: Return's the guild's channel used for safety alerts, if set.
For example, this is used for the raid protection setting. The guild must have the ``COMMUNITY`` feature.
.. versionadded:: 2.1
"""
channel_id = self._safety_alerts_channel_id
return channel_id and self._channels.get(channel_id) # type: ignore
@property
def afk_channel(self) -> Optional[VocalGuildChannel]:
"""Optional[:class:`VoiceChannel`]: Returns the guild channel AFK users are moved to.
@ -2184,12 +2213,14 @@ class Guild(Hashable):
preferred_locale: Locale = MISSING,
rules_channel: Optional[TextChannel] = MISSING,
public_updates_channel: Optional[TextChannel] = MISSING,
safety_alerts_channel: Optional[TextChannel] = MISSING,
premium_progress_bar_enabled: bool = MISSING,
discoverable: bool = MISSING,
invites_disabled: bool = MISSING,
widget_enabled: bool = MISSING,
widget_channel: Optional[Snowflake] = MISSING,
mfa_level: MFALevel = MISSING,
raid_alerts_disabled: bool = MISSING,
invites_disabled_until: datetime = MISSING,
dms_disabled_until: datetime = MISSING,
) -> Guild:
@ -2278,6 +2309,12 @@ class Guild(Hashable):
public updates channel.
.. versionadded:: 1.4
safety_alerts_channel: Optional[:class:`TextChannel`]
The new channel that is used for safety alerts. This is only available to
guilds that contain ``COMMUNITY`` in :attr:`Guild.features`. Could be ``None`` for no
safety alerts channel.
.. versionadded:: 2.1
premium_progress_bar_enabled: :class:`bool`
Whether the premium AKA server boost level progress bar should be enabled for the guild.
@ -2303,20 +2340,22 @@ class Guild(Hashable):
Note that you must be owner of the guild to do this.
.. versionadded:: 2.0
reason: Optional[:class:`str`]
The reason for editing this guild. Shows up on the audit log.
raid_alerts_disabled: :class:`bool`
Whether the alerts for raid protection should be disabled for the guild.
.. versionadded:: 2.1
invites_disabled_until: Optional[:class:`datetime.datetime`]
The time when invites should be enabled again, or ``None`` to disable the action.
This must be a timezone-aware datetime object. Consider using :func:`utils.utcnow`.
.. versionadded:: 2.1
dms_disabled_until: Optional[:class:`datetime.datetime`]
The time when direct messages should be allowed again, or ``None`` to disable the action.
This must be a timezone-aware datetime object. Consider using :func:`utils.utcnow`.
.. versionadded:: 2.1
reason: Optional[:class:`str`]
The reason for editing this guild. Shows up on the audit log.
Raises
-------
@ -2410,6 +2449,12 @@ class Guild(Hashable):
else:
fields['public_updates_channel_id'] = public_updates_channel.id
if safety_alerts_channel is not MISSING:
if safety_alerts_channel is None:
fields['safety_alerts_channel_id'] = safety_alerts_channel
else:
fields['safety_alerts_channel_id'] = safety_alerts_channel.id
if owner is not MISSING:
if self.owner_id != self._state.self_id:
raise ValueError('To transfer ownership you must be the owner of the guild')
@ -2434,7 +2479,7 @@ class Guild(Hashable):
fields['system_channel_flags'] = system_channel_flags.value
if any(feat is not MISSING for feat in (community, discoverable, invites_disabled)):
if any(feat is not MISSING for feat in (community, discoverable, invites_disabled, raid_alerts_disabled)):
features = set(self.features)
if community is not MISSING:
@ -2460,6 +2505,12 @@ class Guild(Hashable):
else:
features.discard('INVITES_DISABLED')
if raid_alerts_disabled is not MISSING:
if raid_alerts_disabled:
features.add('RAID_ALERTS_DISABLED')
else:
features.discard('RAID_ALERTS_DISABLED')
fields['features'] = list(features)
if premium_progress_bar_enabled is not MISSING:
@ -5212,6 +5263,10 @@ class Guild(Hashable):
.. versionadded:: 1.4
.. versionchanged:: 2.1
Removed the ``preferred_region`` parameter.
Parameters
-----------
channel: Optional[:class:`abc.Snowflake`]
@ -5223,23 +5278,12 @@ class Guild(Hashable):
self_video: :class:`bool`
Indicates if the client is using video. Untested & unconfirmed
(do not use).
preferred_region: Optional[:class:`str`]
The preferred region to connect to.
.. versionchanged:: 2.0
The type of this parameter has changed to :class:`str`.
"""
state = self._state
ws = state.ws
channel_id = channel.id if channel else None
if preferred_region is None or channel_id is None:
region = None
else:
region = str(preferred_region) if preferred_region else state.preferred_rtc_region
await ws.voice_state(self.id, channel_id, self_mute, self_deaf, self_video, preferred_region=region)
await ws.voice_state(self.id, channel_id, self_mute, self_deaf, self_video)
async def subscribe(
self, *, typing: bool = MISSING, activities: bool = MISSING, threads: bool = MISSING, member_updates: bool = MISSING
@ -5575,7 +5619,7 @@ class Guild(Hashable):
return utils.parse_time(self._incidents_data.get('dms_disabled_until'))
@property
def dm_spam_detected_at(self) -> Optional[datetime.datetime]:
def dm_spam_detected_at(self) -> Optional[datetime]:
""":class:`datetime.datetime`: Returns the time when DM spam was detected in the guild.
.. versionadded:: 2.1
@ -5586,7 +5630,7 @@ class Guild(Hashable):
return utils.parse_time(self._incidents_data.get('dm_spam_detected_at'))
@property
def raid_detected_at(self) -> Optional[datetime.datetime]:
def raid_detected_at(self) -> Optional[datetime]:
"""Optional[:class:`datetime.datetime`]: Returns the time when a raid was detected in the guild.
.. versionadded:: 2.1

22
discord/http.py

@ -1053,7 +1053,7 @@ class HTTPClient:
raise HTTPException(response, data)
async def get_preferred_voice_regions(self) -> List[dict]:
async def get_preferred_voice_regions(self) -> List[guild.RTCRegion]:
async with self.__session.get('https://latency.discord.media/rtc') as resp:
if resp.status == 200:
return await resp.json()
@ -1283,7 +1283,10 @@ class HTTPClient:
else:
return self.request(r, json=params.payload)
def add_reaction(self, channel_id: Snowflake, message_id: Snowflake, emoji: str) -> Response[None]:
def add_reaction(
self, channel_id: Snowflake, message_id: Snowflake, emoji: str, type: message.ReactionType = 0
) -> Response[None]:
params = {'type': type}
return self.request(
Route(
'PUT',
@ -1291,31 +1294,36 @@ class HTTPClient:
channel_id=channel_id,
message_id=message_id,
emoji=emoji,
)
),
params=params,
)
def remove_reaction(
self, channel_id: Snowflake, message_id: Snowflake, emoji: str, member_id: Snowflake
self, channel_id: Snowflake, message_id: Snowflake, emoji: str, member_id: Snowflake, type: message.ReactionType = 0
) -> Response[None]:
return self.request(
Route(
'DELETE',
'/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/{member_id}',
'/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/{reaction_type}/{member_id}',
channel_id=channel_id,
message_id=message_id,
member_id=member_id,
emoji=emoji,
reaction_type=type,
)
)
def remove_own_reaction(self, channel_id: Snowflake, message_id: Snowflake, emoji: str) -> Response[None]:
def remove_own_reaction(
self, channel_id: Snowflake, message_id: Snowflake, emoji: str, type: message.ReactionType = 0
) -> Response[None]:
return self.request(
Route(
'DELETE',
'/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me',
'/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/{reaction_type}/@me',
channel_id=channel_id,
message_id=message_id,
emoji=emoji,
reaction_type=type,
)
)

80
discord/message.py

@ -47,12 +47,22 @@ from typing import (
overload,
)
from discord.types.components import MessageActionRow
from . import utils
from .reaction import Reaction
from .emoji import Emoji
from .partial_emoji import PartialEmoji
from .calls import CallMessage
from .enums import MessageType, ChannelType, ApplicationCommandType, PurchaseNotificationType, MessageReferenceType, try_enum
from .enums import (
MessageType,
ChannelType,
ApplicationCommandType,
PurchaseNotificationType,
MessageReferenceType,
ReactionType,
try_enum,
)
from .errors import HTTPException
from .components import _component_factory
from .embeds import Embed
@ -472,13 +482,29 @@ class DeletedReferencedMessage:
return self._parent.guild_id
class MessageSnapshot:
class MessageSnapshot(Hashable):
"""Represents a message snapshot attached to a forwarded message.
.. container:: operations
.. describe:: x == y
Checks if the message snapshot is equal to another message snapshot.
.. describe:: x != y
Checks if the message snapshot is not equal to another message snapshot.
.. describe:: hash(x)
Returns the hash of the message snapshot.
.. versionadded:: 2.1
Attributes
-----------
id: :class:`int`
The ID of the forwarded message.
type: :class:`MessageType`
The type of the forwarded message.
content: :class:`str`
@ -519,27 +545,29 @@ class MessageSnapshot:
cls,
state: ConnectionState,
message_snapshots: Optional[List[Dict[Literal['message'], MessageSnapshotPayload]]],
reference: MessageReference,
) -> List[Self]:
if not message_snapshots:
return []
return [cls(state, snapshot['message']) for snapshot in message_snapshots]
return [cls(state, snapshot['message'], reference) for snapshot in message_snapshots]
def __init__(self, state: ConnectionState, data: MessageSnapshotPayload):
def __init__(self, state: ConnectionState, data: MessageSnapshotPayload, reference: MessageReference):
self.type: MessageType = try_enum(MessageType, data['type'])
self.id: int = reference.message_id # type: ignore
self.content: str = data['content']
self.embeds: List[Embed] = [Embed.from_dict(a) for a in data['embeds']]
self.attachments: List[Attachment] = [Attachment(data=a, state=state) for a in data['attachments']]
self.created_at: datetime.datetime = utils.parse_time(data['timestamp'])
self._edited_timestamp: Optional[datetime.datetime] = utils.parse_time(data['edited_timestamp'])
self.flags: MessageFlags = MessageFlags._from_value(data.get('flags', 0))
self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('stickers_items', [])]
self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])]
self.components: List[MessageComponentType] = []
self.components: List[MessageActionRow] = []
for component_data in data.get('components', []):
component = _component_factory(component_data)
if component is not None:
self.components.append(component)
self.components.append(component) # type: ignore
self._state: ConnectionState = state
@ -577,16 +605,7 @@ class MessageSnapshot:
state = self._state
return (
utils.find(
lambda m: (
m.created_at == self.created_at
and m.edited_at == self.edited_at
and m.content == self.content
and m.embeds == self.embeds
and m.components == self.components
and m.stickers == self.stickers
and m.attachments == self.attachments
and m.flags == self.flags
),
lambda m: m.id == self.id,
reversed(state._messages),
)
if state._messages
@ -1200,7 +1219,7 @@ class PartialMessage(Hashable):
# pinned exists on PartialMessage for duck typing purposes
self.pinned = False
async def add_reaction(self, emoji: Union[EmojiInputType, Reaction], /) -> None:
async def add_reaction(self, emoji: Union[EmojiInputType, Reaction], /, *, boost: bool = False) -> None:
"""|coro|
Adds a reaction to the message.
@ -1223,6 +1242,10 @@ class PartialMessage(Hashable):
------------
emoji: Union[:class:`Emoji`, :class:`Reaction`, :class:`PartialEmoji`, :class:`str`]
The emoji to react with.
boost: :class:`bool`
Whether to react with a super reaction.
.. versionadded:: 2.1
Raises
--------
@ -1236,9 +1259,9 @@ class PartialMessage(Hashable):
The emoji parameter is invalid.
"""
emoji = convert_emoji_reaction(emoji)
await self._state.http.add_reaction(self.channel.id, self.id, emoji)
await self._state.http.add_reaction(self.channel.id, self.id, emoji, type=1 if boost else 0)
async def remove_reaction(self, emoji: Union[EmojiInputType, Reaction], member: Snowflake) -> None:
async def remove_reaction(self, emoji: Union[EmojiInputType, Reaction], member: Snowflake, boost: bool = False) -> None:
"""|coro|
Remove a reaction by the member from the message.
@ -1261,6 +1284,14 @@ class PartialMessage(Hashable):
The emoji to remove.
member: :class:`abc.Snowflake`
The member for which to remove the reaction.
boost: :class:`bool`
Whether to remove a super reaction.
.. note::
Keep in mind that members can both react and super react with the same emoji.
.. versionadded:: 2.1
Raises
--------
@ -1276,9 +1307,9 @@ class PartialMessage(Hashable):
emoji = convert_emoji_reaction(emoji)
if member.id == self._state.self_id:
await self._state.http.remove_own_reaction(self.channel.id, self.id, emoji)
await self._state.http.remove_own_reaction(self.channel.id, self.id, emoji, type=1 if boost else 0)
else:
await self._state.http.remove_reaction(self.channel.id, self.id, emoji, member.id)
await self._state.http.remove_reaction(self.channel.id, self.id, emoji, member.id, type=1 if boost else 0)
async def clear_reaction(self, emoji: Union[EmojiInputType, Reaction]) -> None:
"""|coro|
@ -1657,7 +1688,7 @@ class PartialMessage(Hashable):
.. versionadded:: 1.7
type: :class:`MessageReferenceType`
The type of message reference.
The type of message reference. Default :attr:`MessageReferenceType.reply`.
.. versionadded:: 2.1
@ -1967,7 +1998,6 @@ class Message(PartialMessage, Hashable):
self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])]
self.call: Optional[CallMessage] = None
self.interaction: Optional[Interaction] = None
self.message_snapshots: List[MessageSnapshot] = MessageSnapshot._from_value(state, data.get('message_snapshots'))
self.poll: Optional[Poll] = None
try:
@ -2036,6 +2066,8 @@ class Message(PartialMessage, Hashable):
# The channel will be the correct type here
ref.resolved = self.__class__(channel=chan, data=resolved, state=state) # type: ignore
self.message_snapshots: List[MessageSnapshot] = MessageSnapshot._from_value(state, data.get('message_snapshots'), self.reference) # type: ignore
self.role_subscription: Optional[RoleSubscriptionInfo] = None
try:
role_subscription = data['role_subscription_data']

11
discord/permissions.py

@ -257,10 +257,7 @@ class Permissions(BaseFlags):
no longer part of the general permissions.
.. versionchanged:: 2.1
Added :attr:`create_expressions` permission.
.. versionchanged:: 2.1
Added :attr:`view_creator_monetization_analytics` permission.
Added :attr:`create_expressions` and :attr:`view_creator_monetization_analytics` permission.
"""
return cls(0b0000_0000_0000_0000_0000_1010_0000_0000_0111_0000_0000_1000_0000_0100_1011_0000)
@ -290,10 +287,7 @@ class Permissions(BaseFlags):
:attr:`send_messages_in_threads` and :attr:`use_external_stickers` permissions.
.. versionchanged:: 2.1
Added :attr:`send_voice_messages` permission.
.. versionchanged:: 2.1
Added :attr:`send_polls` and :attr:`use_external_apps` permissions.
Added :attr:`send_voice_messages`, :attr:`send_polls`, and :attr:`use_external_apps` permissions.
"""
return cls(0b0000_0000_0000_0110_0100_0000_0111_1100_1000_0000_0000_0111_1111_1000_0100_0000)
@ -893,6 +887,7 @@ class PermissionOverwrite:
send_polls: Optional[bool]
create_polls: Optional[bool]
use_external_apps: Optional[bool]
view_creator_monetization_analytics: Optional[bool]
def __init__(self, **kwargs: Optional[bool]):
self._values: Dict[str, Optional[bool]] = {}

2
discord/player.py

@ -199,7 +199,7 @@ class FFmpegAudio(AudioSource):
self._pipe_reader_thread.start()
def _spawn_process(self, args: Any, **subprocess_kwargs: Any) -> subprocess.Popen:
_log.debug('Spawning ffmpeg process with command: %s', args)
_log.debug('Spawning ffmpeg process with command: %s.', args)
process = None
try:
process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW, **subprocess_kwargs)

2
discord/raw_models.py

@ -31,6 +31,8 @@ from .enums import ChannelType, ReactionType, ReadStateType, try_enum
from .utils import _get_as_snowflake
if TYPE_CHECKING:
from typing_extensions import Self
from .guild import Guild
from .member import Member
from .message import Message

23
discord/reaction.py

@ -77,11 +77,11 @@ class Reaction:
count: :class:`int`
Number of times this reaction was made. This is a sum of :attr:`normal_count` and :attr:`burst_count`.
me: :class:`bool`
If the user sent this reaction.
If the user has reacted with this emoji.
message: :class:`Message`
Message this reaction is for.
me_burst: :class:`bool`
If the user sent this super reaction.
If the user has super-reacted with this emoji.
.. versionadded:: 2.1
normal_count: :class:`int`
@ -105,11 +105,12 @@ class Reaction:
self.emoji: Union[PartialEmoji, Emoji, str] = emoji or message._state.get_reaction_emoji(data['emoji'])
self.count: int = data.get('count', 1)
self.me: bool = data['me']
details = data.get('count_details', {})
self.normal_count: int = details.get('normal', 0)
self.burst_count: int = details.get('burst', 0)
self.me_burst: bool = data.get('me_burst', False)
details = data.get('count_details', {})
self.normal_count: int = details.get('normal', int(self.me))
self.burst_count: int = details.get('burst', int(self.me_burst))
# TODO: typeguard
def is_custom_emoji(self) -> bool:
""":class:`bool`: If this is a custom emoji."""
@ -132,7 +133,7 @@ class Reaction:
def __repr__(self) -> str:
return f'<Reaction emoji={self.emoji!r} me={self.me} count={self.count}>'
async def remove(self, user: Snowflake) -> None:
async def remove(self, user: Snowflake, *, boost: bool = False) -> None:
"""|coro|
Remove the reaction by the provided :class:`User` from the message.
@ -147,6 +148,14 @@ class Reaction:
-----------
user: :class:`abc.Snowflake`
The user or member from which to remove the reaction.
boost: :class:`bool`
Whether to remove a super reaction.
.. note::
Keep in mind that members can both react and super react with the same emoji.
.. versionadded:: 2.1
Raises
-------
@ -158,7 +167,7 @@ class Reaction:
The user you specified, or the reaction's message was not found.
"""
await self.message.remove_reaction(self.emoji, user)
await self.message.remove_reaction(self.emoji, user, boost=boost)
async def clear(self) -> None:
"""|coro|

11
discord/state.py

@ -1016,6 +1016,7 @@ class ConnectionState:
self._status: Optional[str] = status
self._afk: bool = options.get('afk', False)
self._idle_since: int = since
self.overriden_rtc_regions: Optional[List[str]] = options.get('preferred_rtc_regions', None)
if cache_flags._empty:
self.store_user = self.create_user
@ -1131,10 +1132,6 @@ class ConnectionState:
def locale(self) -> str:
return str(getattr(self.user, 'locale', 'en-US'))
@property
def preferred_rtc_region(self) -> str:
return self.preferred_rtc_regions[0] if self.preferred_rtc_regions else 'us-central'
@property
def voice_clients(self) -> List[VoiceProtocol]:
return list(self._voice_clients.values())
@ -1176,6 +1173,12 @@ class ConnectionState:
def _remove_voice_client(self, guild_id: int) -> None:
self._voice_clients.pop(guild_id, None)
def _get_preferred_regions(self) -> Dict[str, Union[List[str], str]]:
regions = self.overriden_rtc_regions if self.overriden_rtc_regions is not None else self.client.preferred_rtc_regions
if regions:
return {'preferred_regions': regions, 'preferred_region': regions[0]}
return {}
def _update_references(self, ws: DiscordWebSocket) -> None:
for vc in self.voice_clients:
vc.main_ws = ws # type: ignore # Silencing the unknown attribute (ok at runtime).

10
discord/types/channel.py

@ -170,7 +170,15 @@ class MediaChannel(_BaseForumChannel):
GuildChannel = Union[
TextChannel, NewsChannel, VoiceChannel, CategoryChannel, StageChannel, DirectoryChannel, ThreadChannel, ForumChannel, MediaChannel
TextChannel,
NewsChannel,
VoiceChannel,
CategoryChannel,
StageChannel,
DirectoryChannel,
ThreadChannel,
ForumChannel,
MediaChannel,
]

7
discord/types/guild.py

@ -130,9 +130,11 @@ class Guild(UnavailableGuild, _GuildMedia):
max_members: NotRequired[int]
premium_subscription_count: NotRequired[int]
max_video_channel_users: NotRequired[int]
max_stage_video_channel_users: NotRequired[int]
# application_command_counts: ApplicationCommandCounts
hub_type: Optional[Literal[0, 1, 2]]
incidents_data: Optional[IncidentData]
safety_alerts_channel_id: Optional[Snowflake]
class UserGuild(BaseGuild):
@ -201,3 +203,8 @@ class SupplementalGuild(UnavailableGuild):
class BulkBanUserResponse(TypedDict):
banned_users: Optional[List[Snowflake]]
failed_users: Optional[List[Snowflake]]
class RTCRegion(TypedDict):
region: str
ips: List[str]

4
discord/types/message.py

@ -180,8 +180,8 @@ class MessageSnapshot(TypedDict):
flags: NotRequired[int]
mentions: List[UserWithMember]
mention_roles: SnowflakeList
stickers_items: NotRequired[List[StickerItem]]
components: NotRequired[List[Component]]
sticker_items: NotRequired[List[StickerItem]]
components: NotRequired[List[MessageActionRow]]
class Message(PartialMessage):

2
discord/types/poll.py

@ -35,7 +35,7 @@ if TYPE_CHECKING:
from .emoji import PartialEmoji
LayoutType = Literal[1] # 1 = Default
LayoutType = Literal[1, 2] # 1 = Default
class PollMedia(TypedDict):

2
discord/types/voice.py

@ -76,7 +76,7 @@ class VoiceRegion(TypedDict):
class VoiceServerUpdate(TypedDict):
token: str
guild_id: Snowflake
guild_id: NotRequired[Snowflake]
channel_id: Snowflake
endpoint: Optional[str]

23
discord/voice_client.py

@ -47,6 +47,7 @@ if TYPE_CHECKING:
from .types.gateway import VoiceStateUpdateEvent as VoiceStateUpdatePayload
from .types.voice import (
GuildVoiceState as GuildVoiceStatePayload,
VoiceServerUpdate as VoiceServerUpdatePayload,
TransportEncryptionModes,
)
@ -128,7 +129,9 @@ class VoiceProtocol:
"""
raise NotImplementedError
async def connect(self, *, timeout: float, reconnect: bool, self_deaf: bool = False, self_mute: bool = False) -> None:
async def connect(
self, *, timeout: float, reconnect: bool, self_deaf: bool = False, self_mute: bool = False, self_video: bool = False
) -> None:
"""|coro|
An abstract method called when the client initiates the connection request.
@ -302,9 +305,16 @@ class VoiceClient(VoiceProtocol):
async def on_voice_server_update(self, data: VoiceServerUpdatePayload) -> None:
await self._connection.voice_server_update(data)
async def connect(self, *, reconnect: bool, timeout: float, self_deaf: bool = False, self_mute: bool = False) -> None:
async def connect(
self, *, reconnect: bool, timeout: float, self_deaf: bool = False, self_mute: bool = False, self_video: bool = False
) -> None:
await self._connection.connect(
reconnect=reconnect, timeout=timeout, self_deaf=self_deaf, self_mute=self_mute, resume=False
reconnect=reconnect,
timeout=timeout,
self_deaf=self_deaf,
self_mute=self_mute,
self_video=self_video,
resume=False,
)
def wait_until_connected(self, timeout: Optional[float] = 30.0) -> bool:
@ -541,10 +551,10 @@ class VoiceClient(VoiceProtocol):
@source.setter
def source(self, value: AudioSource) -> None:
if not isinstance(value, AudioSource):
raise TypeError(f'expected AudioSource not {value.__class__.__name__}.')
raise TypeError(f'expected AudioSource not {value.__class__.__name__}')
if self._player is None:
raise ValueError('Not playing anything.')
raise ValueError('Not playing anything')
self._player.set_source(value)
@ -567,7 +577,6 @@ class VoiceClient(VoiceProtocol):
opus.OpusError
Encoding the data failed.
"""
self.checked_add('sequence', 1, 65535)
if encode:
encoded_data = self.encoder.encode(data, self.encoder.SAMPLES_PER_FRAME)
@ -577,6 +586,6 @@ class VoiceClient(VoiceProtocol):
try:
self._connection.send_packet(packet)
except OSError:
_log.debug('A packet has been dropped (seq: %s, timestamp: %s)', self.sequence, self.timestamp)
_log.debug('A packet has been dropped (seq: %s, timestamp: %s).', self.sequence, self.timestamp)
self.checked_add('timestamp', opus.Encoder.SAMPLES_PER_FRAME, 4294967295)

98
discord/voice_state.py

@ -148,7 +148,7 @@ class SocketReader(threading.Thread):
readable, _, _ = select.select([self.state.socket], [], [], 30)
except (ValueError, TypeError, OSError) as e:
_log.debug(
"Select error handling socket in reader, this should be safe to ignore: %s: %s", e.__class__.__name__, e
'Select error handling socket in reader, this should be safe to ignore: %s: %s.', e.__class__.__name__, e
)
# The socket is either closed or doesn't exist at the moment
continue
@ -159,13 +159,13 @@ class SocketReader(threading.Thread):
try:
data = self.state.socket.recv(2048)
except OSError:
_log.debug('Error reading from socket in %s, this should be safe to ignore', self, exc_info=True)
_log.debug('Error reading from socket in %s, this should be safe to ignore.', self, exc_info=True)
else:
for cb in self._callbacks:
try:
cb(data)
except Exception:
_log.exception('Error calling %s in %s', cb, self)
_log.exception('Error calling %s in %s.', cb, self)
class ConnectionFlowState(Enum):
@ -195,6 +195,7 @@ class VoiceConnectionState:
self.reconnect: bool = True
self.self_deaf: bool = False
self.self_mute: bool = False
self.self_video: bool = False
self.token: Optional[str] = None
self.session_id: Optional[str] = None
self.endpoint: Optional[str] = None
@ -226,7 +227,7 @@ class VoiceConnectionState:
@state.setter
def state(self, state: ConnectionFlowState) -> None:
if state is not self._state:
_log.debug('Connection state changed to %s', state.name)
_log.debug('Voice connection state changed to %s.', state.name)
self._state = state
self._state_event.set()
self._state_event.clear()
@ -298,11 +299,12 @@ class VoiceConnectionState:
timeout=self.timeout,
self_deaf=(self.self_voice_state or self).self_deaf,
self_mute=(self.self_voice_state or self).self_mute,
self_video=(self.self_voice_state or self).self_video,
resume=False,
wait=False,
)
else:
_log.debug('Ignoring unexpected voice_state_update event')
_log.debug('Ignoring unexpected VOICE_STATE_UPDATE event.')
async def voice_server_update(self, data: VoiceServerUpdatePayload) -> None:
previous_token = self.token
@ -310,13 +312,13 @@ class VoiceConnectionState:
previous_endpoint = self.endpoint
self.token = data['token']
self.server_id = int(data['guild_id'])
self.server_id = int(data.get('guild_id', data['channel_id']))
endpoint = data.get('endpoint')
if self.token is None or endpoint is None:
_log.warning(
'Awaiting endpoint... This requires waiting. '
'If timeout occurred considering raising the timeout and reconnecting.'
'If timeout occurrs, considering raising the timeout and reconnecting.'
)
return
@ -337,15 +339,15 @@ class VoiceConnectionState:
self.state = ConnectionFlowState.got_both_voice_updates
elif self.state is ConnectionFlowState.connected:
_log.debug('Voice server update, closing old voice websocket')
_log.debug('Got VOICE_SERVER_UPDATE, closing old voice gateway.')
await self.ws.close(4014)
self.state = ConnectionFlowState.got_voice_server_update
elif self.state is not ConnectionFlowState.disconnected:
# eventual consistency
if previous_token == self.token and previous_server_id == self.server_id:
if previous_token == self.token and previous_server_id == self.server_id and previous_endpoint == self.endpoint:
return
_log.debug('Unexpected server update event, attempting to handle')
_log.debug('Unexpected VOICE_SERVER_UPDATE event, attempting to handle...')
await self.soft_disconnect(with_state=ConnectionFlowState.got_voice_server_update)
await self.connect(
@ -353,13 +355,22 @@ class VoiceConnectionState:
timeout=self.timeout,
self_deaf=(self.self_voice_state or self).self_deaf,
self_mute=(self.self_voice_state or self).self_mute,
self_video=(self.self_voice_state or self).self_video,
resume=False,
wait=False,
)
self._create_socket()
async def connect(
self, *, reconnect: bool, timeout: float, self_deaf: bool, self_mute: bool, resume: bool, wait: bool = True
self,
*,
reconnect: bool,
timeout: float,
self_deaf: bool,
self_mute: bool,
self_video: bool,
resume: bool,
wait: bool = True,
) -> None:
if self._connector:
self._connector.cancel()
@ -372,7 +383,7 @@ class VoiceConnectionState:
self.timeout = timeout
self.reconnect = reconnect
self._connector = self.voice_client.loop.create_task(
self._wrap_connect(reconnect, timeout, self_deaf, self_mute, resume), name='Voice connector'
self._wrap_connect(reconnect, timeout, self_deaf, self_mute, self_video, resume), name='Voice connector'
)
if wait:
await self._connector
@ -381,30 +392,32 @@ class VoiceConnectionState:
try:
await self._connect(*args)
except asyncio.CancelledError:
_log.debug('Cancelling voice connection')
_log.debug('Cancelling voice connection.')
await self.soft_disconnect()
raise
except asyncio.TimeoutError:
_log.info('Timed out connecting to voice')
_log.info('Timed out connecting to voice.')
await self.disconnect()
raise
except Exception:
_log.exception('Error connecting to voice... disconnecting')
_log.exception('Error connecting to voice. Disconnecting.')
await self.disconnect()
raise
async def _inner_connect(self, reconnect: bool, self_deaf: bool, self_mute: bool, resume: bool) -> None:
async def _inner_connect(
self, reconnect: bool, self_deaf: bool, self_mute: bool, self_video: bool, resume: bool
) -> None:
for i in range(5):
_log.info('Starting voice handshake... (connection attempt %d)', i + 1)
_log.info('Starting voice handshake (connection attempt %d)...', i + 1)
await self._voice_connect(self_deaf=self_deaf, self_mute=self_mute)
await self._voice_connect(self_deaf=self_deaf, self_mute=self_mute, self_video=self_video)
# Setting this unnecessarily will break reconnecting
if self.state is ConnectionFlowState.disconnected:
self.state = ConnectionFlowState.set_guild_voice_state
await self._wait_for_state(ConnectionFlowState.got_both_voice_updates)
_log.info('Voice handshake complete. Endpoint found: %s', self.endpoint)
_log.info('Voice handshake complete. Endpoint found: %s.', self.endpoint)
try:
self.ws = await self._connect_websocket(resume)
@ -421,11 +434,15 @@ class VoiceConnectionState:
await self.disconnect()
raise
async def _connect(self, reconnect: bool, timeout: float, self_deaf: bool, self_mute: bool, resume: bool) -> None:
async def _connect(
self, reconnect: bool, timeout: float, self_deaf: bool, self_mute: bool, self_video: bool, resume: bool
) -> None:
_log.info('Connecting to voice...')
await asyncio.wait_for(
self._inner_connect(reconnect=reconnect, self_deaf=self_deaf, self_mute=self_mute, resume=resume),
self._inner_connect(
reconnect=reconnect, self_deaf=self_deaf, self_mute=self_mute, self_video=self_video, resume=resume
),
timeout=timeout,
)
_log.info('Voice connection complete.')
@ -442,7 +459,7 @@ class VoiceConnectionState:
if self.ws:
await self.ws.close()
except Exception:
_log.debug('Ignoring exception disconnecting from voice', exc_info=True)
_log.debug('Ignoring exception disconnecting from voice.', exc_info=True)
finally:
self.state = ConnectionFlowState.disconnected
self._socket_reader.pause()
@ -471,7 +488,7 @@ class VoiceConnectionState:
try:
await asyncio.wait_for(self._disconnected.wait(), timeout=self.timeout)
except TimeoutError:
_log.debug('Timed out waiting for voice disconnection confirmation')
_log.debug('Timed out waiting for voice disconnection confirmation.')
except asyncio.CancelledError:
pass
@ -489,7 +506,7 @@ class VoiceConnectionState:
if self.ws:
await self.ws.close()
except Exception:
_log.debug('Ignoring exception soft disconnecting from voice', exc_info=True)
_log.debug('Ignoring exception soft disconnecting from voice.', exc_info=True)
finally:
self.state = with_state
self._socket_reader.pause()
@ -518,9 +535,13 @@ class VoiceConnectionState:
try:
await self.wait_async(timeout)
except asyncio.TimeoutError:
_log.warning('Timed out trying to move to channel %s in guild %s', channel.id, self.guild.id if self.guild else 'private')
_log.warning(
'Timed out trying to move to channel %s in guild %s.',
channel.id,
self.guild.id if self.guild else '"private"',
)
if self.state is last_state:
_log.debug('Reverting to previous state %s', previous_state.name)
_log.debug('Reverting to previous voice state %s.', previous_state.name)
self.state = previous_state
def wait(self, timeout: Optional[float] = None) -> bool:
@ -536,11 +557,11 @@ class VoiceConnectionState:
self.socket.sendall(packet)
def add_socket_listener(self, callback: SocketReaderCallback) -> None:
_log.debug('Registering socket listener callback %s', callback)
_log.debug('Registering voice socket listener callback %s.', callback)
self._socket_reader.register(callback)
def remove_socket_listener(self, callback: SocketReaderCallback) -> None:
_log.debug('Unregistering socket listener callback %s', callback)
_log.debug('Unregistering voice socket listener callback %s.', callback)
self._socket_reader.unregister(callback)
def _inside_runner(self) -> bool:
@ -555,18 +576,22 @@ class VoiceConnectionState:
return
await sane_wait_for([self._state_event.wait()], timeout=timeout)
async def _voice_connect(self, *, self_deaf: bool = False, self_mute: bool = False) -> None:
async def _voice_connect(self, *, self_deaf: bool = False, self_mute: bool = False, self_video: bool = False) -> None:
channel = self.voice_client.channel
if self.guild:
await self.guild.change_voice_state(channel=channel, self_deaf=self_deaf, self_mute=self_mute)
await self.guild.change_voice_state(
channel=channel, self_deaf=self_deaf, self_mute=self_mute, self_video=self_video
)
else:
await self.voice_client._state.client.change_voice_state(channel=channel, self_deaf=self_deaf, self_mute=self_mute)
await self.voice_client._state.client.change_voice_state(
channel=channel, self_deaf=self_deaf, self_mute=self_mute, self_video=self_video
)
async def _voice_disconnect(self) -> None:
_log.info(
'The voice handshake is being terminated for Channel ID %s (Guild ID %s)',
'The voice handshake is being terminated for channel ID %s (guild ID %s).',
self.voice_client.channel.id,
self.guild.id if self.guild else 'private',
self.guild.id if self.guild else '"private"',
)
self.state = ConnectionFlowState.disconnected
if self.guild:
@ -618,12 +643,12 @@ class VoiceConnectionState:
# We were disconnected by discord
# This condition is a race between the main ws event and the voice ws closing
if self._disconnected.is_set():
_log.info('Disconnected from voice by discord, close code %d.', exc.code)
_log.info('Disconnected from voice by Discord, close code %d.', exc.code)
await self.disconnect()
break
# We may have been moved to a different channel
_log.info('Disconnected from voice by force... potentially reconnecting.')
_log.info('Disconnected from voice by force. Potentially reconnecting...')
successful = await self._potential_reconnect()
if not successful:
_log.info('Reconnect was unsuccessful, disconnecting from voice normally...')
@ -634,7 +659,7 @@ class VoiceConnectionState:
else:
continue
_log.debug('Not handling close code %s (%s)', exc.code, exc.reason or 'no reason')
_log.debug('Not handling close code %s (%s).', exc.code, exc.reason or 'no reason')
if not reconnect:
await self.disconnect()
@ -651,6 +676,7 @@ class VoiceConnectionState:
timeout=self.timeout,
self_deaf=(self.self_voice_state or self).self_deaf,
self_mute=(self.self_voice_state or self).self_mute,
self_video=(self.self_voice_state or self).self_video,
resume=False,
)
except asyncio.TimeoutError:

3
discord/webhook/async_.py

@ -58,6 +58,7 @@ __all__ = (
_log = logging.getLogger(__name__)
if TYPE_CHECKING:
from datetime import datetime
from typing_extensions import Self
from types import TracebackType
@ -807,7 +808,7 @@ class BaseWebhook(Hashable):
return guild and guild.get_channel(self.channel_id) # type: ignore
@property
def created_at(self) -> datetime.datetime:
def created_at(self) -> datetime:
""":class:`datetime.datetime`: Returns the webhook's creation time in UTC."""
return utils.snowflake_time(self.id)

45
docs/api.rst

@ -1977,22 +1977,6 @@ of :class:`enum.Enum`.
.. versionadded:: 2.0
.. class:: InviteType
Specifies the type of :class:`Invite`.
.. attribute:: guild
A guild invite.
.. attribute:: group_dm
A group DM invite.
.. attribute:: friend
A friend invite.
.. attribute:: guild_incident_alert_mode_enabled
The system message sent when security actions is enabled.
@ -2023,6 +2007,22 @@ of :class:`enum.Enum`.
.. versionadded:: 2.1
.. class:: InviteType
Specifies the type of :class:`Invite`.
.. attribute:: guild
A guild invite.
.. attribute:: group_dm
A group DM invite.
.. attribute:: friend
A friend invite.
.. class:: UserFlags
Represents Discord User flags.
@ -8261,14 +8261,6 @@ DirectoryEntry
.. autoclass:: DirectoryEntry()
:members:
RoleFlags
~~~~~~~~~~
.. attributetable:: RoleFlags
.. autoclass:: RoleFlags
:members:
ForumTag
~~~~~~~~~
@ -8429,6 +8421,11 @@ Flags
.. autoclass:: ReadStateFlags()
:members:
.. attributetable:: RoleFlags
.. autoclass:: RoleFlags()
:members:
.. attributetable:: SKUFlags
.. autoclass:: SKUFlags()

Loading…
Cancel
Save