diff --git a/discord/__init__.py b/discord/__init__.py index c666cca4..589fdd3a 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -33,12 +33,12 @@ from .guild import Guild from .flags import * from .relationship import Relationship from .member import Member, VoiceState -from .message import Message, MessageReference, Attachment +from .message import * from .asset import Asset from .errors import * from .calls import CallMessage, GroupCall from .permissions import Permissions, PermissionOverwrite -from .role import Role +from .role import Role, RoleTags from .file import File from .colour import Color, Colour from .integrations import Integration, IntegrationAccount @@ -64,11 +64,4 @@ VersionInfo = namedtuple('VersionInfo', 'major minor micro enhanced releaselevel version_info = VersionInfo(major=1, minor=6, micro=0, enhanced=6, releaselevel='alpha', serial=0) -try: - from logging import NullHandler -except ImportError: - class NullHandler(logging.Handler): - def emit(self, record): - pass - -logging.getLogger(__name__).addHandler(NullHandler()) +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/discord/abc.py b/discord/abc.py index c0a7a54d..5684a4b2 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -32,7 +32,7 @@ import asyncio from .iterators import HistoryIterator from .context_managers import Typing from .enums import ChannelType -from .errors import InvalidArgument, ClientException, HTTPException +from .errors import InvalidArgument, ClientException from .permissions import PermissionOverwrite, Permissions from .role import Role from .invite import Invite @@ -805,7 +805,8 @@ class Messageable(metaclass=abc.ABCMeta): async def send(self, content=None, *, tts=False, embed=None, file=None, files=None, delete_after=None, nonce=None, - allowed_mentions=None, message_reference=None): + allowed_mentions=None, reference=None, + mention_author=None): """|coro| Sends a message to the destination with the content given. @@ -857,6 +858,19 @@ class Messageable(metaclass=abc.ABCMeta): .. versionadded:: 1.5.1.5 + reference: Union[:class:`~discord.Message`, :class:`~discord.MessageReference`] + A reference to the :class:`~discord.Message` to which you are replying, this can be created using + :meth:`~discord.Message.to_reference` or passed directly as a :class:`~discord.Message`. You can control + whether this mentions the author of the referenced message using the :attr:`~discord.AllowedMentions.replied_user` + attribute of ``allowed_mentions`` or by setting ``mention_author``. + + .. versionadded:: 1.6 + + mention_author: Optional[:class:`bool`] + If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. + + .. versionadded:: 1.6 + Raises -------- ~discord.HTTPException @@ -864,8 +878,10 @@ class Messageable(metaclass=abc.ABCMeta): ~discord.Forbidden You do not have the proper permissions to send the message. ~discord.InvalidArgument - The ``files`` list is not of the appropriate size or - you specified both ``file`` and ``files``. + The ``files`` list is not of the appropriate size, + you specified both ``file`` and ``files``, + or the ``reference`` object is not a :class:`~discord.Message` + or :class:`~discord.MessageReference`. Returns --------- @@ -887,8 +903,15 @@ class Messageable(metaclass=abc.ABCMeta): else: allowed_mentions = state.allowed_mentions and state.allowed_mentions.to_dict() - if message_reference is not None: - message_reference = message_reference.to_dict() + if mention_author is not None: + allowed_mentions = allowed_mentions or {} + allowed_mentions['replied_user'] = bool(mention_author) + + if reference is not None: + try: + reference = reference.to_message_reference_dict() + except AttributeError: + raise InvalidArgument('reference parameter must be Message or MessageReference') from None if file is not None and files is not None: raise InvalidArgument('cannot pass both file and files parameter to send()') @@ -899,8 +922,8 @@ class Messageable(metaclass=abc.ABCMeta): try: data = await state.http.send_files(channel.id, files=[file], allowed_mentions=allowed_mentions, - content=content, tts=tts, embed=embed, nonce=nonce, - message_reference=message_reference) + content=content, tts=tts, embed=embed, nonce=nonce, + message_reference=reference) finally: file.close() @@ -913,14 +936,14 @@ class Messageable(metaclass=abc.ABCMeta): try: data = await state.http.send_files(channel.id, files=files, content=content, tts=tts, embed=embed, nonce=nonce, allowed_mentions=allowed_mentions, - message_reference=message_reference) + message_reference=reference) finally: for f in files: f.close() else: data = await state.http.send_message(channel.id, content, tts=tts, embed=embed, nonce=nonce, allowed_mentions=allowed_mentions, - message_reference=message_reference) + message_reference=reference) ret = state.create_message(channel=channel, data=data) if delete_after is not None: diff --git a/discord/activity.py b/discord/activity.py index 0e0e59bc..91e094e2 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -685,6 +685,7 @@ class CustomActivity(BaseActivity): __slots__ = ('name', 'emoji', 'state') def __init__(self, name, *, emoji=None, **extra): + super().__init__(**extra) self.name = name self.state = extra.pop('state', None) if self.name == 'Custom Status': diff --git a/discord/client.py b/discord/client.py index 537ec6d8..5e53eae1 100644 --- a/discord/client.py +++ b/discord/client.py @@ -35,14 +35,12 @@ import re import aiohttp from .user import User, Profile -from .asset import Asset from .invite import Invite from .template import Template from .widget import Widget from .guild import Guild from .channel import _channel_factory from .enums import ChannelType -from .member import Member from .mentions import AllowedMentions from .errors import * from .enums import Status, VoiceRegion @@ -347,6 +345,18 @@ class Client: ws = self.ws return float('nan') if not ws else ws.latency + def is_ws_ratelimited(self): + """:class:`bool`: Whether the websocket is currently rate limited. + + This can be useful to know when deciding whether you should query members + using HTTP or via the gateway. + + .. versionadded:: 1.6 + """ + if self.ws: + return self.ws.is_ratelimited() + return False + @property def user(self): """Optional[:class:`.ClientUser`]: Represents the connected client. ``None`` if not logged in.""" diff --git a/discord/enums.py b/discord/enums.py index 871d7e49..fff7a153 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -446,6 +446,11 @@ class ExpireBehaviour(Enum): ExpireBehavior = ExpireBehaviour +class StickerType(Enum): + png = 1 + apng = 2 + lottie = 3 + def try_enum(cls, val): """A function that tries to turn the value into enum ``cls``. @@ -456,8 +461,3 @@ def try_enum(cls, val): return cls._enum_value_map_[val] except (KeyError, TypeError, AttributeError): return val - -class StickerType(Enum): - png = 1 - apng = 2 - lottie = 3 diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index a261f096..3fcfd808 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -30,12 +30,11 @@ import inspect import importlib.util import sys import traceback -import re import types import discord -from .core import GroupMixin, Command +from .core import GroupMixin from .view import StringView from .context import Context from . import errors diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index 2a836daa..774f5317 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -70,6 +70,11 @@ class CogMeta(type): ----------- name: :class:`str` The cog name. By default, it is the name of the class with no modification. + description: :class:`str` + The cog description. By default, it is the cleaned docstring of the class. + + .. versionadded:: 1.6 + command_attrs: :class:`dict` A list of attributes to apply to every command inside this cog. The dictionary is passed into the :class:`Command` options at ``__init__``. @@ -93,6 +98,11 @@ class CogMeta(type): attrs['__cog_name__'] = kwargs.pop('name', name) attrs['__cog_settings__'] = command_attrs = kwargs.pop('command_attrs', {}) + description = kwargs.pop('description', None) + if description is None: + description = inspect.cleandoc(attrs.get('__doc__', '')) + attrs['__cog_description__'] = description + commands = {} listeners = {} no_bot_cog = 'Commands or listeners must not start with cog_ or bot_ (in method {0.__name__}.{1})' @@ -209,11 +219,11 @@ class Cog(metaclass=CogMeta): @property def description(self): """:class:`str`: Returns the cog's description, typically the cleaned docstring.""" - try: - return self.__cog_cleaned_doc__ - except AttributeError: - self.__cog_cleaned_doc__ = cleaned = inspect.getdoc(self) - return cleaned + return self.__cog_description__ + + @description.setter + def description(self, description): + self.__cog_description__ = description def walk_commands(self): """An iterator that recursively walks through this cog's commands and subcommands. @@ -427,4 +437,7 @@ class Cog(metaclass=CogMeta): if cls.bot_check_once is not Cog.bot_check_once: bot.remove_check(self.bot_check_once, call_once=True) finally: - self.cog_unload() + try: + self.cog_unload() + except Exception: + pass diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 4c32b5e8..2a7b1d39 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -333,27 +333,7 @@ class Context(discord.abc.Messageable): except CommandError as e: await cmd.on_help_command_error(self, e) - async def reply(self, content=None, **kwargs): - """|coro| - - A shortcut method to :meth:`~discord.abc.Messageable.send` to reply to the - :class:`~discord.Message` that invoked the command. - - .. versionadded:: 1.5.1.5 - - Raises - -------- - ~discord.HTTPException - Sending the message failed. - ~discord.Forbidden - You do not have the proper permissions to send the message. - ~discord.InvalidArgument - The ``files`` list is not of the appropriate size or - you specified both ``file`` and ``files``. - - Returns - --------- - :class:`~discord.Message` - The message that was sent. - """ - return await self.message.reply(content, **kwargs) \ No newline at end of file + async def reply(self, content=None, **kwargs): + return await self.message.reply(content, **kwargs) + + reply.__doc__ = discord.Message.reply.__doc__ diff --git a/discord/guild.py b/discord/guild.py index 5716526c..24e2441c 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -30,14 +30,13 @@ from collections import namedtuple from . import utils from .role import Role from .member import Member, VoiceState -from .activity import create_activity from .emoji import Emoji from .errors import InvalidData from .permissions import PermissionOverwrite from .colour import Colour from .errors import InvalidArgument, ClientException from .channel import * -from .enums import VoiceRegion, Status, ChannelType, try_enum, VerificationLevel, ContentFilter, NotificationLevel +from .enums import VoiceRegion, ChannelType, try_enum, VerificationLevel, ContentFilter, NotificationLevel from .mixins import Hashable from .user import User from .invite import Invite @@ -474,7 +473,7 @@ class Guild(Hashable): @property def rules_channel(self): """Optional[:class:`TextChannel`]: Return's the guild's channel used for the rules. - Must be a discoverable guild. + The guild must be a Community guild. If no channel is set, then this returns ``None``. @@ -486,8 +485,8 @@ class Guild(Hashable): @property def public_updates_channel(self): """Optional[:class:`TextChannel`]: Return's the guild's channel where admins and - moderators of the guilds receive notices from Discord. This is only available to - guilds that contain ``PUBLIC`` in :attr:`Guild.features`. + moderators of the guilds receive notices from Discord. The guild must be a + Community guild. If no channel is set, then this returns ``None``. @@ -581,6 +580,30 @@ class Guild(Hashable): """:class:`Role`: Gets the @everyone role that all members have by default.""" return self.get_role(self.id) + @property + def premium_subscriber_role(self): + """Optional[:class:`Role`]: Gets the premium subscriber role, AKA "boost" role, in this guild. + + .. versionadded:: 1.6 + """ + for role in self._roles.values(): + if role.is_premium_subscriber(): + return role + return None + + @property + def self_role(self): + """Optional[:class:`Role`]: Gets the role associated with this client's user, if any. + + .. versionadded:: 1.6 + """ + self_id = self._state.self_id + for role in self._roles.values(): + tags = role.tags + if tags and tags.bot_id == self_id: + return role + return None + @property def owner(self): """Optional[:class:`Member`]: The member that owns the guild.""" @@ -1209,10 +1232,10 @@ class Guild(Hashable): except KeyError: pass else: - if rules_channel is None: - fields['public_updates_channel_id'] = rules_channel + if public_updates_channel is None: + fields['public_updates_channel_id'] = public_updates_channel else: - fields['public_updates_channel_id'] = rules_channel.id + fields['public_updates_channel_id'] = public_updates_channel.id await http.edit_guild(self.id, reason=reason, **fields) async def fetch_channels(self): diff --git a/discord/http.py b/discord/http.py index 143faad1..887632f9 100644 --- a/discord/http.py +++ b/discord/http.py @@ -361,7 +361,7 @@ class HTTPClient: if allowed_mentions: payload['allowed_mentions'] = allowed_mentions - if message_reference: + if message_reference: payload['message_reference'] = message_reference return self.request(r, json=payload) @@ -382,7 +382,7 @@ class HTTPClient: payload['nonce'] = nonce if allowed_mentions: payload['allowed_mentions'] = allowed_mentions - if message_reference: + if message_reference: payload['message_reference'] = message_reference form.add_field('payload_json', utils.to_json(payload)) diff --git a/discord/iterators.py b/discord/iterators.py index b059ea51..51431651 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -28,7 +28,7 @@ import asyncio import datetime from .errors import NoMoreItems -from .utils import DISCORD_EPOCH, time_snowflake, maybe_coroutine +from .utils import time_snowflake, maybe_coroutine from .object import Object from .audit_logs import AuditLogEntry @@ -62,6 +62,11 @@ class _AsyncIterator: if ret: return elem + def chunk(self, max_size): + if max_size <= 0: + raise ValueError('async iterator chunk sizes must be greater than 0.') + return _ChunkedAsyncIterator(self, max_size) + def map(self, func): return _MappedAsyncIterator(self, func) @@ -92,6 +97,26 @@ class _AsyncIterator: def _identity(x): return x +class _ChunkedAsyncIterator(_AsyncIterator): + def __init__(self, iterator, max_size): + self.iterator = iterator + self.max_size = max_size + + async def next(self): + ret = [] + n = 0 + while n < self.max_size: + try: + item = await self.iterator.next() + except NoMoreItems: + if ret: + return ret + raise + else: + ret.append(item) + n += 1 + return ret + class _MappedAsyncIterator(_AsyncIterator): def __init__(self, iterator, func): self.iterator = iterator diff --git a/discord/member.py b/discord/member.py index 3fb62c83..2d79b8a8 100644 --- a/discord/member.py +++ b/discord/member.py @@ -34,7 +34,7 @@ from . import utils from .user import BaseUser, User from .activity import create_activity from .permissions import Permissions -from .enums import Status, try_enum, UserFlags, HypeSquadHouse +from .enums import Status, try_enum from .colour import Colour from .object import Object diff --git a/discord/mentions.py b/discord/mentions.py index 5429280c..14fcecc5 100644 --- a/discord/mentions.py +++ b/discord/mentions.py @@ -59,11 +59,11 @@ class AllowedMentions: roles are not mentioned at all. If a list of :class:`abc.Snowflake` is given then only the roles provided will be mentioned, provided those roles are in the message content. - replied_user: :class:`bool` - Whether to mention the author of the message being replied to. Defaults - to ``True``. + replied_user: :class:`bool` + Whether to mention the author of the message being replied to. Defaults + to ``True``. - .. versionadded:: 1.5.1.5 + .. versionadded:: 1.6 """ __slots__ = ('everyone', 'users', 'roles', 'replied_user') @@ -107,7 +107,7 @@ class AllowedMentions: elif self.roles != False: data['roles'] = [x.id for x in self.roles] - if self.replied_user == True: + if self.replied_user: data['replied_user'] = True data['parse'] = parse diff --git a/discord/message.py b/discord/message.py index b291268e..09ec88f0 100644 --- a/discord/message.py +++ b/discord/message.py @@ -45,6 +45,12 @@ from .guild import Guild from .mixins import Hashable from .sticker import Sticker +__all__ = ( + 'Attachment', + 'Message', + 'MessageReference', + 'DeletedReferencedMessage', +) class Attachment: """Represents an attachment from Discord. @@ -213,11 +219,45 @@ class Attachment: data = await self.read(use_cached=use_cached) return File(io.BytesIO(data), filename=self.filename, spoiler=spoiler) +class DeletedReferencedMessage: + """A special sentinel type that denotes whether the + resolved message referenced message had since been deleted. + + The purpose of this class is to separate referenced messages that could not be + fetched and those that were previously fetched but have since been deleted. + + .. versionadded:: 1.6 + """ + + __slots__ = ('_parent') + + def __init__(self, parent): + self._parent = parent + + @property + def id(self): + """:class:`int`: The message ID of the deleted referenced message.""" + return self._parent.message_id + + @property + def channel_id(self): + """:class:`int`: The channel ID of the deleted referenced message.""" + return self._parent.channel_id + + @property + def guild_id(self): + """Optional[:class:`int`]: The guild ID of the deleted referenced message.""" + return self._parent.guild_id + + class MessageReference: - """Represents a reference to a :class:`Message`. + """Represents a reference to a :class:`~discord.Message`. .. versionadded:: 1.5 + .. versionchanged:: 1.6 + This class can now be constructed by users. + Attributes ----------- message_id: Optional[:class:`int`] @@ -226,15 +266,56 @@ class MessageReference: The channel id of the message referenced. guild_id: Optional[:class:`int`] The guild id of the message referenced. + resolved: Optional[Union[:class:`Message`, :class:`DeletedReferencedMessage`]] + The message that this reference resolved to. If this is ``None`` + then the original message was not fetched either due to the discord API + not attempting to resolve it or it not being available at the time of creation. + If the message was resolved at a prior point but has since been deleted then + this will be of type :class:`DeletedReferencedMessage`. + + Currently, this is mainly the replied to message when a user replies to a message. + + .. versionadded:: 1.6 """ - __slots__ = ('message_id', 'channel_id', 'guild_id', '_state') + __slots__ = ('message_id', 'channel_id', 'guild_id', 'resolved', '_state') - def __init__(self, state, **kwargs): - self.message_id = utils._get_as_snowflake(kwargs, 'message_id') - self.channel_id = int(kwargs.pop('channel_id')) - self.guild_id = utils._get_as_snowflake(kwargs, 'guild_id') + def __init__(self, *, message_id, channel_id, guild_id=None): + self._state = None + self.resolved = None + self.message_id = message_id + self.channel_id = channel_id + self.guild_id = guild_id + + @classmethod + def with_state(cls, state, data): + self = cls.__new__(cls) + self.message_id = utils._get_as_snowflake(data, 'message_id') + self.channel_id = int(data.pop('channel_id')) + self.guild_id = utils._get_as_snowflake(data, 'guild_id') self._state = state + self.resolved = None + return self + + @classmethod + def from_message(cls, message): + """Creates a :class:`MessageReference` from an existing :class:`~discord.Message`. + + .. versionadded:: 1.6 + + Parameters + ---------- + message: :class:`~discord.Message` + The message to be converted into a reference. + + Returns + ------- + :class:`MessageReference` + A reference to the message. + """ + self = cls(message_id=message.id, channel_id=message.channel.id, guild_id=getattr(message.guild, 'id', None)) + self._state = message._state + return self @classmethod def from_message(cls, message): @@ -256,35 +337,21 @@ class MessageReference: @property def cached_message(self): - """Optional[:class:`Message`]: The cached message, if found in the internal message cache.""" + """Optional[:class:`~discord.Message`]: The cached message, if found in the internal message cache.""" return self._state._get_message(self.message_id) def __repr__(self): return ''.format(self) - def to_dict(self, specify_channel=False): - """Converts the message reference to a dict, for transmission via the gateway. - - .. versionadded:: 1.5.1.5 - - Parameters - ------- - specify_channel: Optional[:class:`bool`] - Whether to include the channel ID in the returned object. - Defaults to False. - - Returns - ------- - :class:`dict` - The reference as a dict. - """ - result = {'message_id': self.message_id} if self.message_id is not None else {} - if specify_channel: - result['channel_id'] = self.channel_id - if self.guild_id is not None: - result['guild_id'] = self.guild_id + def to_dict(self): + result = {'message_id': self.message_id} if self.message_id is not None else {} + result['channel_id'] = self.channel_id + if self.guild_id is not None: + result['guild_id'] = self.guild_id return result + to_message_reference_dict = to_dict + def flatten_handlers(cls): prefix = len('_handle_') handlers = [ @@ -332,10 +399,10 @@ class Message(Hashable): call: Optional[:class:`CallMessage`] The call that the message refers to. This is only applicable to messages of type :attr:`MessageType.call`. - reference: Optional[:class:`MessageReference`] + reference: Optional[:class:`~discord.MessageReference`] The message that this message references. This is only applicable to messages of type :attr:`MessageType.pins_add`, crossposted messages created by a - followed channel integration or message replies. + followed channel integration, or message replies. .. versionadded:: 1.5 @@ -431,8 +498,27 @@ class Message(Hashable): self.nonce = data.get('nonce') self.stickers = [Sticker(data=data, state=state) for data in data.get('stickers', [])] - ref = data.get('message_reference') - self.reference = MessageReference(state, **ref) if ref is not None else None + try: + ref = data['message_reference'] + except KeyError: + self.reference = None + else: + self.reference = ref = MessageReference.with_state(state, ref) + try: + resolved = data['referenced_message'] + except KeyError: + pass + else: + if resolved is None: + ref.resolved = DeletedReferencedMessage(ref) + else: + # Right now the channel IDs match but maybe in the future they won't. + if ref.channel_id == channel.id: + chan = channel + else: + chan, _ = state._get_guild_channel(resolved) + + ref.resolved = self.__class__(channel=chan, data=resolved, state=state) for handler in ('author', 'member', 'mentions', 'mention_roles', 'call', 'flags'): try: @@ -891,9 +977,22 @@ class Message(Hashable): before deleting the message we just edited. If the deletion fails, then it is silently ignored. allowed_mentions: Optional[:class:`~discord.AllowedMentions`] - Controls the mentions being processed in this message. + Controls the mentions being processed in this message. If this is + passed, then the object is merged with :attr:`~discord.Client.allowed_mentions`. + The merging behaviour only overrides attributes that have been explicitly passed + to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`. + If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions` + are used instead. .. versionadded:: 1.4 + .. versionchanged:: 1.6 + :attr:`~discord.Client.allowed_mentions` serves as defaults unconditionally. + + mention_author: Optional[:class:`bool`] + Overrides the :attr:`~discord.AllowedMentions.replied_user` attribute + of ``allowed_mentions``. + + .. versionadded:: 1.6 Raises ------- @@ -931,17 +1030,24 @@ class Message(Hashable): delete_after = fields.pop('delete_after', None) - try: - allowed_mentions = fields.pop('allowed_mentions') - except KeyError: - pass - else: - if allowed_mentions is not None: - if self._state.allowed_mentions is not None: - allowed_mentions = self._state.allowed_mentions.merge(allowed_mentions).to_dict() - else: - allowed_mentions = allowed_mentions.to_dict() - fields['allowed_mentions'] = allowed_mentions + mention_author = fields.pop('mention_author', None) + allowed_mentions = fields.pop('allowed_mentions', None) + if allowed_mentions is not None: + if self._state.allowed_mentions is not None: + allowed_mentions = self._state.allowed_mentions.merge(allowed_mentions) + allowed_mentions = allowed_mentions.to_dict() + if mention_author is not None: + allowed_mentions['replied_user'] = mention_author + fields['allowed_mentions'] = allowed_mentions + elif mention_author is not None: + if self._state.allowed_mentions is not None: + allowed_mentions = self._state.allowed_mentions.to_dict() + allowed_mentions['replied_user'] = mention_author + else: + allowed_mentions = {'replied_user': mention_author} + fields['allowed_mentions'] = allowed_mentions + elif self._state.allowed_mentions is not None: + fields['allowed_mentions'] = self._state.allowed_mentions.to_dict() if fields: data = await self._state.http.edit_message(self.channel.id, self.id, **fields) @@ -1180,26 +1286,50 @@ class Message(Hashable): async def reply(self, content=None, **kwargs): """|coro| - A shortcut method to :meth:`abc.Messageable.send` to reply to the - :class:`Message`. - - .. versionadded:: 1.5.1.5 - - Raises - -------- - ~discord.HTTPException - Sending the message failed. - ~discord.Forbidden - You do not have the proper permissions to send the message. - ~discord.InvalidArgument - The ``files`` list is not of the appropriate size or - you specified both ``file`` and ``files``. - - Returns - --------- - :class:`Message` - The message that was sent. - """ - reference = MessageReference.from_message(self) - return await self.channel.send(content, message_reference=reference, **kwargs) \ No newline at end of file + A shortcut method to :meth:`abc.Messageable.send` to reply to the + :class:`Message`. + + .. versionadded:: 1.6 + + Raises + -------- + ~discord.HTTPException + Sending the message failed. + ~discord.Forbidden + You do not have the proper permissions to send the message. + ~discord.InvalidArgument + The ``files`` list is not of the appropriate size or + you specified both ``file`` and ``files``. + + Returns + --------- + :class:`Message` + The message that was sent. + """ + + return await self.channel.send(content, reference=self, **kwargs) + + def to_reference(self): + """Creates a :class:`~discord.MessageReference` from the current message. + + .. versionadded:: 1.6 + + Returns + --------- + :class:`~discord.MessageReference` + The reference to this message. + """ + + return MessageReference.from_message(self) + + def to_message_reference_dict(self): + data = { + 'message_id': self.id, + 'channel_id': self.channel.id, + } + + if self.guild is not None: + data['guild_id'] = self.guild.id + + return data diff --git a/discord/role.py b/discord/role.py index 09a42599..3fe749b6 100644 --- a/discord/role.py +++ b/discord/role.py @@ -28,7 +28,53 @@ from .permissions import Permissions from .errors import InvalidArgument from .colour import Colour from .mixins import Hashable -from .utils import snowflake_time +from .utils import snowflake_time, _get_as_snowflake + +class RoleTags: + """Represents tags on a role. + + A role tag is a piece of extra information attached to a managed role + that gives it context for the reason the role is managed. + + While this can be accessed, a useful interface is also provided in the + :class:`Role` and :class:`Guild` classes as well. + + .. versionadded:: 1.6 + + Attributes + ------------ + bot_id: Optional[:class:`int`] + The bot's user ID that manages this role. + integration_id: Optional[:class:`int`] + The integration ID that manages the role. + """ + + __slots__ = ('bot_id', 'integration_id', '_premium_subscriber',) + + def __init__(self, data): + self.bot_id = _get_as_snowflake(data, 'bot_id') + self.integration_id = _get_as_snowflake(data, 'integration_id') + # NOTE: The API returns "null" for this if it's valid, which corresponds to None. + # This is different from other fields where "null" means "not there". + # So in this case, a value of None is the same as True. + # Which means we would need a different sentinel. For this purpose I used ellipsis. + self._premium_subscriber = data.get('premium_subscriber', ...) + + def is_bot_managed(self): + """:class:`bool`: Whether the role is associated with a bot.""" + return self.bot_id is not None + + def is_premium_subscriber(self): + """:class:`bool`: Whether the role is the premium subscriber, AKA "boost", role for the guild.""" + return self._premium_subscriber is None + + def is_integration(self): + """:class:`bool`: Whether the role is managed by an integration.""" + return self.integration_id is not None + + def __repr__(self): + return ''.format(self, self.is_premium_subscriber()) class Role(Hashable): """Represents a Discord role in a :class:`Guild`. @@ -85,10 +131,12 @@ class Role(Hashable): integrations such as Twitch. mentionable: :class:`bool` Indicates if the role can be mentioned by users. + tags: Optional[:class:`RoleTags`] + The role tags associated with this role. """ __slots__ = ('id', 'name', '_permissions', '_colour', 'position', - 'managed', 'mentionable', 'hoist', 'guild', '_state') + 'managed', 'mentionable', 'hoist', 'guild', 'tags', '_state') def __init__(self, *, guild, state, data): self.guild = guild @@ -150,10 +198,36 @@ class Role(Hashable): self.managed = data.get('managed', False) self.mentionable = data.get('mentionable', False) + try: + self.tags = RoleTags(data['tags']) + except KeyError: + self.tags = None + def is_default(self): """:class:`bool`: Checks if the role is the default role.""" return self.guild.id == self.id + def is_bot_managed(self): + """:class:`bool`: Whether the role is associated with a bot. + + .. versionadded:: 1.6 + """ + return self.tags is not None and self.tags.is_bot_managed() + + def is_premium_subscriber(self): + """:class:`bool`: Whether the role is the premium subscriber, AKA "boost", role for the guild. + + .. versionadded:: 1.6 + """ + return self.tags is not None and self.tags.is_premium_subscriber() + + def is_integration(self): + """:class:`bool`: Whether the role is managed by an integration. + + .. versionadded:: 1.6 + """ + return self.tags is not None and self.tags.is_integration() + @property def permissions(self): """:class:`Permissions`: Returns the role's permissions.""" @@ -221,7 +295,7 @@ class Role(Hashable): use this. All fields are optional. - + .. versionchanged:: 1.4 Can now pass ``int`` to ``colour`` keyword-only parameter. @@ -263,7 +337,7 @@ class Role(Hashable): colour = fields['colour'] except KeyError: colour = fields.get('color', self.colour) - + if isinstance(colour, int): colour = Colour(value=colour) diff --git a/discord/shard.py b/discord/shard.py index 6985d797..ebc27b0d 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -258,6 +258,16 @@ class ShardInfo: """:class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds for this shard.""" return self._parent.ws.latency + def is_ws_ratelimited(self): + """:class:`bool`: Whether the websocket is currently rate limited. + + This can be useful to know when deciding whether you should query members + using HTTP or via the gateway. + + .. versionadded:: 1.6 + """ + return self._parent.ws.is_ratelimited() + class AutoShardedClient(Client): """A client similar to :class:`Client` except it handles the complications of sharding for the user into a more manageable and transparent single @@ -519,3 +529,16 @@ class AutoShardedClient(Client): me.activities = activities me.status = status_enum + + def is_ws_ratelimited(self): + """:class:`bool`: Whether the websocket is currently rate limited. + + This can be useful to know when deciding whether you should query members + using HTTP or via the gateway. + + This implementation checks if any of the shards are rate limited. + For more granular control, consider :meth:`ShardInfo.is_ws_ratelimited`. + + .. versionadded:: 1.6 + """ + return any(shard.ws.is_ratelimited() for shard in self.__shards.values()) diff --git a/discord/state.py b/discord/state.py index 6650ba87..983577ab 100644 --- a/discord/state.py +++ b/discord/state.py @@ -30,7 +30,6 @@ import copy import datetime import itertools import logging -import math import weakref import warnings import inspect @@ -53,7 +52,6 @@ from .role import Role from .enums import ChannelType, try_enum, Status from . import utils from .flags import Intents, MemberCacheFlags -from .embeds import Embed from .object import Object from .invite import Invite diff --git a/discord/team.py b/discord/team.py index 94b00a2e..e59c122c 100644 --- a/discord/team.py +++ b/discord/team.py @@ -75,7 +75,7 @@ class Team: """ return self.icon_url_as() - def icon_url_as(self, *, format='None', size=1024): + def icon_url_as(self, *, format='webp', size=1024): """Returns an :class:`Asset` for the icon the team has. The format must be one of 'webp', 'jpeg', 'jpg' or 'png'. diff --git a/discord/utils.py b/discord/utils.py index 56ac6699..fd24eaee 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -31,7 +31,6 @@ import unicodedata from base64 import b64encode from bisect import bisect_left import datetime -from email.utils import parsedate_to_datetime import functools from inspect import isawaitable as _isawaitable from operator import attrgetter @@ -40,7 +39,6 @@ import re import warnings from .errors import InvalidArgument -from .object import Object DISCORD_EPOCH = 1420070400000 MAX_ASYNCIO_SECONDS = 3456000 diff --git a/docs/api.rst b/docs/api.rst index bff0bf94..e10efdef 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2145,6 +2145,26 @@ Certain utilities make working with async iterators easier, detailed below. :return: A list of every element in the async iterator. :rtype: list + .. method:: chunk(max_size) + + Collects items into chunks of up to a given maximum size. + Another :class:`AsyncIterator` is returned which collects items into + :class:`list`\s of a given size. The maximum chunk size must be a positive integer. + + .. versionadded:: 1.6 + + Collecting groups of users: :: + + async for leader, *users in reaction.users().chunk(3): + ... + + .. warning:: + + The last chunk collected may not be as large as ``max_size``. + + :param max_size: The size of individual chunks. + :rtype: :class:`AsyncIterator` + .. method:: map(func) This is similar to the built-in :func:`map ` function. Another @@ -2767,6 +2787,13 @@ Message .. autoclass:: Message() :members: +DeletedReferencedMessage +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: DeletedReferencedMessage() + :members: + + Reaction ~~~~~~~~~ @@ -2872,6 +2899,12 @@ Role .. autoclass:: Role() :members: +RoleTags +~~~~~~~~~~ + +.. autoclass:: RoleTags() + :members: + TextChannel ~~~~~~~~~~~~ @@ -2999,11 +3032,6 @@ Sticker .. autoclass:: Sticker() :members: -MessageReference -~~~~~~~~~~~~~~~~~ -.. autoclass:: MessageReference() - :members: - RawMessageDeleteEvent ~~~~~~~~~~~~~~~~~~~~~~~ @@ -3092,6 +3120,12 @@ AllowedMentions .. autoclass:: AllowedMentions :members: +MessageReference +~~~~~~~~~~~~~~~~~ + +.. autoclass:: MessageReference + :members: + Intents ~~~~~~~~~~ diff --git a/examples/reaction_roles.py b/examples/reaction_roles.py new file mode 100644 index 00000000..1a668561 --- /dev/null +++ b/examples/reaction_roles.py @@ -0,0 +1,83 @@ +"""Uses a messages to add and remove roles through reactions.""" + +import discord + +class RoleReactClient(discord.Client): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.role_message_id = 0 # ID of message that can be reacted to to add role + self.emoji_to_role = { + partial_emoji_1: 0, # ID of role associated with partial emoji object 'partial_emoji_1' + partial_emoji_2: 0 # ID of role associated with partial emoji object 'partial_emoji_2' + } + + async def on_raw_reaction_add(self, payload): + """Gives a role based on a reaction emoji.""" + # Make sure that the message the user is reacting to is the one we care about + if payload.message_id != self.role_message_id: + return + + try: + role_id = self.emoji_to_role[payload.emoji] + except KeyError: + # If the emoji isn't the one we care about then exit as well. + return + + guild = self.get_guild(payload.guild_id) + if guild is None: + # Check if we're still in the guild and it's cached. + return + + role = guild.get_role(role_id) + if role is None: + # Make sure the role still exists and is valid. + return + + try: + # Finally add the role + await payload.member.add_roles(role) + except discord.HTTPException: + # If we want to do something in case of errors we'd do it here. + pass + + async def on_raw_reaction_remove(self, payload): + """Removes a role based on a reaction emoji.""" + # Make sure that the message the user is reacting to is the one we care about + if payload.message_id == self.role_message_id: + return + + try: + role_id = self.emoji_to_role[payload.emoji] + except KeyError: + # If the emoji isn't the one we care about then exit as well. + return + + guild = self.get_guild(payload.guild_id) + if guild is None: + # Check if we're still in the guild and it's cached. + return + + role = guild.get_role(role_id) + if role is None: + # Make sure the role still exists and is valid. + return + + member = guild.get_member(payload.user_id) + if member is None: + # Makes sure the member still exists and is valid + return + + try: + # Finally, remove the role + await member.remove_roles(role) + except discord.HTTPException: + # If we want to do something in case of errors we'd do it here. + pass + +# This bot requires the members and reactions intents. +intents = discord.Intents.default() +intents.members = True + +client = RoleReactClient(intents=intents) +client.run("token") diff --git a/examples/reply.py b/examples/reply.py index c8f903f8..5ea764a1 100644 --- a/examples/reply.py +++ b/examples/reply.py @@ -13,7 +13,7 @@ class MyClient(discord.Client): return if message.content.startswith('!hello'): - await message.channel.send('Hello {0.author.mention}'.format(message)) + await message.reply('Hello!', mention_author=True) client = MyClient() client.run('token')