From 9f98a9a87fa1f2cc2702b5dc536178a018a631eb Mon Sep 17 00:00:00 2001 From: Nadir Chowdhury Date: Sun, 30 May 2021 18:51:52 +0100 Subject: [PATCH] Implement StageInstance --- discord/__init__.py | 1 + discord/channel.py | 123 +++++++++++++++++++++++++--- discord/client.py | 55 ++++++++++++- discord/enums.py | 6 ++ discord/guild.py | 36 +++++++- discord/http.py | 29 +++++++ discord/stage_instance.py | 168 ++++++++++++++++++++++++++++++++++++++ discord/state.py | 35 ++++++++ discord/types/channel.py | 12 +++ docs/api.rst | 52 ++++++++++++ 10 files changed, 502 insertions(+), 15 deletions(-) create mode 100644 discord/stage_instance.py diff --git a/discord/__init__.py b/discord/__init__.py index 3b057e366..dcf421ff6 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -55,6 +55,7 @@ from .audit_logs import * from .raw_models import * from .team import * from .sticker import * +from .stage_instance import * from .interactions import * from .components import * diff --git a/discord/channel.py b/discord/channel.py index c9eb9c345..ded4da418 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -30,11 +30,12 @@ from typing import Callable, Dict, List, Optional, TYPE_CHECKING, Union, overloa import discord.abc from .permissions import PermissionOverwrite, Permissions -from .enums import ChannelType, try_enum, VoiceRegion, VideoQualityMode +from .enums import ChannelType, StagePrivacyLevel, try_enum, VoiceRegion, VideoQualityMode from .mixins import Hashable from . import utils from .asset import Asset from .errors import ClientException, NoMoreItems, InvalidArgument +from .stage_instance import StageInstance __all__ = ( 'TextChannel', @@ -49,7 +50,7 @@ __all__ = ( if TYPE_CHECKING: from .role import Role - from .member import Member + from .member import Member, VoiceState from .abc import Snowflake from .message import Message from .webhook import Webhook @@ -611,7 +612,7 @@ class VocalGuildChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hasha return ChannelType.voice.value @property - def members(self): + def members(self) -> List[Member]: """List[:class:`Member`]: Returns all members that are currently inside this voice channel.""" ret = [] for user_id, state in self.guild._voice_states.items(): @@ -622,7 +623,7 @@ class VocalGuildChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hasha return ret @property - def voice_states(self): + def voice_states(self) -> Dict[int, VoiceState]: """Returns a mapping of member IDs who have voice states in this channel. .. versionadded:: 1.3 @@ -640,7 +641,7 @@ class VocalGuildChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hasha return {key: value for key, value in self.guild._voice_states.items() if value.channel.id == self.id} @utils.copy_doc(discord.abc.GuildChannel.permissions_for) - def permissions_for(self, member): + def permissions_for(self, member: Union[Role, Member], /) -> Permissions: base = super().permissions_for(member) # voice channels cannot be edited by people who can't connect to them @@ -875,10 +876,35 @@ class StageChannel(VocalGuildChannel): self.topic = data.get('topic') @property - def requesting_to_speak(self): + def requesting_to_speak(self) -> List[Member]: """List[:class:`Member`]: A list of members who are requesting to speak in the stage channel.""" return [member for member in self.members if member.voice.requested_to_speak_at is not None] + @property + def speakers(self) -> List[Member]: + """List[:class:`Member`]: A list of members who have been permitted to speak in the stage channel. + + .. versionadded:: 2.0 + """ + return [member for member in self.members if not member.voice.suppress and member.voice.requested_to_speak_at is None] + + @property + def listeners(self) -> List[Member]: + """List[:class:`Member`]: A list of members who are listening in the stage channel. + + .. versionadded:: 2.0 + """ + return [member for member in self.members if member.voice.suppress] + + @property + def moderators(self) -> List[Member]: + """List[:class:`Member`]: A list of members who are moderating the stage channel. + + .. versionadded:: 2.0 + """ + required_permissions = Permissions.stage_moderator() + return [member for member in self.members if self.permissions_for(member) >= required_permissions] + @property def type(self): """:class:`ChannelType`: The channel's Discord type.""" @@ -886,9 +912,83 @@ class StageChannel(VocalGuildChannel): @utils.copy_doc(discord.abc.GuildChannel.clone) async def clone(self, *, name: str = None, reason: Optional[str] = None) -> StageChannel: - return await self._clone_impl({ - 'topic': self.topic, - }, name=name, reason=reason) + return await self._clone_impl({}, name=name, reason=reason) + + @property + def instance(self) -> Optional[StageInstance]: + """Optional[:class:`StageInstance`]: The running stage instance of the stage channel. + + .. versionadded:: 2.0 + """ + return utils.get(self.guild.stage_instances, channel_id=self.id) + + async def create_instance(self, *, topic: str, privacy_level: StagePrivacyLevel = utils.MISSING) -> StageInstance: + """|coro| + + Create a stage instance. + + You must have the :attr:`~Permissions.manage_channels` permission to + use this. + + .. versionadded:: 2.0 + + Parameters + ----------- + topic: :class:`str` + The stage instance's topic. + privacy_level: :class:`StagePrivacyLevel` + The stage instance's privacy level. Defaults to :attr:`PrivacyLevel.guild_only`. + + Raises + ------ + InvalidArgument + If the ``privacy_level`` parameter is not the proper type. + Forbidden + You do not have permissions to create a stage instance. + HTTPException + Creating a stage instance failed. + + Returns + -------- + :class:`StageInstance` + The newly created stage instance. + """ + + payload = { + 'channel_id': self.id, + 'topic': topic + } + + if privacy_level is not utils.MISSING: + if not isinstance(privacy_level, StagePrivacyLevel): + raise InvalidArgument('privacy_level field must be of type PrivacyLevel') + + payload['privacy_level'] = privacy_level.value + + data = await self._state.http.create_stage_instance(**payload) + return StageInstance(guild=self.guild, state=self._state, data=data) + + async def fetch_instance(self) -> StageInstance: + """|coro| + + Gets the running :class:`StageInstance`. + + .. versionadded:: 2.0 + + Raises + ------- + :exc:`.NotFound` + The stage instance or channel could not be found. + :exc:`.HTTPException` + Getting the stage instance failed. + + Returns + -------- + :class:`StageInstance` + The stage instance. + """ + data = await self._state.http.get_stage_instance(self.id) + return StageInstance(guild=self.guild, state=self._state, data=data) @overload async def edit( @@ -918,12 +1018,13 @@ class StageChannel(VocalGuildChannel): You must have the :attr:`~Permissions.manage_channels` permission to use this. + .. versionchanged:: 2.0 + The ``topic`` parameter must now be set via :attr:`create_instance`. + Parameters ---------- name: :class:`str` The new channel's name. - topic: Optional[:class:`str`] - The new channel's topic. position: :class:`int` The new channel's position. sync_permissions: :class:`bool` diff --git a/discord/client.py b/discord/client.py index 60226012b..48bae6884 100644 --- a/discord/client.py +++ b/discord/client.py @@ -29,7 +29,7 @@ import logging import signal import sys import traceback -from typing import Any, List, Optional, TYPE_CHECKING, Union +from typing import Any, Generator, List, Optional, TYPE_CHECKING, TypeVar, Union import aiohttp @@ -56,6 +56,7 @@ from .webhook import Webhook from .iterators import GuildIterator from .appinfo import AppInfo from .ui.view import View +from .stage_instance import StageInstance __all__ = ( 'Client', @@ -693,6 +694,28 @@ class Client: """ return self._connection.get_channel(id) + def get_stage_instance(self, id) -> Optional[StageInstance]: + """Returns a stage instance with the given stage channel ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`StageInstance`] + The returns stage instance of ``None`` if not found. + """ + from .channel import StageChannel + + channel = self._connection.get_channel(id) + + if isinstance(channel, StageChannel): + return channel.instance + def get_guild(self, id): """Returns a guild with the given ID. @@ -1136,6 +1159,34 @@ class Client: data = await self.http.create_guild(name, region_value, icon) return Guild(data=data, state=self._connection) + async def fetch_stage_instance(self, channel_id: int) -> StageInstance: + """|coro| + + Gets a :class:`StageInstance` for a stage channel id. + + .. versionadded:: 2.0 + + Parameters + ----------- + channel_id: :class:`int` + The stage channel ID. + + Raises + ------- + :exc:`.NotFound` + The stage instance or channel could not be found. + :exc:`.HTTPException` + Getting the stage instance failed. + + Returns + -------- + :class:`StageInstance` + The stage instance from the stage channel ID. + """ + data = await self.http.get_stage_instance(channel_id) + guild = self.get_guild(int(data['guild_id'])) + return StageInstance(guild=guild, state=self._connection, data=data) # type: ignore + # Invite management async def fetch_invite(self, url: Union[Invite, str], *, with_counts: bool = True, with_expiration: bool = True) -> Invite: @@ -1261,7 +1312,7 @@ class Client: async def fetch_user(self, user_id): """|coro| - Retrieves a :class:`~discord.User` based on their ID. + Retrieves a :class:`~discord.User` based on their ID. You do not have to share any guilds with the user to get this information, however many operations do require that you do. diff --git a/discord/enums.py b/discord/enums.py index f34d135c6..897f96021 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -50,6 +50,7 @@ __all__ = ( 'VideoQualityMode', 'ComponentType', 'ButtonStyle', + 'StagePrivacyLevel', ) def _create_value_cls(name): @@ -480,6 +481,11 @@ class ButtonStyle(Enum): def __int__(self): return self.value +class StagePrivacyLevel(Enum): + public = 1 + closed = 2 + guild_only = 2 + T = TypeVar('T') def create_unknown_value(cls: Type[T], val: Any) -> T: diff --git a/discord/guild.py b/discord/guild.py index e7c74de92..be2235592 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -46,6 +46,7 @@ from .widget import Widget from .asset import Asset from .flags import SystemChannelFlags from .integrations import Integration, _integration_factory +from .stage_instance import StageInstance __all__ = ( 'Guild', @@ -182,7 +183,7 @@ class Guild(Hashable): 'description', 'max_presences', 'max_members', 'max_video_channel_users', 'premium_tier', 'premium_subscription_count', '_system_channel_flags', 'preferred_locale', '_discovery_splash', '_rules_channel_id', - '_public_updates_channel_id', 'nsfw') + '_public_updates_channel_id', '_stage_instances', 'nsfw') _PREMIUM_GUILD_LIMITS = { None: _GuildLimit(emoji=50, bitrate=96e3, filesize=8388608), @@ -319,6 +320,11 @@ class Guild(Hashable): self._public_updates_channel_id = utils._get_as_snowflake(guild, 'public_updates_channel_id') self.nsfw = guild.get('nsfw', False) + self._stage_instances = {} + for s in guild.get('stage_instances', []): + stage_instance = StageInstance(guild=self, data=s, state=state) + self._stage_instances[stage_instance.id] = stage_instance + cache_joined = self._state.member_cache_flags.joined self_id = self._state.self_id for mdata in guild.get('members', []): @@ -613,6 +619,32 @@ class Guild(Hashable): return role return None + @property + def stage_instances(self) -> List[StageInstance]: + """List[:class:`StageInstance`]: Returns a :class:`list` of the guild's stage instances that + are currently running. + + .. versionadded:: 2.0 + """ + return list(self._stage_instances.values()) + + def get_stage_instance(self, stage_instance_id: int) -> Optional[StageInstance]: + """Returns a stage instance with the given ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + stage_instance_id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`StageInstance`] + The stage instance or ``None`` if not found. + """ + return self._stage_instances.get(stage_instance_id) + @property def owner(self): """Optional[:class:`Member`]: The member that owns the guild.""" @@ -1801,7 +1833,7 @@ class Guild(Hashable): The list of integrations that are attached to the guild. """ data = await self._state.http.get_all_integrations(self.id) - + def convert(d): factory, _ = _integration_factory(d['type']) if factory is None: diff --git a/discord/http.py b/discord/http.py index c14cc8d49..4dfea5da3 100644 --- a/discord/http.py +++ b/discord/http.py @@ -44,7 +44,9 @@ if TYPE_CHECKING: from .types import ( interactions, invite, + stage_instance, ) + from .types.snowflake import Snowflake T = TypeVar('T') Response = Coroutine[Any, Any, T] @@ -1080,6 +1082,33 @@ class HTTPClient: def move_member(self, user_id, guild_id, channel_id, *, reason=None): return self.edit_member(guild_id=guild_id, user_id=user_id, channel_id=channel_id, reason=reason) + # Stage instance management + + def get_stage_instance(self, channel_id: Snowflake) -> Response[stage_instance.StageInstance]: + return self.request(Route('GET', '/stage-instances/{channel_id}', channel_id=channel_id)) + + def create_stage_instance(self, **payload) -> Response[stage_instance.StageInstance]: + valid_keys = ( + 'channel_id', + 'topic', + 'privacy_level', + ) + payload = {k: v for k, v in payload.items() if k in valid_keys} + + return self.request(Route('POST', '/stage-instances'), json=payload) + + def edit_stage_instance(self, channel_id: Snowflake, **payload) -> Response[None]: + valid_keys = ( + 'topic', + 'privacy_level', + ) + payload = {k: v for k, v in payload.items() if k in valid_keys} + + return self.request(Route('PATCH', '/stage-instances/{channel_id}', channel_id=channel_id), json=payload) + + def delete_stage_instance(self, channel_id: Snowflake) -> Response[None]: + return self.request(Route('DELETE', '/stage-instances/{channel_id}', channel_id=channel_id)) + # Application commands (global) def get_global_commands(self, application_id) -> Response[List[interactions.ApplicationCommand]]: diff --git a/discord/stage_instance.py b/discord/stage_instance.py new file mode 100644 index 000000000..a6479d751 --- /dev/null +++ b/discord/stage_instance.py @@ -0,0 +1,168 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +from .utils import MISSING, cached_slot_property +from .mixins import Hashable +from .errors import InvalidArgument +from .enums import StagePrivacyLevel, try_enum + +__all__ = ( + 'StageInstance', +) + +if TYPE_CHECKING: + from .types.channel import StageInstance as StageInstancePayload + from .state import ConnectionState + from .channel import StageChannel + from .guild import Guild + + +class StageInstance(Hashable): + """Represents a stage instance of a stage channel in a guild. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two stagea instances are equal. + + .. describe:: x != y + + Checks if two stage instances are not equal. + + .. describe:: hash(x) + + Returns the stage instance's hash. + + Attributes + ----------- + id: :class:`int` + The stage instance's ID. + guild: :class:`Guild` + The guild that the stage instance is running in. + channel_id: :class:`int` + The ID of the channel that the stage instance is running in. + topic: :class:`str` + The topic of the stage instance. + privacy_level: :class:`StagePrivacyLevel` + The privacy level of the stage instance. + discoverable_disabled: :class:`bool` + Whether the stage instance is discoverable. + """ + + __slots__ = ( + '_state', + 'id', + 'guild', + 'channel_id', + 'topic', + 'privacy_level', + 'discoverable_disabled', + '_cs_channel', + ) + + def __init__(self, *, state: ConnectionState, guild: Guild, data: StageInstancePayload) -> None: + self._state = state + self.guild = guild + self._update(data) + + def _update(self, data: StageInstancePayload): + self.id: int = int(data['id']) + self.channel_id: int = int(data['channel_id']) + self.topic: str = data['topic'] + self.privacy_level = try_enum(StagePrivacyLevel, data['privacy_level']) + self.discoverable_disabled = data['discoverable_disabled'] + + def __repr__(self) -> str: + return f'' + + @cached_slot_property('_cs_channel') + def channel(self) -> Optional[StageChannel]: + """Optional[:class:`StageChannel`: The guild that stage instance is running in.""" + return self._state.get_channel(self.channel_id) + + def is_public(self) -> bool: + return self.privacy_level is StagePrivacyLevel.public + + async def edit(self, *, topic: str = MISSING, privacy_level: StagePrivacyLevel = MISSING) -> None: + """|coro| + + Edits the stage instance. + + You must have the :attr:`~Permissions.manage_channels` permission to + use this. + + Parameters + ----------- + topic: :class:`str` + The stage instance's new topic. + privacy_level: :class:`StagePrivacyLevel` + The stage instance's new privacy level. + + Raises + ------ + InvalidArgument + If the ``privacy_level`` parameter is not the proper type. + Forbidden + You do not have permissions to edit the stage instance. + HTTPException + Editing a stage instance failed. + """ + + payload = {} + + if topic is not MISSING: + payload['topic'] = topic + + if privacy_level is not MISSING: + if not isinstance(privacy_level, StagePrivacyLevel): + raise InvalidArgument('privacy_level field must be of type PrivacyLevel') + + payload['privacy_level'] = privacy_level.value + + if payload: + await self._state.http.edit_stage_instance(self.channel_id, **payload) + + async def delete(self) -> None: + """|coro| + + Deletes the stage instance. + + You must have the :attr:`~Permissions.manage_channels` permission to + use this. + + Raises + ------ + Forbidden + You do not have permissions to delete the stage instance. + HTTPException + Deleting the stage instance failed. + """ + await self._state.http.delete_stage_instance(self.channel_id) diff --git a/discord/state.py b/discord/state.py index fec758877..177c195c5 100644 --- a/discord/state.py +++ b/discord/state.py @@ -53,6 +53,7 @@ from .object import Object from .invite import Invite from .interactions import Interaction from .ui.view import ViewStore +from .stage_instance import StageInstance class ChunkRequest: def __init__(self, guild_id, loop, resolver, *, cache=True): @@ -956,6 +957,40 @@ class ConnectionState: else: log.debug('WEBHOOKS_UPDATE referencing an unknown channel ID: %s. Discarding.', data['channel_id']) + def parse_stage_instance_create(self, data): + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + stage_instance = StageInstance(guild=guild, state=self, data=data) + guild._stage_instances[stage_instance.id] = stage_instance + self.dispatch('stage_instance_create', stage_instance) + else: + log.debug('STAGE_INSTANCE_CREATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_stage_instance_update(self, data): + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + stage_instance = guild._stage_instances.get(int(data['id'])) + if stage_instance is not None: + old_stage_instance = copy.copy(stage_instance) + stage_instance._update(data) + self.dispatch('stage_instance_update', old_stage_instance, stage_instance) + else: + log.debug('STAGE_INSTANCE_UPDATE referencing unknown stage instance ID: %s. Discarding.', data['id']) + else: + log.debug('STAGE_INSTANCE_UPDATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_stage_instance_delete(self, data): + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + try: + stage_instance = guild._stage_instances.pop(int(data['id'])) + except KeyError: + pass + else: + self.dispatch('stage_instance_delete', stage_instance) + else: + log.debug('STAGE_INSTANCE_DELETE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_voice_state_update(self, data): guild = self._get_guild(utils._get_as_snowflake(data, 'guild_id')) channel_id = utils._get_as_snowflake(data, 'channel_id') diff --git a/discord/types/channel.py b/discord/types/channel.py index d9c9f74d7..ea3747ab3 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -89,3 +89,15 @@ class DMChannel(PartialChannel): class GroupDMChannel(DMChannel): icon: Optional[str] owner_id: Snowflake + + +PrivacyLevel = Literal[1, 2] + + +class StageInstance(TypedDict): + id: Snowflake + guild_id: Snowflake + channel_id: Snowflake + topic: str + privacy_level: PrivacyLevel + discoverable_disabled: bool diff --git a/docs/api.rst b/docs/api.rst index 27604ae46..ff6b13759 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -835,6 +835,32 @@ to handle it, which defaults to print a traceback and ignoring the exception. :param after: The voice state after the changes. :type after: :class:`VoiceState` +.. function:: on_stage_instance_create(stage_instance) + on_stage_instance_delete(stage_instance) + + Called when a :class:`StageInstance` is created or deleted for a :class:`StageChannel`. + + .. versionadded:: 2.0 + + :param stage_instance: The stage instance that was created or deleted. + :type stage_instance: :class:`StageInstance` + +.. function:: on_stage_instance_update(before, after) + + Called when a :class:`StageInstance` is updated. + + The following, but not limited to, examples illustrate when this event is called: + + - The topic is changed. + - The privacy level is changed. + + .. versionadded:: 2.0 + + :param before: The stage instance before the update. + :type before: :class:`StageInstance` + :param after: The stage instance after the update. + :type after: :class:`StageInstance` + .. function:: on_member_ban(guild, user) Called when user gets banned from a :class:`Guild`. @@ -2120,6 +2146,23 @@ of :class:`enum.Enum`. Represents full camera video quality. +.. class:: PrivacyLevel + + Represents a stage instance's privacy level. + + .. versionadded:: 2.0 + + .. attribute:: public + + The stage instance can be joined by external users. + + .. attribute:: closed + + The stage instance can only be joined by members of the guild. + + .. attribute:: guild_only + + Alias for :attr:`.closed` Async Iterator ---------------- @@ -3126,6 +3169,15 @@ StageChannel :members: :inherited-members: + +StageInstance +~~~~~~~~~~~~~~ + +.. attributetable:: StageInstance + +.. autoclass:: StageInstance() + :members: + CategoryChannel ~~~~~~~~~~~~~~~~~