Add webhook support.
Allows for usage of either `requests` and `aiohttp` when used in "Standalone" mode. Fixes #704
This commit is contained in:
parent
deaba1f5ab
commit
37b0fdb898
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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|
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
651
discord/webhook.py
Normal file
651
discord/webhook.py
Normal file
@ -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`."""
|
24
docs/api.rst
24
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
|
||||
------------
|
||||
|
||||
|
@ -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
|
||||
"""
|
||||
|
Loading…
x
Reference in New Issue
Block a user