diff --git a/discord/channel.py b/discord/channel.py index 79441c968..28739edc5 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -38,6 +38,7 @@ from .errors import ClientException, NoMoreItems, InvalidArgument __all__ = ( 'TextChannel', 'VoiceChannel', + 'StageChannel', 'DMChannel', 'CategoryChannel', 'StoreChannel', @@ -537,51 +538,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): from .message import PartialMessage return PartialMessage(channel=self, id=message_id) -class VoiceChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hashable): - """Represents a Discord guild voice channel. - - .. container:: operations - - .. describe:: x == y - - Checks if two channels are equal. - - .. describe:: x != y - - Checks if two channels are not equal. - - .. describe:: hash(x) - - Returns the channel's hash. - - .. describe:: str(x) - - Returns the channel's name. - - Attributes - ----------- - name: :class:`str` - The channel name. - guild: :class:`Guild` - The guild the channel belongs to. - id: :class:`int` - The channel ID. - category_id: Optional[:class:`int`] - The category channel ID this channel belongs to, if applicable. - position: :class:`int` - The position in the channel list. This is a number that starts at 0. e.g. the - top channel is position 0. - bitrate: :class:`int` - The channel's preferred audio bitrate in bits per second. - user_limit: :class:`int` - The channel's limit for number of members that can be in a voice channel. - rtc_region: Optional[:class:`VoiceRegion`] - The region for the voice channel's voice communication. - A value of ``None`` indicates automatic voice region detection. - - .. versionadded:: 1.7 - """ - +class VocalGuildChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hashable): __slots__ = ('name', 'id', 'guild', 'bitrate', 'user_limit', '_state', 'position', '_overwrites', 'category_id', 'rtc_region') @@ -591,29 +548,12 @@ class VoiceChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hashable): self.id = int(data['id']) self._update(guild, data) - def __repr__(self): - attrs = [ - ('id', self.id), - ('name', self.name), - ('rtc_region', self.rtc_region), - ('position', self.position), - ('bitrate', self.bitrate), - ('user_limit', self.user_limit), - ('category_id', self.category_id) - ] - return '<%s %s>' % (self.__class__.__name__, ' '.join('%s=%r' % t for t in attrs)) - def _get_voice_client_key(self): return self.guild.id, 'guild_id' def _get_voice_state_pair(self): return self.guild.id, self.id - @property - def type(self): - """:class:`ChannelType`: The channel's Discord type.""" - return ChannelType.voice - def _update(self, guild, data): self.guild = guild self.name = data['name'] @@ -671,6 +611,70 @@ class VoiceChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hashable): base.value &= ~denied.value return base +class VoiceChannel(VocalGuildChannel): + """Represents a Discord guild voice channel. + + .. container:: operations + + .. describe:: x == y + + Checks if two channels are equal. + + .. describe:: x != y + + Checks if two channels are not equal. + + .. describe:: hash(x) + + Returns the channel's hash. + + .. describe:: str(x) + + Returns the channel's name. + + Attributes + ----------- + name: :class:`str` + The channel name. + guild: :class:`Guild` + The guild the channel belongs to. + id: :class:`int` + The channel ID. + category_id: Optional[:class:`int`] + The category channel ID this channel belongs to, if applicable. + position: :class:`int` + The position in the channel list. This is a number that starts at 0. e.g. the + top channel is position 0. + bitrate: :class:`int` + The channel's preferred audio bitrate in bits per second. + user_limit: :class:`int` + The channel's limit for number of members that can be in a voice channel. + rtc_region: Optional[:class:`VoiceRegion`] + The region for the voice channel's voice communication. + A value of ``None`` indicates automatic voice region detection. + + .. versionadded:: 1.7 + """ + + __slots__ = () + + def __repr__(self): + attrs = [ + ('id', self.id), + ('name', self.name), + ('rtc_region', self.rtc_region), + ('position', self.position), + ('bitrate', self.bitrate), + ('user_limit', self.user_limit), + ('category_id', self.category_id) + ] + return '<%s %s>' % (self.__class__.__name__, ' '.join('%s=%r' % t for t in attrs)) + + @property + def type(self): + """:class:`ChannelType`: The channel's Discord type.""" + return ChannelType.voice + @utils.copy_doc(discord.abc.GuildChannel.clone) async def clone(self, *, name=None, reason=None): return await self._clone_impl({ @@ -728,6 +732,130 @@ class VoiceChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hashable): await self._edit(options, reason=reason) +class StageChannel(VocalGuildChannel): + """Represents a Discord guild stage channel. + + .. versionadded:: 1.7 + + .. container:: operations + + .. describe:: x == y + + Checks if two channels are equal. + + .. describe:: x != y + + Checks if two channels are not equal. + + .. describe:: hash(x) + + Returns the channel's hash. + + .. describe:: str(x) + + Returns the channel's name. + + Attributes + ----------- + name: :class:`str` + The channel name. + guild: :class:`Guild` + The guild the channel belongs to. + id: :class:`int` + The channel ID. + topic: Optional[:class:`str`] + The channel's topic. ``None`` if it isn't set. + category_id: Optional[:class:`int`] + The category channel ID this channel belongs to, if applicable. + position: :class:`int` + The position in the channel list. This is a number that starts at 0. e.g. the + top channel is position 0. + bitrate: :class:`int` + The channel's preferred audio bitrate in bits per second. + user_limit: :class:`int` + The channel's limit for number of members that can be in a stage channel. + rtc_region: Optional[:class:`VoiceRegion`] + The region for the stage channel's voice communication. + A value of ``None`` indicates automatic voice region detection. + """ + __slots__ = ('topic',) + + def __repr__(self): + attrs = [ + ('id', self.id), + ('name', self.name), + ('topic', self.topic), + ('rtc_region', self.rtc_region), + ('position', self.position), + ('bitrate', self.bitrate), + ('user_limit', self.user_limit), + ('category_id', self.category_id) + ] + return '<%s %s>' % (self.__class__.__name__, ' '.join('%s=%r' % t for t in attrs)) + + def _update(self, guild, data): + super()._update(guild, data) + self.topic = data.get('topic') + + @property + def requesting_to_speak(self): + """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 type(self): + """:class:`ChannelType`: The channel's Discord type.""" + return ChannelType.stage_voice + + @utils.copy_doc(discord.abc.GuildChannel.clone) + async def clone(self, *, name=None, reason=None): + return await self._clone_impl({ + 'topic': self.topic, + }, name=name, reason=reason) + + async def edit(self, *, reason=None, **options): + """|coro| + + Edits the channel. + + You must have the :attr:`~Permissions.manage_channels` permission to + use this. + + Parameters + ---------- + name: :class:`str` + The new channel's name. + topic: :class:`str` + The new channel's topic. + position: :class:`int` + The new channel's position. + sync_permissions: :class:`bool` + Whether to sync permissions with the channel's new or pre-existing + category. Defaults to ``False``. + category: Optional[:class:`CategoryChannel`] + The new category for this channel. Can be ``None`` to remove the + category. + reason: Optional[:class:`str`] + The reason for editing this channel. Shows up on the audit log. + overwrites: :class:`dict` + A :class:`dict` of target (either a role or a member) to + :class:`PermissionOverwrite` to apply to the channel. + rtc_region: Optional[:class:`VoiceRegion`] + The new region for the stage channel's voice communication. + A value of ``None`` indicates automatic voice region detection. + + Raises + ------ + InvalidArgument + If the permission overwrite information is not in proper form. + Forbidden + You do not have permissions to edit the channel. + HTTPException + Editing the channel failed. + """ + + await self._edit(options, reason=reason) + class CategoryChannel(discord.abc.GuildChannel, Hashable): """Represents a Discord channel category. @@ -874,6 +1002,18 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): ret.sort(key=lambda c: (c.position, c.id)) return ret + @property + def stage_channels(self): + """List[:class:`StageChannel`]: Returns the voice channels that are under this category. + + .. versionadded:: 1.7 + """ + ret = [c for c in self.guild.channels + if c.category_id == self.id + and isinstance(c, StageChannel)] + ret.sort(key=lambda c: (c.position, c.id)) + return ret + async def create_text_channel(self, name, *, overwrites=None, reason=None, **options): """|coro| @@ -898,6 +1038,20 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): """ return await self.guild.create_voice_channel(name, overwrites=overwrites, category=self, reason=reason, **options) + async def create_stage_channel(self, name, *, overwrites=None, reason=None, **options): + """|coro| + + A shortcut method to :meth:`Guild.create_stage_channel` to create a :class:`StageChannel` in the category. + + .. versionadded:: 1.7 + + Returns + ------- + :class:`StageChannel` + The channel that was just created. + """ + return await self.guild.create_stage_channel(name, overwrites=overwrites, category=self, reason=reason, **options) + class StoreChannel(discord.abc.GuildChannel, Hashable): """Represents a Discord guild store channel. @@ -1407,5 +1561,7 @@ def _channel_factory(channel_type): return TextChannel, value elif value is ChannelType.store: return StoreChannel, value + elif value is ChannelType.stage_voice: + return StageChannel, value else: return None, value diff --git a/discord/enums.py b/discord/enums.py index 93194788a..38ff285fe 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -158,6 +158,7 @@ class ChannelType(Enum): category = 4 news = 5 store = 6 + stage_voice = 13 def __str__(self): return self.name diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 2da0e110f..afbea4f28 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -46,6 +46,7 @@ __all__ = ( 'ColourConverter', 'ColorConverter', 'VoiceChannelConverter', + 'StageChannelConverter', 'EmojiConverter', 'PartialEmojiConverter', 'CategoryChannelConverter', @@ -396,6 +397,46 @@ class VoiceChannelConverter(IDConverter): return result +class StageChannelConverter(IDConverter): + """Converts to a :class:`~discord.StageChannel`. + + .. versionadded:: 1.7 + + All lookups are via the local guild. If in a DM context, then the lookup + is done by the global cache. + + The lookup strategy is as follows (in order): + + 1. Lookup by ID. + 2. Lookup by mention. + 3. Lookup by name + """ + async def convert(self, ctx, argument): + bot = ctx.bot + match = self._get_id_match(argument) or re.match(r'<#([0-9]+)>$', argument) + result = None + guild = ctx.guild + + if match is None: + # not a mention + if guild: + result = discord.utils.get(guild.stage_channels, name=argument) + else: + def check(c): + return isinstance(c, discord.StageChannel) and c.name == argument + result = discord.utils.find(check, bot.get_all_channels()) + else: + channel_id = int(match.group(1)) + if guild: + result = guild.get_channel(channel_id) + else: + result = _get_from_guilds(bot, 'get_channel', channel_id) + + if not isinstance(result, discord.StageChannel): + raise ChannelNotFound(argument) + + return result + class CategoryChannelConverter(IDConverter): """Converts to a :class:`~discord.CategoryChannel`. diff --git a/discord/guild.py b/discord/guild.py index 4b7ade899..2cc5679f0 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -371,6 +371,18 @@ class Guild(Hashable): r.sort(key=lambda c: (c.position, c.id)) return r + @property + def stage_channels(self): + """List[:class:`StageChannel`]: A list of voice channels that belongs to this guild. + + .. versionadded:: 1.7 + + This is sorted by the position and are in UI order from top to bottom. + """ + r = [ch for ch in self._channels.values() if isinstance(ch, StageChannel)] + r.sort(key=lambda c: (c.position, c.id)) + return r + @property def me(self): """:class:`Member`: Similar to :attr:`Client.user` except an instance of :class:`Member`. @@ -979,6 +991,38 @@ class Guild(Hashable): self._channels[channel.id] = channel return channel + async def create_stage_channel(self, name, *, topic=None, category=None, overwrites=None, reason=None, position=None): + """|coro| + + This is similar to :meth:`create_text_channel` except makes a :class:`StageChannel` instead. + + .. note:: + + The ``slowmode_delay`` and ``nsfw`` parameters are not supported in this function. + + .. versionadded:: 1.7 + + Raises + ------ + Forbidden + You do not have the proper permissions to create this channel. + HTTPException + Creating the channel failed. + InvalidArgument + The permission overwrite information is not in proper form. + + Returns + ------- + :class:`StageChannel` + The channel that was just created. + """ + data = await self._create_channel(name, overwrites, ChannelType.stage_voice, category, reason=reason, position=position, topic=topic) + channel = StageChannel(state=self._state, guild=self, data=data) + + # temporarily add to the cache + self._channels[channel.id] = channel + return channel + async def create_category(self, name, *, overwrites=None, reason=None, position=None): """|coro| diff --git a/discord/http.py b/discord/http.py index 9a95c65a6..3f63a75dc 100644 --- a/discord/http.py +++ b/discord/http.py @@ -574,6 +574,14 @@ class HTTPClient: } return self.request(r, json=payload, reason=reason) + def edit_my_voice_state(self, guild_id, payload): + r = Route('PATCH', '/guilds/{guild_id}/voice-states/@me', guild_id=guild_id) + return self.request(r, json=payload) + + def edit_voice_state(self, guild_id, user_id, payload): + r = Route('PATCH', '/guilds/{guild_id}/voice-states/{user_id}', guild_id=guild_id, user_id=user_id) + return self.request(r, json=payload) + def edit_member(self, guild_id, user_id, *, reason=None, **fields): r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) return self.request(r, json=fields, reason=reason) diff --git a/discord/member.py b/discord/member.py index a72e9945f..d0e969bd4 100644 --- a/discord/member.py +++ b/discord/member.py @@ -24,6 +24,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +import datetime import inspect import itertools import sys @@ -32,6 +33,7 @@ from operator import attrgetter import discord.abc from . import utils +from .errors import ClientException from .user import BaseUser, User from .activity import create_activity from .permissions import Permissions @@ -59,15 +61,32 @@ class VoiceState: self_video: :class:`bool` Indicates if the user is currently broadcasting video. + suppress: :class:`bool` + Indicates if the user is suppressed from speaking. + + Only applies to stage channels. + + .. versionadded:: 1.7 + + requested_to_speak_at: Optional[:class:`datetime.datetime`] + A datetime object that specifies the date and time in UTC that the member + requested to speak. It will be ``None`` if they are not requesting to speak + anymore or have been accepted to speak. + + Only applicable to stage channels. + + .. versionadded:: 1.7 + afk: :class:`bool` Indicates if the user is currently in the AFK channel in the guild. - channel: Optional[:class:`VoiceChannel`] + channel: Optional[Union[:class:`VoiceChannel`, :class:`StageChannel`]] The voice channel that the user is currently connected to. ``None`` if the user is not currently in a voice channel. """ __slots__ = ('session_id', 'deaf', 'mute', 'self_mute', - 'self_stream', 'self_video', 'self_deaf', 'afk', 'channel') + 'self_stream', 'self_video', 'self_deaf', 'afk', 'channel', + 'requested_to_speak_at', 'suppress') def __init__(self, *, data, channel=None): self.session_id = data.get('session_id') @@ -81,10 +100,20 @@ class VoiceState: self.afk = data.get('suppress', False) self.mute = data.get('mute', False) self.deaf = data.get('deaf', False) + self.suppress = data.get('suppress', False) + self.requested_to_speak_at = utils.parse_time(data.get('request_to_speak_timestamp')) self.channel = channel def __repr__(self): - return ''.format(self) + attrs = [ + ('self_mute', self.self_mute), + ('self_deaf', self.self_deaf), + ('self_stream', self.self_stream), + ('suppress', self.suppress), + ('requested_to_speak_at', self.requested_to_speak_at), + ('channel', self.channel) + ] + return '<%s %s>' % (self.__class__.__name__, ' '.join('%s=%r' % t for t in attrs)) def flatten_user(cls): for attr, value in itertools.chain(BaseUser.__dict__.items(), User.__dict__.items()): @@ -559,6 +588,11 @@ class Member(discord.abc.Messageable, _BaseUser): Indicates if the member should be guild muted or un-muted. deafen: :class:`bool` Indicates if the member should be guild deafened or un-deafened. + suppress: :class:`bool` + Indicates if the member should be suppressed in stage channels. + + .. versionadded:: 1.7 + roles: Optional[List[:class:`Role`]] The member's new list of roles. This *replaces* the roles. voice_channel: Optional[:class:`VoiceChannel`] @@ -576,6 +610,7 @@ class Member(discord.abc.Messageable, _BaseUser): """ http = self._state.http guild_id = self.guild.id + me = self._state.self_id == self.id payload = {} try: @@ -585,7 +620,7 @@ class Member(discord.abc.Messageable, _BaseUser): pass else: nick = nick or '' - if self._state.self_id == self.id: + if me: await http.change_my_nickname(guild_id, nick, reason=reason) else: payload['nick'] = nick @@ -598,6 +633,23 @@ class Member(discord.abc.Messageable, _BaseUser): if mute is not None: payload['mute'] = mute + suppress = fields.get('suppress') + if suppress is not None: + voice_state_payload = { + 'channel_id': self.voice.channel.id, + 'suppress': suppress, + } + + if suppress or self.bot: + voice_state_payload['request_to_speak_timestamp'] = None + + if me: + await http.edit_my_voice_state(guild_id, voice_state_payload) + else: + if not suppress: + voice_state_payload['request_to_speak_timestamp'] = datetime.datetime.utcnow().isoformat() + await http.edit_voice_state(guild_id, self.id, voice_state_payload) + try: vc = fields['voice_channel'] except KeyError: @@ -612,10 +664,43 @@ class Member(discord.abc.Messageable, _BaseUser): else: payload['roles'] = tuple(r.id for r in roles) - await http.edit_member(guild_id, self.id, reason=reason, **payload) + if payload: + await http.edit_member(guild_id, self.id, reason=reason, **payload) # TODO: wait for WS event for modify-in-place behaviour + async def request_to_speak(self): + """|coro| + + Request to speak in the connected channel. + + Only applies to stage channels. + + .. note:: + + Requesting members that are not the client is equivalent + to :attr:`.edit` providing ``suppress`` as ``False``. + + .. versionadded:: 1.7 + + Raises + ------- + Forbidden + You do not have the proper permissions to the action requested. + HTTPException + The operation failed. + """ + payload = { + 'channel_id': self.voice.channel.id, + 'request_to_speak_timestamp': datetime.datetime.utcnow().isoformat(), + } + + if self._state.self_id != self.id: + payload['suppress'] = False + await self._state.http.edit_voice_state(self.guild.id, self.id, payload) + else: + await self._state.http.edit_my_voice_state(self.guild.id, payload) + async def move_to(self, channel, *, reason=None): """|coro| diff --git a/discord/permissions.py b/discord/permissions.py index 1b0dd5a2d..3fd0dacf0 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -213,6 +213,15 @@ class Permissions(BaseFlags): """ return cls(1 << 32) + @classmethod + def stage_moderator(cls): + """A factory method that creates a :class:`Permissions` with all + "Stage Moderator" permissions from the official Discord UI set to ``True``. + + .. versionadded:: 1.7 + """ + return cls(0b100000001010000000000000000000000) + @classmethod def advanced(cls): """A factory method that creates a :class:`Permissions` with all @@ -222,7 +231,6 @@ class Permissions(BaseFlags): """ return cls(1 << 3) - def update(self, **kwargs): r"""Bulk updates this permission object. diff --git a/docs/api.rst b/docs/api.rst index 844f18072..3ed59c044 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1074,6 +1074,12 @@ of :class:`enum.Enum`. A guild store channel. + .. attribute:: stage_voice + + A guild stage voice channel. + + .. versionadded:: 1.7 + .. class:: MessageType Specifies the type of :class:`Message`. This is used to denote if a message @@ -3038,6 +3044,15 @@ VoiceChannel :members: :inherited-members: +StageChannel +~~~~~~~~~~~~~ + +.. attributetable:: StageChannel + +.. autoclass:: StageChannel() + :members: + :inherited-members: + CategoryChannel ~~~~~~~~~~~~~~~~~