From 37b0fdb898a0f242f68d30abb6c58bd1a15a6524 Mon Sep 17 00:00:00 2001
From: Rapptz <rapptz@gmail.com>
Date: Mon, 21 Aug 2017 01:54:33 -0400
Subject: [PATCH] Add webhook support.

Allows for usage of either `requests` and `aiohttp` when used in
"Standalone" mode.

Fixes #704
---
 discord/__init__.py |   1 +
 discord/channel.py  |  61 +++++
 discord/client.py   |  24 ++
 discord/errors.py   |   3 +-
 discord/guild.py    |  23 ++
 discord/http.py     |  26 +-
 discord/utils.py    |   6 +
 discord/webhook.py  | 651 ++++++++++++++++++++++++++++++++++++++++++++
 docs/api.rst        |  24 +-
 docs/conf.py        |   1 +
 10 files changed, 813 insertions(+), 7 deletions(-)
 create mode 100644 discord/webhook.py

diff --git a/discord/__init__.py b/discord/__init__.py
index 5efdd74a1..7973cd4a5 100644
--- a/discord/__init__.py
+++ b/discord/__init__.py
@@ -41,6 +41,7 @@ from collections import namedtuple
 from .embeds import Embed
 from .shard import AutoShardedClient
 from .player import *
+from .webhook import *
 from .voice_client import VoiceClient
 from .audit_logs import AuditLogChanges, AuditLogEntry, AuditLogDiff
 
diff --git a/discord/channel.py b/discord/channel.py
index 946ed3bcd..d81ad2735 100644
--- a/discord/channel.py
+++ b/discord/channel.py
@@ -28,6 +28,7 @@ from .enums import ChannelType, try_enum
 from .mixins import Hashable
 from . import utils
 from .errors import ClientException, NoMoreItems
+from .webhook import Webhook
 
 import discord.abc
 
@@ -321,6 +322,66 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable):
                     count += 1
                     ret.append(msg)
 
+    @asyncio.coroutine
+    def webhooks(self):
+        """|coro|
+
+        Gets the list of webhooks from this channel.
+
+        Requires :attr:`~.Permissions.manage_webhooks` permissions.
+
+        Raises
+        -------
+        Forbidden
+            You don't have permissions to get the webhooks.
+
+        Returns
+        --------
+        List[:class:`Webhook`]
+            The webhooks for this channel.
+        """
+
+        data = yield from self._state.http.channel_webhooks(self.id)
+        return [Webhook.from_state(d, state=self._state) for d in data]
+
+    @asyncio.coroutine
+    def create_webhook(self, *, name=None, avatar=None):
+        """|coro|
+
+        Creates a webhook for this channel.
+
+        Requires :attr:`~.Permissions.manage_webhooks` permissions.
+
+        Parameters
+        -------------
+        name: Optional[str]
+            The webhook's name.
+        avatar: Optional[bytes]
+            A *bytes-like* object representing the webhook's default avatar.
+            This operates similarly to :meth:`~ClientUser.edit`.
+
+        Raises
+        -------
+        HTTPException
+            Creating the webhook failed.
+        Forbidden
+            You do not have permissions to create a webhook.
+
+        Returns
+        --------
+        :class:`Webhook`
+            The created webhook.
+        """
+
+        if avatar is not None:
+            avatar = utils._bytes_to_base64_data(avatar)
+
+        if name is not None:
+            name = str(name)
+
+        data = yield from self._state.http.create_webhook(self.id, name=name, avatar=avatar)
+        return Webhook.from_state(data, state=self._state)
+
 class VoiceChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hashable):
     """Represents a Discord guild voice channel.
 
diff --git a/discord/client.py b/discord/client.py
index 4d719a4be..67d1fb9ee 100644
--- a/discord/client.py
+++ b/discord/client.py
@@ -36,6 +36,7 @@ from .http import HTTPClient
 from .state import ConnectionState
 from . import utils, compat
 from .backoff import ExponentialBackoff
+from .webhook import Webhook
 
 import asyncio
 import aiohttp
@@ -1010,3 +1011,26 @@ class Client:
                        mutual_guilds=mutual_guilds,
                        user=User(data=user, state=state),
                        connected_accounts=data['connected_accounts'])
+
+    @asyncio.coroutine
+    def get_webhook_info(self, webhook_id):
+        """|coro|
+
+        Retrieves a :class:`Webhook` with the specified ID.
+
+        Raises
+        --------
+        HTTPException
+            Retrieving the webhook failed.
+        NotFound
+            Invalid webhook ID.
+        Forbidden
+            You do not have permission to fetch this webhook.
+
+        Returns
+        ---------
+        :class:`Webhook`
+            The webhook you requested.
+        """
+        data = yield from self.http.get_webhook(webhook_id)
+        return Webhook.from_state(data, state=self._connection)
diff --git a/discord/errors.py b/discord/errors.py
index a613d2c01..84768ca2c 100644
--- a/discord/errors.py
+++ b/discord/errors.py
@@ -76,7 +76,8 @@ class HTTPException(DiscordException):
     ------------
     response: aiohttp.ClientResponse
         The response of the failed HTTP request. This is an
-        instance of `aiohttp.ClientResponse`__.
+        instance of `aiohttp.ClientResponse`__. In some cases
+        this could also be a ``requests.Response``.
 
         __ http://aiohttp.readthedocs.org/en/stable/client_reference.html#aiohttp.ClientResponse
 
diff --git a/discord/guild.py b/discord/guild.py
index 3f7b72706..5d7cb134d 100644
--- a/discord/guild.py
+++ b/discord/guild.py
@@ -43,6 +43,7 @@ from .utils import valid_icon_size
 from .user import User
 from .invite import Invite
 from .iterators import AuditLogIterator
+from .webhook import Webhook
 
 VALID_ICON_FORMATS = {"jpeg", "jpg", "webp", "png"}
 
@@ -798,6 +799,28 @@ class Guild(Hashable):
         data = yield from self._state.http.prune_members(self.id, days, reason=reason)
         return data['pruned']
 
+    @asyncio.coroutine
+    def webhooks(self):
+        """|coro|
+
+        Gets the list of webhooks from this guild.
+
+        Requires :attr:`~.Permissions.manage_webhooks` permissions.
+
+        Raises
+        -------
+        Forbidden
+            You don't have permissions to get the webhooks.
+
+        Returns
+        --------
+        List[:class:`Webhook`]
+            The webhooks for this guild.
+        """
+
+        data = yield from self._state.http.guild_webhooks(self.id)
+        return [Webhook.from_state(d, state=self._state) for d in data]
+
     @asyncio.coroutine
     def estimate_pruned_members(self, *, days):
         """|coro|
diff --git a/discord/http.py b/discord/http.py
index 57f992441..0d6747067 100644
--- a/discord/http.py
+++ b/discord/http.py
@@ -31,7 +31,6 @@ import sys
 import logging
 import weakref
 import datetime
-from email.utils import parsedate_to_datetime
 from urllib.parse import quote as _uriquote
 
 log = logging.getLogger(__name__)
@@ -161,9 +160,7 @@ class HTTPClient:
                     if remaining == '0' and r.status != 429:
                         # we've depleted our current bucket
                         if header_bypass_delay is None:
-                            now = parsedate_to_datetime(r.headers['Date'])
-                            reset = datetime.datetime.fromtimestamp(int(r.headers['X-Ratelimit-Reset']), datetime.timezone.utc)
-                            delta = (reset - now).total_seconds()
+                            delta = utils._parse_ratelimit_header(r)
                         else:
                             delta = header_bypass_delay
 
@@ -524,6 +521,26 @@ class HTTPClient:
     def delete_channel(self, channel_id, *, reason=None):
         return self.request(Route('DELETE', '/channels/{channel_id}', channel_id=channel_id), reason=reason)
 
+    # Webhook management
+
+    def create_webhook(self, channel_id, *, name=None, avatar=None):
+        payload = {}
+        if name is not None:
+            payload['name'] = name
+        if avatar is not None:
+            payload['avatar'] = avatar
+
+        return self.request(Route('POST', '/channels/{channel_id}/webhooks', channel_id=channel_id), json=payload)
+
+    def channel_webhooks(self, channel_id):
+        return self.request(Route('GET', '/channels/{channel_id}/webhooks', channel_id=channel_id))
+
+    def guild_webhooks(self, guild_id):
+        return self.request(Route('GET', '/guilds/{guild_id}/webhooks', guild_id=guild_id))
+
+    def get_webhook(self, webhook_id):
+        return self.request(Route('GET', '/webhooks/{webhook_id}', webhook_id=webhook_id))
+
     # Guild management
 
     def leave_guild(self, guild_id):
@@ -687,7 +704,6 @@ 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)
 
-
     # Relationship related
 
     def remove_relationship(self, user_id):
diff --git a/discord/utils.py b/discord/utils.py
index 9efb1bf85..e5d366ffc 100644
--- a/discord/utils.py
+++ b/discord/utils.py
@@ -28,6 +28,7 @@ from re import split as re_split
 from .errors import InvalidArgument
 import datetime
 from base64 import b64encode
+from email.utils import parsedate_to_datetime
 import asyncio
 import json
 import warnings, functools
@@ -258,6 +259,11 @@ def _bytes_to_base64_data(data):
 def to_json(obj):
     return json.dumps(obj, separators=(',', ':'), ensure_ascii=True)
 
+def _parse_ratelimit_header(request):
+    now = parsedate_to_datetime(request.headers['Date'])
+    reset = datetime.datetime.fromtimestamp(int(request.headers['X-Ratelimit-Reset']), datetime.timezone.utc)
+    return (reset - now).total_seconds()
+
 @asyncio.coroutine
 def maybe_coroutine(f, *args, **kwargs):
     if asyncio.iscoroutinefunction(f):
diff --git a/discord/webhook.py b/discord/webhook.py
new file mode 100644
index 000000000..d7d0fdcc6
--- /dev/null
+++ b/discord/webhook.py
@@ -0,0 +1,651 @@
+# -*- coding: utf-8 -*-
+
+"""
+The MIT License (MIT)
+
+Copyright (c) 2015-2017 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.
+"""
+
+import aiohttp
+import asyncio
+import json
+import time
+import re
+
+from . import utils
+from .errors import InvalidArgument, HTTPException, Forbidden, NotFound
+from .user import BaseUser, User
+
+__all__ = ('WebhookAdapter', 'AsyncWebhookAdapter', 'RequestsWebhookAdapter', 'Webhook')
+
+class WebhookAdapter:
+    """Base class for all webhook adapters.
+
+    Attributes
+    ------------
+    webhook: :class:`Webhook`
+        The webhook that owns this adapter.
+    """
+
+    BASE = 'https://discordapp.com/api/v7'
+
+    def _prepare(self, webhook):
+        self._webhook_id = webhook.id
+        self._webhook_token = webhook.token
+        self._request_url = '{0.BASE}/webhooks/{1}/{2}'.format(self, webhook.id, webhook.token)
+        self.webhook = webhook
+
+    def request(self, verb, url, json=None, multipart=None):
+        """Actually does the request.
+
+        Subclasses must implement this.
+
+        Parameters
+        -----------
+        verb: str
+            The HTTP verb to use for the request.
+        url: str
+            The URL to send the request to. This will have
+            the query parameters already added to it, if any.
+        multipart: Optional[dict]
+            A dict containing multipart form data to send with
+            the request. If a filename is being uploaded, then it will
+            be under a ``file`` key which will have a 3-element tuple
+            denoting ``(filename, file, content_type)``.
+        json: Optional[dict]
+            The JSON to send with the request, if any.
+        """
+        raise NotImplementedError()
+
+    def delete_webhook(self):
+        return self.request('DELETE', self._request_url)
+
+    def edit_webhook(self, **json):
+        return self.request('PATCH', self._request_url, json=json)
+
+    def handle_execution_response(self, data, *, wait):
+        """Transforms the webhook execution response into something
+        more meaningful.
+
+        This is mainly used to convert the data into a :class:`Message`
+        if necessary.
+
+        Subclasses must implement this.
+
+        Parameters
+        ------------
+        data
+            The data that was returned from the request.
+        wait: bool
+            Whether the webhook execution was asked to wait or not.
+        """
+        raise NotImplementedError()
+
+    def _store_user(self, data):
+        # mocks a ConnectionState for appropriate use for Message
+        return BaseUser(state=self, data=data)
+
+    def execute_webhook(self, *, json, wait=False, file=None):
+        if file is not None:
+            multipart = {
+                'file': file,
+                'payload_json': utils.to_json(json)
+            }
+            data = None
+        else:
+            data = json
+            multipart = None
+
+        url = '%s?wait=%d' % (self._request_url, wait)
+        maybe_coro = self.request('POST', url, multipart=multipart, json=data)
+        return self.handle_execution_response(maybe_coro, wait=wait)
+
+class AsyncWebhookAdapter(WebhookAdapter):
+    """A webhook adapter suited for use with aiohttp.
+
+    .. note::
+
+        You are responsible for cleaning up the client session.
+
+    Parameters
+    -----------
+    session: aiohttp.ClientSession
+        The session to use to send requests.
+    """
+
+    def __init__(self, *, session):
+        self.session = session
+        self.loop = session.loop
+
+    @asyncio.coroutine
+    def request(self, verb, url, json=None, multipart=None):
+        headers = {}
+        data = None
+        if json:
+            headers['Content-Type'] = 'application/json'
+            data = utils.to_json(json)
+
+        if multipart:
+            file = multipart.pop('file', None)
+            if file:
+                data.add_field('file', file[0], filename=file[1], content_type=file[2])
+            for key, value in multipart.items():
+                data.add_field(key, value)
+
+        for tries in range(5):
+            r = yield from self.session.request(verb, url, headers=headers, data=data)
+            try:
+                data = yield from r.text(encoding='utf-8')
+                if r.headers['Content-Type'] == 'application/json':
+                    data = json.loads(data)
+
+                # check if we have rate limit header information
+                remaining = r.headers.get('X-Ratelimit-Remaining')
+                if remaining == '0' and r.status != 429:
+                    delta = utils._parse_ratelimit_header(r)
+                    yield from asyncio.sleep(delta, loop=self.loop)
+
+                if 300 > r.status >= 200:
+                    return data
+
+                # we are being rate limited
+                if r.status == 429:
+                    retry_after = data['retry_after'] / 1000.0
+                    yield from asyncio.sleep(retry_after, loop=self.loop)
+                    continue
+
+                if r.status in (500, 502):
+                    yield from asyncio.sleep(1 + tries * 2, loop=self.loop)
+                    continue
+
+                if r.status == 403:
+                    raise Forbidden(r, data)
+                elif r.status == 404:
+                    raise NotFound(r, data)
+                else:
+                    raise HTTPException(r, data)
+            finally:
+                yield from r.release()
+
+    @asyncio.coroutine
+    def handle_execution_response(self, response, *, wait):
+        data = yield from response
+        if not wait:
+            return data
+
+        # transform into Message object
+        from .message import Message
+        return Message(data=data, state=self, channel=self.webhook.channel)
+
+class RequestsWebhookAdapter(WebhookAdapter):
+    """A webhook adapter suited for use with ``requests``.
+
+    Only versions of requests higher than 2.13.0 are supported.
+
+    Parameters
+    -----------
+    session: Optional[`requests.Session <http://docs.python-requests.org/en/latest/api/#requests.Session>`_]
+        The requests session to use for sending requests. If not given then
+        each request will create a new session. Note if a session is given,
+        the webhook adapter **will not** clean it up for you. You must close
+        the session yourself.
+    sleep: bool
+        Whether to sleep the thread when encountering a 429 or pre-emptive
+        rate limit or a 5xx status code. Defaults to ``True``. If set to
+        ``False`` then this will raise an :exc:`HTTPException` instead.
+    """
+
+    def __init__(self, session=None, *, sleep=True):
+        import requests
+        self.session = session or requests
+        self.sleep = sleep
+
+    def request(self, verb, url, json=None, multipart=None):
+        headers = {}
+        data = None
+        if json:
+            headers['Content-Type'] = 'application/json'
+            data = utils.to_json(json)
+
+        for tries in range(5):
+            r = self.session.request(verb, url, headers=headers, data=data, files=multipart)
+            r.encoding = 'utf-8'
+            data = r.text
+
+            # compatibility with aiohttp
+            r.status = r.status_code
+
+            if r.headers['Content-Type'] == 'application/json':
+                data = json.loads(data)
+
+            # check if we have rate limit header information
+            remaining = r.headers.get('X-Ratelimit-Remaining')
+            if remaining == '0' and r.status != 429 and self.sleep:
+                delta = utils._parse_ratelimit_header(r)
+                time.sleep(delta)
+
+            if 300 > r.status >= 200:
+                return data
+
+            # we are being rate limited
+            if r.status == 429:
+                if self.sleep:
+                    retry_after = data['retry_after'] / 1000.0
+                    time.sleep(retry_after)
+                    continue
+                else:
+                    raise HTTPException(r, data)
+
+            if self.sleep and r.status in (500, 502):
+                time.sleep(1 + tries * 2)
+                continue
+
+            if r.status == 403:
+                raise Forbidden(r, data)
+            elif r.status == 404:
+                raise NotFound(r, data)
+            else:
+                raise HTTPException(r, data)
+
+    def handle_execution_response(self, response, *, wait):
+        if not wait:
+            return response
+
+        # transform into Message object
+        from .message import Message
+        return Message(data=response, state=self, channel=self.webhook.channel)
+
+class Webhook:
+    """Represents a Discord webhook.
+
+    Webhooks are a form to send messages to channels in Discord without a
+    bot user or authentication.
+
+    There are two main ways to use Webhooks. The first is through the ones
+    received by the library such as :meth:`.Guild.webhooks` and
+    :meth:`.TextChannel.webhooks`. The ones received by the library will
+    automatically have an adapter bound using the library's HTTP session.
+    Those webhooks will have :meth:`~.Webhook.send`, :meth:`~.Webhook.delete` and
+    :meth:`~.Webhook.edit` as coroutines.
+
+    The second form involves creating a webhook object manually without having
+    it bound to a websocket connection using the :meth:`~.Webhook.from_url` or
+    :meth:`~.Webhook.partial` classmethods. This form allows finer grained control
+    over how requests are done, allowing you to mix async and sync code using either
+    ``aiohttp`` or ``requests``.
+
+    For example, creating a webhook from a URL and using ``aiohttp``:
+
+    .. code-block:: python3
+
+        from discord import Webhook, AsyncWebhookAdapter
+        import aiohttp
+
+        async def foo():
+            async with aiohttp.ClientSession() as session:
+                webhook = Webhook.from_url('url-here', adapter=AsyncWebhookAdapter(session))
+                await webhook.send('Hello World', username='Foo')
+
+    Or creating a webhook from an ID and token and using ``requests``:
+
+    .. code-block:: python3
+
+        import requests
+        from discord import Webhook, RequestsWebhookAdapter
+
+        webhook = Webhook.partial(123456, 'abcdefg', adapter=RequestsWebhookAdapter())
+        webhook.send('Hello World', username='Foo')
+
+    Attributes
+    ------------
+    id: int
+        The webhook's ID
+    token: str
+        The authentication token of the webhook.
+    guild_id: Optional[int]
+        The guild ID this webhook is for.
+    channel_id: Optional[int]
+        The channel ID this webhook is for.
+    user: Optional[:class:`abc.User`]
+        The user this webhook was created by. If the webhook was
+        received without authentication then this will be ``None``.
+    name: Optional[str]
+        The default name of the webhook.
+    avatar: Optional[str]
+        The default avatar of the webhook.
+    """
+
+    __slots__ = ('id', 'guild_id', 'channel_id', 'user', 'name', 'avatar',
+                 'token', '_state', '_adapter')
+
+    def __init__(self, data, *, adapter, state=None):
+        self.id = int(data['id'])
+        self.channel_id = utils._get_as_snowflake(data, 'channel_id')
+        self.guild_id = utils._get_as_snowflake(data, 'guild_id')
+        self.name = data.get('name')
+        self.avatar = data.get('avatar')
+        self.token = data['token']
+        self._state = state
+        self._adapter = adapter
+        self._adapter._prepare(self)
+
+        user = data.get('user')
+        if user is None:
+            self.user = None
+        elif state is None:
+            self.user = BaseUser(state=None, data=user)
+        else:
+            self.user = User(state=state, data=user)
+
+    def __repr__(self):
+        return '<Webhook id=%r>' % self.id
+
+    @classmethod
+    def partial(cls, id, token, *, adapter):
+        """Creates an partial :class:`Webhook`.
+
+        A partial webhook is just a webhook object with an ID and a token.
+
+        Parameters
+        -----------
+        id: int
+            The ID of the webhook.
+        token: str
+            The authentication token of the webhook.
+        adapter: :class:`WebhookAdapter`
+            The webhook adapter to use when sending requests. This is
+            typically :class:`AsyncWebhookAdapter` for ``aiohttp`` or
+            :class:`RequestsWebhookAdapter` for ``requests``.
+        """
+
+        if not isinstance(adapter, WebhookAdapter):
+            raise TypeError('adapter must be a subclass of WebhookAdapter')
+
+        data = {
+            'id': id,
+            'token': token
+        }
+
+        return cls(data, adapter=adapter)
+
+    @classmethod
+    def from_url(cls, url, *, adapter):
+        """Creates a partial :class:`Webhook` from a webhook URL.
+
+        Parameters
+        ------------
+        url: str
+            The URL of the webhook.
+        adapter: :class:`WebhookAdapter`
+            The webhook adapter to use when sending requests. This is
+            typically :class:`AsyncWebhookAdapter` for ``aiohttp`` or
+            :class:`RequestsWebhookAdapter` for ``requests``.
+
+        Raises
+        -------
+        InvalidArgument
+            The URL is invalid.
+        """
+
+        m = re.search(r'discordapp.com/api/webhooks/(?P<id>[0-9]{17,21})/(?P<token>[A-Za-z0-9\.]{60,68})', url)
+        if m is None:
+            raise InvalidArgument('Invalid webhook URL given.')
+        return cls(m.groupdict(), adapter=adapter)
+
+    @classmethod
+    def from_state(cls, data, state):
+        return cls(data, adapter=AsyncWebhookAdapter(session=state.http._session), state=state)
+
+    @property
+    def guild(self):
+        """Optional[:class:`Guild`]: The guild this webhook belongs to.
+
+        If this is an partial webhook, then this will always return ``None``.
+        """
+        return self._state and self._state.get_guild(self.guild_id)
+
+    @property
+    def channel(self):
+        """Optional[:class:`TextChannel`]: The text channel this webhook belongs to.
+
+        If this is an partial webhook, then this will always return ``None``.
+        """
+        guild = self.guild
+        return guild and guild.get_channel(self.channel_id)
+
+    @property
+    def created_at(self):
+        """Returns the webhook's creation time in UTC.
+
+        This is when the webhook's discord account was created."""
+        return utils.snowflake_time(self.id)
+
+    @property
+    def avatar_url(self):
+        """Returns a friendly URL version of the avatar the webhook has.
+
+        If the webhook does not have a traditional avatar, their default
+        avatar URL is returned instead.
+
+        This is equivalent to calling :meth:`avatar_url_as` with the
+        default parameters.
+        """
+        return self.avatar_url_as()
+
+    def avatar_url_as(self, *, format=None, size=1024):
+        """Returns a friendly URL version of the avatar the webhook has.
+
+        If the webhook does not have a traditional avatar, their default
+        avatar URL is returned instead.
+
+        The format must be one of 'jpeg', 'jpg', or 'png'.
+        The size must be a power of 2 between 16 and 1024.
+
+        Parameters
+        -----------
+        format: Optional[str]
+            The format to attempt to convert the avatar to.
+            If the format is ``None``, then it is equivalent to png.
+        size: int
+            The size of the image to display.
+
+        Returns
+        --------
+        str
+            The resulting CDN URL.
+
+        Raises
+        ------
+        InvalidArgument
+            Bad image format passed to ``format` or invalid ``size``.
+        """
+        if self.avatar is None:
+            # Default is always blurple apparently
+            return 'https://cdn.discordapp.com/embed/avatars/0.png'
+
+        if not utils.valid_icon_size(size):
+            raise InvalidArgument("size must be a power of 2 between 16 and 1024")
+
+        format = format or 'png'
+
+        if format not in ('png', 'jpg', 'jpeg'):
+            raise InvalidArgument("format must be one of 'png', 'jpg', or 'jpeg'.")
+
+        return 'https://cdn.discordapp.com/avatars/{0.id}/{0.avatar}.{1}?size={2}'.format(self, format, size)
+
+    def delete(self):
+        """|maybecoro|
+
+        Deletes this Webhook.
+
+        If the webhook is constructed with a `RequestsWebhookAdapter` then this is
+        not a coroutine.
+
+        Raises
+        -------
+        HTTPException
+            Deleting the webhook failed.
+        NotFound
+            This webhook does not exist.
+        Forbidden
+            You do not have permissions to delete this webhook.
+        """
+        return self._adapter.delete_webhook(self.id, self.token)
+
+    def edit(self, **kwargs):
+        """|maybecoro|
+
+        Edits this Webhook.
+
+        If the webhook is constructed with a `RequestsWebhookAdapter` then this is
+        not a coroutine.
+
+        Parameters
+        -------------
+        name: Optional[str]
+            The webhook's new default name.
+        avatar: Optional[bytes]
+            A *bytes-like* object representing the webhook's new default avatar.
+
+        Raises
+        -------
+        HTTPException
+            Editing the webhook failed.
+        NotFound
+            This webhook does not exist.
+        Forbidden
+            You do not have permissions to edit this webhook.
+        """
+        payload = {}
+
+        try:
+            name = kwargs['name']
+        except KeyError:
+            pass
+        else:
+            if name is not None:
+                payload['name'] = str(name)
+            else:
+                payload['name'] = None
+
+        try:
+            avatar = kwargs['avatar']
+        except KeyError:
+            pass
+        else:
+            if avatar is not None:
+                payload['avatar'] = utils._bytes_to_base64_data(avatar)
+            else:
+                payload['avatar'] = None
+
+        return self._adapter.edit_webhook(**payload)
+
+    def send(self, content=None, *, wait=False, username=None, avatar_url=None,
+                                    tts=False, file=None, embed=None, embeds=None):
+        """|coro|
+
+        Sends a message using the webhook.
+
+        The content must be a type that can convert to a string through ``str(content)``.
+
+        To upload a single file, the ``file`` parameter should be used with a
+        single :class:`File` object.
+
+        If the ``embed`` parameter is provided, it must be of type :class:`Embed` and
+        it must be a rich embed type. You cannot mix the ``embed`` parameter with the
+        ``embeds`` parameter, which must be a list of :class:`Embed` objects to send.
+
+        Parameters
+        ------------
+        content
+            The content of the message to send.
+        tts: bool
+            Indicates if the message should be sent using text-to-speech.
+        embed: :class:`Embed`
+            The rich embed for the content to send. This cannot be mixed with
+            ``embeds`` parameter.
+        embeds: List[:class:`Embed`]
+            A list of embeds to send with the content. Maximum of 10. This cannot
+            be mixed with the ``embed`` parameter.
+        file: :class:`File`
+            The file to upload.
+        username: str
+            The username to send with this message. If no username is provided
+            then the default username for the webhook is used.
+        avatar_url: str
+            The avatar URL to send with this message. If no avatar URL is provided
+            then the default avatar for the webhook is used.
+        wait: 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``.
+
+        Raises
+        --------
+        HTTPException
+            Sending the message failed.
+        NotFound
+            This webhook was not found.
+        Forbidden
+            The authorization token for the webhook is incorrect.
+        InvalidArgument
+            You specified both ``file`` and ``files`` or the length of
+            ``files`` was invalid.
+
+        Returns
+        ---------
+        Optional[:class:`Message`]
+            The message that was sent.
+        """
+
+        payload = {}
+
+        if embeds is not None and embed is not None:
+            raise InvalidArgument('Cannot mix embed and embeds keyword arguments.')
+
+        if embeds is not None:
+            if len(embeds) > 10:
+                raise InvalidArgument('embeds has a maximum of 10 elements.')
+            payload['embeds'] = [e.to_dict() for e in embeds]
+
+        if embed is not None:
+            payload['embeds'] = [embed.to_dict()]
+
+        if content is not None:
+            payload['content'] = str(content)
+
+        payload['tts'] = tts
+        if avatar_url:
+            payload['avatar_url'] = avatar_url
+        if username:
+            payload['username'] = username
+
+        if file is not None:
+            try:
+                to_pass = (file.open_file(), file.filename, 'application/octet-stream')
+                return self._adapter.execute_webhook(wait=wait, file=to_pass, json=payload)
+            finally:
+                file.close()
+        else:
+            return self._adapter.execute_webhook(wait=wait, json=payload)
+
+    execute = send
+    execute.__doc__ = """An alias for :meth:`.~Webhook.send`."""
diff --git a/docs/api.rst b/docs/api.rst
index c0816af0f..cd163d801 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -1692,6 +1692,29 @@ this goal, it must make use of a couple of data classes that aid in this goal.
 .. this is currently missing the following keys: reason and application_id
    I'm not sure how to about porting these
 
+Webhook Support
+------------------
+
+discord.py offers support for creating, editing, and executing webhooks through the :class:`Webhook` class.
+
+.. autoclass:: Webhook
+    :members:
+
+Adapters
+~~~~~~~~~
+
+Adapters allow you to change how the request should be handled. They all build on a single
+interface, :meth:`WebhookAdapter.request`.
+
+.. autoclass:: WebhookAdapter
+    :members:
+
+.. autoclass:: AsyncWebhookAdapter
+    :members:
+
+.. autoclass:: RequestsWebhookAdapter
+    :members:
+
 .. _discord_api_abcs:
 
 Abstract Base Classes
@@ -1986,7 +2009,6 @@ PermissionOverwrite
 .. autoclass:: PermissionOverwrite
     :members:
 
-
 Exceptions
 ------------
 
diff --git a/docs/conf.py b/docs/conf.py
index c29c8e94d..caaa06879 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -50,6 +50,7 @@ extlinks = {
 
 rst_prolog = """
 .. |coro| replace:: This function is a |corourl|_.
+.. |maybecoro| replace:: This function *could be a* |corourl|_.
 .. |corourl| replace:: *coroutine*
 .. _corourl: https://docs.python.org/3/library/asyncio-task.html#coroutine
 """