diff --git a/discord/channel.py b/discord/channel.py index 7568b4047..9343b72c2 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -34,7 +34,6 @@ from .mixins import Hashable from . import utils from .asset import Asset from .errors import ClientException, NoMoreItems, InvalidArgument -from .webhook import Webhook __all__ = ( 'TextChannel', @@ -221,7 +220,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): A value of `0` disables slowmode. The maximum value possible is `21600`. type: :class:`ChannelType` Change the type of this text channel. Currently, only conversion between - :attr:`ChannelType.text` and :attr:`ChannelType.news` is supported. This + :attr:`ChannelType.text` and :attr:`ChannelType.news` is supported. This is only available to guilds that contain ``NEWS`` in :attr:`Guild.features`. reason: Optional[:class:`str`] The reason for editing this channel. Shows up on the audit log. @@ -429,6 +428,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): The webhooks for this channel. """ + from .webhook import Webhook data = await self._state.http.channel_webhooks(self.id) return [Webhook.from_state(d, state=self._state) for d in data] @@ -465,6 +465,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): The created webhook. """ + from .webhook import Webhook if avatar is not None: avatar = utils._bytes_to_base64_data(avatar) @@ -512,6 +513,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): if not isinstance(destination, TextChannel): raise InvalidArgument('Expected TextChannel received {0.__name__}'.format(type(destination))) + from .webhook import Webhook data = await self._state.http.follow_webhook(self.id, webhook_channel_id=destination.id, reason=reason) return Webhook._as_follower(data, channel=destination, user=self._state.user) diff --git a/discord/guild.py b/discord/guild.py index 1f78de426..1a1917a8b 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -41,7 +41,6 @@ from .mixins import Hashable from .user import User from .invite import Invite from .iterators import AuditLogIterator, MemberIterator -from .webhook import Webhook from .widget import Widget from .asset import Asset from .flags import SystemChannelFlags @@ -482,7 +481,7 @@ 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. The guild must be a + moderators of the guilds receive notices from Discord. The guild must be a Community guild. If no channel is set, then this returns ``None``. @@ -1482,6 +1481,7 @@ class Guild(Hashable): The webhooks for this guild. """ + from .webhook import Webhook data = await self._state.http.guild_webhooks(self.id) return [Webhook.from_state(d, state=self._state) for d in data] diff --git a/discord/webhook.py b/discord/webhook.py index bd2802302..db328dfb1 100644 --- a/discord/webhook.py +++ b/discord/webhook.py @@ -35,6 +35,7 @@ import aiohttp from . import utils from .errors import InvalidArgument, HTTPException, Forbidden, NotFound, DiscordServerError +from .message import Message from .enums import try_enum, WebhookType from .user import BaseUser, User from .asset import Asset @@ -45,6 +46,7 @@ __all__ = ( 'AsyncWebhookAdapter', 'RequestsWebhookAdapter', 'Webhook', + 'WebhookMessage', ) log = logging.getLogger(__name__) @@ -66,6 +68,9 @@ class WebhookAdapter: self._request_url = '{0.BASE}/webhooks/{1}/{2}'.format(self, webhook.id, webhook.token) self.webhook = webhook + def is_async(self): + return False + def request(self, verb, url, payload=None, multipart=None): """Actually does the request. @@ -94,6 +99,12 @@ class WebhookAdapter: def edit_webhook(self, *, reason=None, **payload): return self.request('PATCH', self._request_url, payload=payload, reason=reason) + def edit_webhook_message(self, message_id, payload): + return self.request('PATCH', '{}/messages/{}'.format(self._request_url, message_id), payload=payload) + + def delete_webhook_message(self, message_id): + return self.request('DELETE', '{}/messages/{}'.format(self._request_url, message_id)) + def handle_execution_response(self, data, *, wait): """Transforms the webhook execution response into something more meaningful. @@ -178,6 +189,9 @@ class AsyncWebhookAdapter(WebhookAdapter): self.session = session self.loop = asyncio.get_event_loop() + def is_async(self): + return True + async def request(self, verb, url, payload=None, multipart=None, *, files=None, reason=None): headers = {} data = None @@ -253,8 +267,9 @@ class AsyncWebhookAdapter(WebhookAdapter): return data # transform into Message object - from .message import Message - return Message(data=data, state=self.webhook._state, channel=self.webhook.channel) + # Make sure to coerce the state to the partial one to allow message edits/delete + state = _PartialWebhookState(self, self.webhook) + return WebhookMessage(data=data, state=state, channel=self.webhook.channel) class RequestsWebhookAdapter(WebhookAdapter): """A webhook adapter suited for use with ``requests``. @@ -356,8 +371,9 @@ class RequestsWebhookAdapter(WebhookAdapter): return response # transform into Message object - from .message import Message - return Message(data=response, state=self.webhook._state, channel=self.webhook.channel) + # Make sure to coerce the state to the partial one to allow message edits/delete + state = _PartialWebhookState(self, self.webhook) + return WebhookMessage(data=response, state=state, channel=self.webhook.channel) class _FriendlyHttpAttributeErrorHelper: __slots__ = () @@ -366,9 +382,10 @@ class _FriendlyHttpAttributeErrorHelper: raise AttributeError('PartialWebhookState does not support http methods.') class _PartialWebhookState: - __slots__ = ('loop',) + __slots__ = ('loop', 'parent') - def __init__(self, adapter): + def __init__(self, adapter, parent): + self.parent = parent # Fetch the loop from the adapter if it's there try: self.loop = adapter.loop @@ -394,6 +411,98 @@ class _PartialWebhookState: def __getattr__(self, attr): raise AttributeError('PartialWebhookState does not support {0!r}.'.format(attr)) +class WebhookMessage(Message): + """Represents a message sent from your webhook. + + This allows you to edit or delete a message sent by your + webhook. + + This inherits from :class:`discord.Message` with changes to + :meth:`edit` and :meth:`delete` to work. + + .. versionadded:: 1.6 + """ + + def edit(self, **fields): + """|maybecoro| + + Edits the message. + + The content must be able to be transformed into a string via ``str(content)``. + + .. versionadded:: 1.6 + + Parameters + ------------ + content: Optional[:class:`str`] + The content to edit the message with or ``None`` to clear it. + embeds: List[:class:`Embed`] + A list of embeds to edit the message with. + embed: Optional[:class:`Embed`] + The embed to edit the message with. ``None`` suppresses the embeds. + This should not be mixed with the ``embeds`` parameter. + allowed_mentions: :class:`AllowedMentions` + Controls the mentions being processed in this message. + See :meth:`.abc.Messageable.send` for more information. + + Raises + ------- + HTTPException + Editing the message failed. + Forbidden + Edited a message that is not yours. + InvalidArgument + You specified both ``embed`` and ``embeds`` or the length of + ``embeds`` was invalid or there was no token associated with + this webhook. + """ + return self._state.parent.edit_message(self.id, **fields) + + def _delete_delay_sync(self, delay): + time.sleep(delay) + return self._state.parent.delete_message(self.id) + + async def _delete_delay_async(self, delay): + async def inner_call(): + await asyncio.sleep(delay) + try: + await self._state.parent.delete_message(self.id) + except HTTPException: + pass + + asyncio.ensure_future(inner_call(), loop=self._state.loop) + return await asyncio.sleep(0) + + def delete(self, *, delay=None): + """|coro| + + Deletes the message. + + Parameters + ----------- + delay: Optional[:class:`float`] + If provided, the number of seconds to wait before deleting the message. + If this is a coroutine, the waiting is done in the background and deletion failures + are ignored. If this is not a coroutine then the delay blocks the thread. + + Raises + ------ + Forbidden + You do not have proper permissions to delete the message. + NotFound + The message was deleted already + HTTPException + Deleting the message failed. + """ + + if delay is not None: + if self._state.parent._adapter.is_async(): + return self._delete_delay_async(delay) + else: + return self._delete_delay_sync(delay) + + return self._state.parent.delete_message(self.id) + class Webhook(Hashable): """Represents a Discord webhook. @@ -488,7 +597,7 @@ class Webhook(Hashable): self.name = data.get('name') self.avatar = data.get('avatar') self.token = data.get('token') - self._state = state or _PartialWebhookState(adapter) + self._state = state or _PartialWebhookState(adapter, self) self._adapter = adapter self._adapter._prepare(self) @@ -785,7 +894,7 @@ class Webhook(Hashable): wait: :class:`bool` Whether the server should wait before sending a response. This essentially means that the return type of this function changes from ``None`` to - a :class:`Message` if set to ``True``. + a :class:`WebhookMessage` if set to ``True``. username: :class:`str` The username to send with this message. If no username is provided then the default username for the webhook is used. @@ -825,7 +934,7 @@ class Webhook(Hashable): Returns --------- - Optional[:class:`Message`] + Optional[:class:`WebhookMessage`] The message that was sent. """ @@ -869,3 +978,115 @@ class Webhook(Hashable): def execute(self, *args, **kwargs): """An alias for :meth:`~.Webhook.send`.""" return self.send(*args, **kwargs) + + def edit_message(self, message_id, **fields): + """|maybecoro| + + Edits a message owned by this webhook. + + This is a lower level interface to :meth:`WebhookMessage.edit` in case + you only have an ID. + + .. versionadded:: 1.6 + + Parameters + ------------ + message_id: :class:`int` + The message ID to edit. + content: Optional[:class:`str`] + The content to edit the message with or ``None`` to clear it. + embeds: List[:class:`Embed`] + A list of embeds to edit the message with. + embed: Optional[:class:`Embed`] + The embed to edit the message with. ``None`` suppresses the embeds. + This should not be mixed with the ``embeds`` parameter. + allowed_mentions: :class:`AllowedMentions` + Controls the mentions being processed in this message. + See :meth:`.abc.Messageable.send` for more information. + + Raises + ------- + HTTPException + Editing the message failed. + Forbidden + Edited a message that is not yours. + InvalidArgument + You specified both ``embed`` and ``embeds`` or the length of + ``embeds`` was invalid or there was no token associated with + this webhook. + """ + + payload = {} + + if self.token is None: + raise InvalidArgument('This webhook does not have a token associated with it') + + try: + content = fields['content'] + except KeyError: + pass + else: + if content is not None: + content = str(content) + payload['content'] = content + + # Check if the embeds interface is being used + try: + embeds = fields['embeds'] + except KeyError: + # Nope + pass + else: + if embeds is None or len(embeds) > 10: + raise InvalidArgument('embeds has a maximum of 10 elements') + payload['embeds'] = [e.to_dict() for e in embeds] + + try: + embed = fields['embed'] + except KeyError: + pass + else: + if 'embeds' in payload: + raise InvalidArgument('Cannot mix embed and embeds keyword arguments') + + if embed is None: + payload['embeds'] = [] + else: + payload['embeds'] = [embed.to_dict()] + + allowed_mentions = fields.pop('allowed_mentions', None) + previous_mentions = getattr(self._state, 'allowed_mentions', None) + + if allowed_mentions: + if previous_mentions is not None: + payload['allowed_mentions'] = previous_mentions.merge(allowed_mentions).to_dict() + else: + payload['allowed_mentions'] = allowed_mentions.to_dict() + elif previous_mentions is not None: + payload['allowed_mentions'] = previous_mentions.to_dict() + + return self._adapter.edit_webhook_message(message_id, payload=payload) + + def delete_message(self, message_id): + """|maybecoro| + + Deletes a message owned by this webhook. + + This is a lower level interface to :meth:`WebhookMessage.delete` in case + you only have an ID. + + .. versionadded:: 1.6 + + Parameters + ------------ + message_id: :class:`int` + The message ID to edit. + + Raises + ------- + HTTPException + Deleting the message failed. + Forbidden + Deleted a message that is not yours. + """ + return self._adapter.delete_webhook_message(message_id) diff --git a/docs/api.rst b/docs/api.rst index 892e175ef..ef3f5de5e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2090,9 +2090,9 @@ Certain utilities make working with async iterators easier, detailed below. 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): @@ -2544,6 +2544,9 @@ discord.py offers support for creating, editing, and executing webhooks through .. autoclass:: Webhook :members: +.. autoclass:: WebhookMessage + :members: + Adapters ~~~~~~~~~