From 22c6bf1bf36e93be1f86a6dbe53cf75612254dfc Mon Sep 17 00:00:00 2001 From: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> Date: Fri, 9 May 2025 18:23:27 +0200 Subject: [PATCH 01/21] Update requirements.txt Signed-off-by: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 47b03688..7f462f48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ attrs multidict idna color-pprint>=0.0.3 -colorama \ No newline at end of file +colorama +pydub From 167ae8ce1836f451284468a0effe82bc3918e14f Mon Sep 17 00:00:00 2001 From: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> Date: Fri, 9 May 2025 18:26:31 +0200 Subject: [PATCH 02/21] Implement Soundboard Signed-off-by: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> --- discord/client.py | 6759 +++++++++++++++++------------------ discord/gateway.py | 1955 ++++++----- discord/guild.py | 7790 +++++++++++++++++++++-------------------- discord/http.py | 3917 +++++++++++---------- discord/soundboard.py | 213 ++ 5 files changed, 10591 insertions(+), 10043 deletions(-) create mode 100644 discord/soundboard.py diff --git a/discord/client.py b/discord/client.py index b1fbcdfa..4b9fccea 100644 --- a/discord/client.py +++ b/discord/client.py @@ -1,3359 +1,3400 @@ -# -*- coding: utf-8 -*- - -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz & (c) 2021-present mccoderpy - -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. -""" -from __future__ import annotations - -import aiohttp -import asyncio -import copy -import inspect -import logging -import signal -import sys -import re -import traceback -import warnings - -from typing import ( - Any, - Dict, - List, - Union, - Tuple, - AnyStr, - TypeVar, - Iterator, - Optional, - Callable, - Awaitable, - Coroutine, - TYPE_CHECKING -) - -from typing_extensions import Literal - -from .application_commands import ActivityEntryPointCommand -from .auto_updater import AutoUpdateChecker -from .sticker import StickerPack -from .user import ClientUser, User -from .invite import Invite -from .template import Template -from .widget import Widget -from .guild import Guild -from .channel import _channel_factory, PartialMessageable -from .enums import ChannelType, ApplicationCommandType, Locale, InteractionContextType, AppIntegrationType, \ - EntryPointHandlerType -from .mentions import AllowedMentions -from .monetization import Entitlement, SKU -from .errors import * -from .enums import Status, VoiceRegion -from .gateway import * -from .activity import BaseActivity, create_activity -from .voice_client import VoiceRegionInfo, VoiceClient -from .http import HTTPClient -from .state import ConnectionState -from . import utils -from .object import Object -from .backoff import ExponentialBackoff -from .webhook import Webhook -from .iterators import GuildIterator, EntitlementIterator -from .appinfo import AppInfo -from .application_commands import * - -if TYPE_CHECKING: - import datetime - from re import Pattern - - from .abc import ( - GuildChannel, - Messageable, - PrivateChannel, - VoiceProtocol, - Snowflake - ) - from .components import Button, Select - from .emoji import Emoji - from .flags import Intents - from .interactions import ApplicationCommandInteraction, ComponentInteraction, ModalSubmitInteraction - from .member import Member - from .message import Message - from .permissions import Permissions - from .sticker import Sticker - - _ClickCallback = Callable[[ComponentInteraction, Button], Coroutine[Any, Any, Any]] - _SelectCallback = Callable[[ComponentInteraction, Select], Coroutine[Any, Any, Any]] - _SubmitCallback = Callable[[ModalSubmitInteraction], Coroutine[Any, Any, Any]] - - -T = TypeVar('T') -Coro = TypeVar('Coro', bound=Callable[..., Coroutine[Any, Any, Any]]) - -log = logging.getLogger(__name__) -MISSING = utils.MISSING - -__all__ = ( - 'Client', -) - - -def _cancel_tasks(loop): - try: - task_retriever = asyncio.Task.all_tasks - except AttributeError: - # future proofing for 3.9 I guess - task_retriever = asyncio.all_tasks - - tasks = {t for t in task_retriever(loop=loop) if not t.done()} - - if not tasks: - return - - log.info('Cleaning up after %d tasks.', len(tasks)) - for task in tasks: - task.cancel() - - loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) - log.info('All tasks finished cancelling.') - - for task in tasks: - if task.cancelled(): - continue - if task.exception() is not None: - loop.call_exception_handler({ - 'message': 'Unhandled exception during Client.run shutdown.', - 'exception': task.exception(), - 'task': task - }) - - -def _cleanup_loop(loop): - try: - _cancel_tasks(loop) - if sys.version_info >= (3, 6): - loop.run_until_complete(loop.shutdown_asyncgens()) - finally: - log.info('Closing the event loop.') - loop.close() - - -class _ClientEventTask(asyncio.Task): - def __init__(self, original_coro, event_name, coro, *, loop): - super().__init__(coro, loop=loop) - self.__event_name = event_name - self.__original_coro = original_coro - - def __repr__(self): - info = [ - ('state', self._state.lower()), - ('event', self.__event_name), - ('coro', repr(self.__original_coro)), - ] - if self._exception is not None: - info.append(('exception', repr(self._exception))) - return ''.format(' '.join('%s=%s' % t for t in info)) - - -class Client: - r"""Represents a client connection that connects to Discord. - This class is used to interact with the Discord WebSocket and API. - - A number of options can be passed to the :class:`Client`. - - Parameters - ----------- - max_messages: Optional[:class:`int`] - The maximum number of messages to store in the internal message cache. - This defaults to ``1000``. Passing in ``None`` disables the message cache. - - .. versionchanged:: 1.3 - Allow disabling the message cache and change the default size to ``1000``. - loop: Optional[:class:`asyncio.AbstractEventLoop`] - The :class:`asyncio.AbstractEventLoop` to use for asynchronous operations. - Defaults to ``None``, in which case the default event loop is used via - :func:`asyncio.get_event_loop()`. - connector: :class:`aiohttp.BaseConnector` - The connector to use for connection pooling. - proxy: Optional[:class:`str`] - Proxy URL. - proxy_auth: Optional[:class:`aiohttp.BasicAuth`] - An object that represents proxy HTTP Basic Authorization. - shard_id: Optional[:class:`int`] - Integer starting at ``0`` and less than :attr:`.shard_count`. - shard_count: Optional[:class:`int`] - The total number of shards. - intents: :class:`Intents` - The intents that you want to enable for the _session. This is a way of - disabling and enabling certain gateway events from triggering and being sent. - If not given, defaults to a regularly constructed :class:`Intents` class. - gateway_version: :class:`int` - The gateway and api version to use. Defaults to ``v10``. - api_error_locale: :class:`discord.Locale` - The locale language to use for api errors. This will be applied to the ``X-Discord-Local`` header in requests. - Default to :attr:`Locale.en_US` - member_cache_flags: :class:`MemberCacheFlags` - Allows for finer control over how the library caches members. - If not given, defaults to cache as much as possible with the - currently selected intents. - fetch_offline_members: :class:`bool` - A deprecated alias of ``chunk_guilds_at_startup``. - chunk_guilds_at_startup: :class:`bool` - Indicates if :func:`.on_ready` should be delayed to chunk all guilds - at start-up if necessary. This operation is incredibly slow for large - amounts of guilds. The default is ``True`` if :attr:`Intents.members` - is ``True``. - status: Optional[:class:`.Status`] - A status to start your presence with upon logging on to Discord. - activity: Optional[:class:`.BaseActivity`] - An activity to start your presence with upon logging on to Discord. - allowed_mentions: Optional[:class:`AllowedMentions`] - Control how the client handles mentions by default on every message sent. - heartbeat_timeout: :class:`float` - The maximum numbers of seconds before timing out and restarting the - WebSocket in the case of not receiving a HEARTBEAT_ACK. Useful if - processing the initial packets take too long to the point of disconnecting - you. The default timeout is 60 seconds. - guild_ready_timeout: :class:`float` - The maximum number of seconds to wait for the GUILD_CREATE stream to end before - preparing the member cache and firing READY. The default timeout is 2 seconds. - - .. versionadded:: 1.4 - guild_subscriptions: :class:`bool` - Whether to dispatch presence or typing events. Defaults to :obj:`True`. - - .. versionadded:: 1.3 - - .. warning:: - - If this is set to :obj:`False` then the following features will be disabled: - - - No user related updates (:func:`on_user_update` will not dispatch) - - All member related events will be disabled. - - :func:`on_member_update` - - :func:`on_member_join` - - :func:`on_member_remove` - - - Typing events will be disabled (:func:`on_typing`). - - If ``fetch_offline_members`` is set to ``False`` then the user cache will not exist. - This makes it difficult or impossible to do many things, for example: - - - Computing permissions - - Querying members in a voice channel via :attr:`VoiceChannel.members` will be empty. - - Most forms of receiving :class:`Member` will be - receiving :class:`User` instead, except for message events. - - :attr:`Guild.owner` will usually resolve to ``None``. - - :meth:`Guild.get_member` will usually be unavailable. - - Anything that involves using :class:`Member`. - - :attr:`users` will not be as populated. - - etc. - - In short, this makes it so the only member you can reliably query is the - message author. Useful for bots that do not require any state. - assume_unsync_clock: :class:`bool` - Whether to assume the system clock is unsynced. This applies to the ratelimit handling - code. If this is set to ``True``, the default, then the library uses the time to reset - a rate limit bucket given by Discord. If this is ``False`` then your system clock is - used to calculate how long to sleep for. If this is set to ``False`` it is recommended to - sync your system clock to Google's NTP server. - - .. versionadded:: 1.3 - - sync_commands: :class:`bool` - Whether to sync application-commands on startup, default :obj:`False`. - - This will register global and guild application-commands(slash-, user- and message-commands) - that are not registered yet, update changes and remove application-commands that could not be found - in the code anymore if :attr:`delete_not_existing_commands` is set to :obj:`True` what it is by default. - - delete_not_existing_commands: :class:`bool` - Whether to remove global and guild-only application-commands that are not in the code anymore, default :obj:`True`. - - auto_check_for_updates: :class:`bool` - Whether to check for available updates automatically, default :obj:`False` for legal reasons. - For more info see :func:`discord.on_update_available`. - - .. note:: - - For now, this may only work on the original repository, **not on forks**. - This is because it uses an internal API that listens to a private application that is on the original repo. - - In the future this API might be open-sourced, or it will be possible to add your forks URL as a valid source. - - Attributes - ----------- - ws - The websocket gateway the client is currently connected to. Could be ``None``. - loop: :class:`asyncio.AbstractEventLoop` - The event loop that the client uses for HTTP requests and websocket operations. - """ - def __init__(self, *, loop: Optional[asyncio.AbstractEventLoop] = None, **options): - self.ws: DiscordWebSocket = None - self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() if loop is None else loop - self._listeners = {} - self.sync_commands: bool = options.get('sync_commands', False) - self.delete_not_existing_commands: bool = options.get('delete_not_existing_commands', True) - self._application_commands_by_type: Dict[ - str, - Dict[str, Union[SlashCommand, UserCommand, MessageCommand, ActivityEntryPointCommand]] - ] = { - 'chat_input': {}, 'message': {}, 'user': {}, 'primary_entry_point': {} - } - self._guild_specific_application_commands: Dict[ - int, Dict[str, Dict[str, Union[SlashCommand, UserCommand, MessageCommand]]]] = {} - self._application_commands: Dict[int, ApplicationCommand] = {} - self.shard_id = options.get('shard_id') - self.shard_count = options.get('shard_count') - - connector = options.pop('connector', None) - proxy = options.pop('proxy', None) - proxy_auth = options.pop('proxy_auth', None) - unsync_clock = options.pop('assume_unsync_clock', True) - self.gateway_version: int = options.get('gateway_version', 10) - self.api_error_locale: Locale = options.pop('api_error_locale', 'en-US') - self.auto_check_for_updates: bool = options.pop('auto_check_for_updates', False) - self.http = HTTPClient( - connector, - proxy=proxy, - proxy_auth=proxy_auth, - unsync_clock=unsync_clock, - loop=self.loop, - api_version=self.gateway_version, - api_error_locale=self.api_error_locale - ) - - self._handlers = { - 'ready': self._handle_ready, - 'connect': lambda: self._ws_connected.set(), - 'resumed': lambda: self._ws_connected.set() - } - - self._hooks = { - 'before_identify': self._call_before_identify_hook - } - - self._connection = self._get_state(**options) - self._connection.shard_count = self.shard_count - self._closed = False - self._ready = asyncio.Event() - self._ws_connected = asyncio.Event() - self._connection._get_websocket = self._get_websocket - self._connection._get_client = lambda: self - - if VoiceClient.warn_nacl: - VoiceClient.warn_nacl = False - log.warning("PyNaCl is not installed, voice will NOT be supported") - if self.auto_check_for_updates: - self._auto_update_checker: Optional[AutoUpdateChecker] = AutoUpdateChecker(client=self) - else: - self._auto_update_checker: Optional[AutoUpdateChecker] = None - - # internals - def _get_websocket(self, guild_id=None, *, shard_id=None): - return self.ws - - def _get_state(self, **options): - return ConnectionState(dispatch=self.dispatch, handlers=self._handlers, - hooks=self._hooks, syncer=self._syncer, http=self.http, loop=self.loop, **options) - - async def _syncer(self, guilds): - await self.ws.request_sync(guilds) - - def _handle_ready(self): - self._ready.set() - - @property - def latency(self) -> float: - """:class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. - - This could be referred to as the Discord WebSocket protocol latency. - """ - ws = self.ws - return float('nan') if not ws else ws.latency - - def is_ws_ratelimited(self) -> bool: - """: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) -> ClientUser: - """Optional[:class:`.ClientUser`]: Represents the connected client. ``None`` if not logged in.""" - return self._connection.user - - @property - def guilds(self) -> List[Guild]: - """List[:class:`.Guild`]: The guilds that the connected client is a member of.""" - return self._connection.guilds - - @property - def emojis(self) -> List[Emoji]: - """List[:class:`.Emoji`]: The emojis that the connected client has.""" - return self._connection.emojis - - @property - def stickers(self) -> List[Sticker]: - """List[:class:`.Sticker`]: The stickers that the connected client has.""" - return self._connection.stickers - - @property - def cached_messages(self) -> utils.SequenceProxy[Message]: - """Sequence[:class:`.Message`]: Read-only list of messages the connected client has cached. - - .. versionadded:: 1.1 - """ - return utils.SequenceProxy(self._connection._messages or []) - - @property - def private_channels(self) -> List[PrivateChannel]: - """List[:class:`.abc.PrivateChannel`]: The private channels that the connected client is participating on. - - .. note:: - - This returns only up to 128 most recent private channels due to an internal working - on how Discord deals with private channels. - """ - return self._connection.private_channels - - @property - def voice_clients(self) -> List[VoiceProtocol]: - """List[:class:`.VoiceProtocol`]: Represents a list of voice connections. - - These are usually :class:`.VoiceClient` instances. - """ - return self._connection.voice_clients - - def is_ready(self) -> bool: - """:class:`bool`: Specifies if the client's internal cache is ready for use.""" - return self._ready.is_set() - - async def _run_event(self, coro: Coro, event_name: str, *args, **kwargs): - try: - await coro(*args, **kwargs) - except asyncio.CancelledError: - pass - except Exception: - try: - await self.on_error(event_name, *args, **kwargs) - except asyncio.CancelledError: - pass - - def _schedule_event(self, coro: Coro, event_name: str, *args, **kwargs) -> _ClientEventTask: - wrapped = self._run_event(coro, event_name, *args, **kwargs) - # Schedules the task - return _ClientEventTask(original_coro=coro, event_name=event_name, coro=wrapped, loop=self.loop) - - def dispatch(self, event: str, *args, **kwargs) -> None: - log.debug('Dispatching event %s', event) - method = 'on_' + event - - listeners = self._listeners.get(event) - if listeners: - removed = [] - for i, (future, condition) in enumerate(listeners): - if isinstance(future, asyncio.Future): - if future.cancelled(): - removed.append(i) - continue - - try: - result = condition(*args) - except Exception as exc: - future.set_exception(exc) - removed.append(i) - else: - if result: - if len(args) == 0: - future.set_result(None) - elif len(args) == 1: - future.set_result(args[0]) - else: - future.set_result(args) - removed.append(i) - - if len(removed) == len(listeners): - self._listeners.pop(event) - else: - for idx in reversed(removed): - del listeners[idx] - else: - result = condition(*args) - if result: - self._schedule_event(future, method, *args, **kwargs) - - try: - coro = getattr(self, method) - except AttributeError: - pass - else: - self._schedule_event(coro, method, *args, **kwargs) - - async def on_error(self, event_method: str, *args, **kwargs) -> None: - """|coro| - - The default error handler provided by the client. - - By default, this prints to :data:`sys.stderr` however it could be - overridden to have a different implementation. - Check :func:`~discord.on_error` for more details. - """ - print('Ignoring exception in {}'.format(event_method), file=sys.stderr) - traceback.print_exc() - - async def on_application_command_error( - self, - cmd: ApplicationCommand, - interaction: ApplicationCommandInteraction, - exception: BaseException - ) -> None: - """|coro| - - The default error handler when an Exception was raised when invoking an application-command. - - By default, this prints to :data:`sys.stderr` however it could be - overridden to have a different implementation. - Check :func:`~discord.on_application_command_error` for more details. - """ - if hasattr(cmd, 'on_error'): - return - if isinstance(cmd, (SlashCommand, SubCommand)): - name = cmd.qualified_name - else: - name = cmd.name - print('Ignoring exception in {type} command "{name}" ({id})'.format( - type=str(interaction.command.type).upper(), - name=name, - id=interaction.command.id - ), - file=sys.stderr - ) - traceback.print_exception(type(exception), exception, exception.__traceback__, file=sys.stderr) - - async def _request_sync_commands(self, is_cog_reload: bool = False, *, reload_failed: bool = False) -> None: - """Used to sync commands if the ``GUILD_CREATE`` stream is over or a :class:`~discord.ext.commands.Cog` was reloaded. - - .. warning:: - **DO NOT OVERWRITE THIS METHOD!!! - IF YOU DO SO, THE APPLICATION-COMMANDS WILL NOT BE SYNCED AND NO COMMAND REGISTERED WILL BE DISPATCHED.** - """ - if not hasattr(self, 'app'): - await self.application_info() - if (is_cog_reload and not reload_failed and getattr(self, 'sync_commands_on_cog_reload', False) is True) or ( - not is_cog_reload and self.sync_commands is True - ): - return await self._sync_commands() - state = self._connection # Speedup attribute access - if not is_cog_reload: - app_id = self.app.id - log.info('Collecting global application-commands for application %s (%s)', self.app.name, self.app.id) - - self._minimal_registered_global_commands_raw = minimal_registered_global_commands_raw = [] - get_commands = self.http.get_application_commands - global_registered_raw = await get_commands(app_id) - - for raw_command in global_registered_raw: - command_type = str(ApplicationCommandType.try_value(raw_command['type'])) - minimal_registered_global_commands_raw.append({'id': int(raw_command['id']), 'type': command_type, 'name': raw_command['name']}) - try: - command = self._application_commands_by_type[command_type][raw_command['name']] - except KeyError: - command = ApplicationCommand._from_type(state, data=raw_command) - command.func = None - self._application_commands[command.id] = self._application_commands_by_type[command_type][command.name] = command - else: - command._fill_data(raw_command) - command._state = state - self._application_commands[command.id] = command - - log.info( - 'Done! Cached %s global application-commands', - sum([len(cmds) for cmds in self._application_commands_by_type.values()]) - ) - log.info('Collecting guild-specific application-commands for application %s (%s)', self.app.name, app_id) - - self._minimal_registered_guild_commands_raw = minimal_registered_guild_commands_raw = {} - - for guild in self.guilds: - try: - registered_guild_commands_raw = await get_commands(app_id, guild_id=guild.id) - except Forbidden: - log.info( - 'Missing access to guild %s (%s) or don\'t have the application.commands scope in there, ' - 'skipping!' % (guild.name, guild.id)) - continue - except HTTPException: - raise - if registered_guild_commands_raw: - minimal_registered_guild_commands_raw[guild.id] = minimal_registered_guild_commands = [] - try: - guild_commands = self._guild_specific_application_commands[guild.id] - except KeyError: - self._guild_specific_application_commands[guild.id] = guild_commands = { - 'chat_input': {}, 'user': {}, 'message': {} - } - for raw_command in registered_guild_commands_raw: - command_type = str(ApplicationCommandType.try_value(raw_command['type'])) - minimal_registered_guild_commands.append( - {'id': int(raw_command['id']), 'type': command_type, 'name': raw_command['name']} - ) - try: - command = guild_commands[command_type][raw_command['name']] - except KeyError: - command = ApplicationCommand._from_type(state, data=raw_command) - command.func = None - self._application_commands[command.id] = guild._application_commands[command.id] \ - = guild_commands[command_type][command.name] = command - else: - command._fill_data(raw_command) - command._state = state - self._application_commands[command.id] = guild._application_commands[command.id] = command - - log.info( - 'Done! Cached %s commands for %s guilds', - sum([ - len(commands) for commands in list(minimal_registered_guild_commands_raw.values()) - ]), - len(minimal_registered_guild_commands_raw.keys()) - ) - - else: - # re-assign metadata to the commands (for commands added from cogs) - log.info('Re-assigning metadata to commands') - # For logging purposes - no_longer_in_code_global = 0 - no_longer_in_code_guild_specific = 0 - no_longer_in_code_guilds = set() - - for raw_command in self._minimal_registered_global_commands_raw: - command_type = raw_command['type'] - try: - command = self._application_commands_by_type[command_type][raw_command['name']] - except KeyError: - no_longer_in_code_global += 1 - self._application_commands[raw_command['id']].func = None - continue # Should already be cached in self._application_commands so skip that part here - else: - if command.disabled: - no_longer_in_code_global += 1 - else: - command._fill_data(raw_command) - command._state = state - self._application_commands[command.id] = command - for guild_id, raw_commands in self._minimal_registered_guild_commands_raw.items(): - try: - guild_commands = self._guild_specific_application_commands[guild_id] - except KeyError: - no_longer_in_code_guilds.add(guild_id) - no_longer_in_code_guild_specific += len(raw_commands) - continue # Should already be cached in self._application_commands so skip that part here again - else: - guild = self.get_guild(guild_id) - for raw_command in raw_commands: - command_type = raw_command['type'] - try: - command = guild_commands[command_type][raw_command['name']] - except KeyError: - if guild_id not in no_longer_in_code_guilds: - no_longer_in_code_guilds.add(guild_id) - no_longer_in_code_guild_specific += 1 - self._application_commands[raw_command['id']].func = None - pass # Should already be cached in self._application_commands so skip that part here another once again - else: - if command.disabled: - no_longer_in_code_guild_specific += 1 - else: - command._fill_data(raw_command) - command._state = state - self._application_commands[command.id] = guild._application_commands[command.id] = command - log.info('Done!') - if no_longer_in_code_global: - log.warning( - '%s global application-commands where removed from code but are still registered in discord', - no_longer_in_code_global - ) - if no_longer_in_code_guild_specific: - log.warning( - 'In total %s guild-specific application-commands from %s guild(s) where removed from code ' - 'but are still registered in discord', no_longer_in_code_guild_specific, - len(no_longer_in_code_guilds) - ) - if no_longer_in_code_global or no_longer_in_code_guild_specific: - log.warning( - 'To prevent the above, set `sync_commands_on_cog_reload` of %s to True', - self.__class__.__name__ - ) - - @utils.deprecated('Guild.chunk') - async def request_offline_members(self, *guilds): - r"""|coro| - - Requests previously offline members from the guild to be filled up - into the :attr:`.Guild.members` cache. This function is usually not - called. It should only be used if you have the ``fetch_offline_members`` - parameter set to ``False``. - - When the client logs on and connects to the websocket, Discord does - not provide the library with offline members if the number of members - in the guild is larger than 250. You can check if a guild is large - if :attr:`.Guild.large` is ``True``. - - .. warning:: - - This method is deprecated. Use :meth:`Guild.chunk` instead. - - Parameters - ----------- - \*guilds: :class:`.Guild` - An argument list of guilds to request offline members for. - - Raises - ------- - :exc:`.InvalidArgument` - If any guild is unavailable in the collection. - """ - if any(g.unavailable for g in guilds): - raise InvalidArgument('An unavailable guild was passed.') - - for guild in guilds: - await self._connection.chunk_guild(guild) - - # hooks - - async def _call_before_identify_hook(self, shard_id, *, initial=False): - # This hook is an internal hook that actually calls the public one. - # It allows the library to have its own hook without stepping on the - # toes of those who need to override their own hook. - await self.before_identify_hook(shard_id, initial=initial) - - async def before_identify_hook(self, shard_id: int, *, initial: bool = False): - """|coro| - - A hook that is called before IDENTIFYing a _session. This is useful - if you wish to have more control over the synchronization of multiple - IDENTIFYing clients. - - The default implementation sleeps for 5 seconds. - - .. versionadded:: 1.4 - - Parameters - ------------ - shard_id: :class:`int` - The shard ID that requested being IDENTIFY'd - initial: :class:`bool` - Whether this IDENTIFY is the first initial IDENTIFY. - """ - - if not initial: - await asyncio.sleep(5.0) - - # login state management - - async def login(self, token: str) -> None: - """|coro| - - Logs in the client with the specified credentials. - - This function can be used in two different ways. - - - Parameters - ----------- - token: :class:`str` - The authentication token. Do not prefix this token with - anything as the library will do it for you. - - Raises - ------ - :exc:`.LoginFailure` - The wrong credentials are passed. - :exc:`.HTTPException` - An unknown HTTP related error occurred, - usually when it isn't 200 or the known incorrect credentials - passing status code. - """ - - log.info('logging in using static token') - await self.http.static_login(token.strip()) - - @utils.deprecated('Client.close') - async def logout(self): - """|coro| - - Logs out of Discord and closes all connections. - - .. deprecated:: 1.7 - - .. note:: - - This is just an alias to :meth:`close`. If you want - to do extraneous cleanup when subclassing, it is suggested - to override :meth:`close` instead. - """ - await self.close() - - async def connect(self, *, reconnect: bool = True) -> None: - """|coro| - - Creates a websocket connection and lets the websocket listen - to messages from Discord. This is a loop that runs the entire - event system and miscellaneous aspects of the library. Control - is not resumed until the WebSocket connection is terminated. - - Parameters - ----------- - reconnect: :class:`bool` - If we should attempt reconnecting, either due to internet - failure or a specific failure on Discord's part. Certain - disconnects that lead to bad state will not be handled (such as - invalid sharding payloads or bad tokens). - - Raises - ------- - :exc:`.GatewayNotFound` - If the gateway to connect to Discord is not found. Usually if this - is thrown then there is a Discord API outage. - :exc:`.ConnectionClosed` - The websocket connection has been terminated. - """ - - backoff = ExponentialBackoff() - ws_params = { - 'initial': True, - 'shard_id': self.shard_id, - } - if self.auto_check_for_updates: - self._auto_update_checker.start() - while not self.is_closed(): - try: - coro = DiscordWebSocket.from_client(self, **ws_params) - self.ws = await asyncio.wait_for(coro, timeout=60.0) - ws_params['initial'] = False - while True: - await self.ws.poll_event() - except ReconnectWebSocket as e: - log.info('Got a request to %s the websocket.', e.op) - self._ws_connected.clear() - self.dispatch('disconnect') - ws_params.update( - sequence=self.ws.sequence, - resume=e.resume, - session=self.ws.session_id, - resume_gateway_url=self.ws.resume_gateway_url if e.resume else None - ) - continue - except (OSError, - HTTPException, - GatewayNotFound, - ConnectionClosed, - aiohttp.ClientError, - asyncio.TimeoutError) as exc: - self._ws_connected.clear() - self.dispatch('disconnect') - if not reconnect: - await self.close() - if isinstance(exc, ConnectionClosed) and exc.code == 1000: - # clean close, don't re-raise this - return - raise - - if self.is_closed(): - return - - # If we get connection reset by peer then try to RESUME - if isinstance(exc, OSError) and exc.errno in (54, 10054): - ws_params.update( - sequence=self.ws.sequence, - initial=False, - resume=True, - session=self.ws.session_id, - resume_gateway_url=self.ws.resume_gateway_url - ) - continue - - # We should only get this when an unhandled close code happens, - # such as a clean disconnect (1000) or a bad state (bad token, no sharding, etc) - # sometimes, discord sends us 1000 for unknown reasons, so we should reconnect - # regardless and rely on is_closed instead - if isinstance(exc, ConnectionClosed): - if exc.code == 4014: - if self.shard_count and self.shard_count > 0: - raise PrivilegedIntentsRequired(exc.shard_id) - else: - sys.stderr.write(str(PrivilegedIntentsRequired(exc.shard_id))) - if exc.code != 1000: - await self.close() - if not exc.code == 4014: - raise - - retry = backoff.delay() - log.exception("Attempting a reconnect in %.2fs", retry) - await asyncio.sleep(retry) - # Always try to RESUME the connection - # If the connection is not RESUME-able then the gateway will invalidate the _session. - # This is apparently what the official Discord client does. - ws_params.update( - sequence=self.ws.sequence, - resume=True, - session=self.ws.session_id, - resume_gateway_url=self.ws.resume_gateway_url - ) - - async def close(self) -> None: - """|coro| - - Closes the connection to Discord. - """ - if self._closed: - return - - for voice in self.voice_clients: - try: - await voice.disconnect() # type: ignore - except Exception: - # if an error happens during disconnects, disregard it. - pass - - await self.http.close() - if self._auto_update_checker: - await self._auto_update_checker.close() - self._closed = True - - if self.ws is not None and self.ws.open: - await self.ws.close(code=1000) - - self._ws_connected.clear() - self._ready.clear() - - def clear(self) -> None: - """Clears the internal state of the bot. - - After this, the bot can be considered "re-opened", i.e. :meth:`is_closed` - and :meth:`is_ready` both return ``False`` along with the bot's internal - cache cleared. - """ - self._closed = False - self._ready.clear() - self._ws_connected.clear() - self._connection.clear() - self.http.recreate() - - async def start(self, token: str, reconnect: bool = True) -> None: - """|coro| - - A shorthand coroutine for :meth:`login` + :meth:`connect`. - - Raises - ------- - TypeError - An unexpected keyword argument was received. - """ - await self.login(token) - await self.connect(reconnect=reconnect) - - def run( - self, - token: str, - reconnect: bool = True, - *, - log_handler: Optional[logging.Handler] = MISSING, - log_formatter: logging.Formatter = MISSING, - log_level: int = MISSING, - root_logger: bool = False - ) -> None: - """A blocking call that abstracts away the event loop - initialisation from you. - - If you want more control over the event loop then this - function should not be used. Use :meth:`start` coroutine - or :meth:`connect` + :meth:`login`. - - Roughly Equivalent to: :: - - try: - loop.run_until_complete(start(*args, **kwargs)) - except KeyboardInterrupt: - loop.run_until_complete(close()) - # cancel all tasks lingering - finally: - loop.close() - - This function also sets up the `:mod:`logging` library to make it easier - for beginners to know what is going on with the library. For more - advanced users, this can be disabled by passing :obj:`None` to - the ``log_handler`` parameter. - - .. warning:: - - This function must be the last function to call due to the fact that it - is blocking. That means that registration of events or anything being - called after this function call will not execute until it returns. - - Parameters - ----------- - token: :class:`str` - The authentication token. **Do not prefix this token with anything as the library will do it for you.** - reconnect: :class:`bool` - If we should attempt reconnecting, either due to internet - failure or a specific failure on Discord's part. Certain - disconnects that lead to bad state will not be handled (such as - invalid sharding payloads or bad tokens). - log_handler: Optional[:class:`logging.Handler`] - The log handler to use for the library's logger. If this is :obj:`None` - then the library will not set up anything logging related. Logging - will still work if :obj:`None` is passed, though it is your responsibility - to set it up. - The default log handler if not provided is :class:`logging.StreamHandler`. - log_formatter: :class:`logging.Formatter` - The formatter to use with the given log handler. If not provided then it - defaults to a colour based logging formatter (if available). - log_level: :class:`int` - The default log level for the library's logger. This is only applied if the - ``log_handler`` parameter is not :obj:`None`. Defaults to :attr:`logging.INFO`. - root_logger: :class:`bool` - Whether to set up the root logger rather than the library logger. - By default, only the library logger (``'discord'``) is set up. If this - is set to :obj:`True` then the root logger is set up as well. - Defaults to :obj:`False`. - """ - loop = self.loop - - try: - loop.add_signal_handler(signal.SIGINT, lambda: loop.stop()) - loop.add_signal_handler(signal.SIGTERM, lambda: loop.stop()) - except NotImplementedError: - pass - - async def runner(): - try: - await self.start(token, reconnect) - finally: - if not self.is_closed(): - await self.close() - - if log_handler is not None: - utils.setup_logging( - handler=log_handler, - formatter=log_formatter, - level=log_level, - root=root_logger - ) - - def stop_loop_on_completion(f): - loop.stop() - - future = asyncio.ensure_future(runner(), loop=loop) - future.add_done_callback(stop_loop_on_completion) - try: - loop.run_forever() - except KeyboardInterrupt: - log.info('Received signal to terminate bot and event loop.') - finally: - future.remove_done_callback(stop_loop_on_completion) - log.info('Cleaning up tasks.') - _cleanup_loop(loop) - - if not future.cancelled(): - try: - return future.result() - except KeyboardInterrupt: - # I am unsure why this gets raised here but suppress it anyway - return None - - # properties - - def is_closed(self) -> bool: - """:class:`bool`: Indicates if the websocket connection is closed.""" - return self._closed - - @property - def activity(self) -> Optional[BaseActivity]: - """Optional[:class:`.BaseActivity`]: The activity being used upon - logging in. - """ - return create_activity(self._connection._activity) - - @activity.setter - def activity(self, value: Optional[BaseActivity]): - if value is None: - self._connection._activity = None - elif isinstance(value, BaseActivity): - self._connection._activity = value.to_dict() - else: - raise TypeError('activity must derive from BaseActivity.') - - @property - def allowed_mentions(self) -> Optional[AllowedMentions]: - """Optional[:class:`~discord.AllowedMentions`]: The allowed mention configuration. - - .. versionadded:: 1.4 - """ - return self._connection.allowed_mentions - - @allowed_mentions.setter - def allowed_mentions(self, value: Optional[AllowedMentions]): - if value is None or isinstance(value, AllowedMentions): - self._connection.allowed_mentions = value - else: - raise TypeError('allowed_mentions must be AllowedMentions not {0.__class__!r}'.format(value)) - - @property - def intents(self) -> Intents: - """:class:`~discord.Intents`: The intents configured for this connection. - - .. versionadded:: 1.5 - """ - return self._connection.intents - - # helpers/getters - - @property - def users(self) -> List[User]: - """List[:class:`~discord.User`]: Returns a list of all the users the bot can see.""" - return list(self._connection._users.values()) - - def get_message(self, id: int) -> Optional[Message]: - """Returns a :class:`~discord.Message` with the given ID if it exists in the cache, else :obj:`None`""" - return self._connection._get_message(id) - - def get_channel(self, id: int) -> Optional[Union[Messageable, GuildChannel]]: - """Returns a channel with the given ID. - - Parameters - ----------- - id: :class:`int` - The ID to search for. - - Returns - -------- - Optional[Union[:class:`.abc.GuildChannel`, :class:`.abc.PrivateChannel`]] - The returned channel or ``None`` if not found. - """ - return self._connection.get_channel(id) - - def get_partial_messageable( - self, - id: int, - *, - guild_id: Optional[int] = None, - type: Optional[ChannelType] = None - ) -> PartialMessageable: - """Returns a :class:`~discord.PartialMessageable` with the given channel ID. - This is useful if you have the ID of a channel but don't want to do an API call - to send messages to it. - - Parameters - ----------- - id: :class:`int` - The channel ID to create a :class:`~discord.PartialMessageable` for. - guild_id: Optional[:class:`int`] - The optional guild ID to create a :class:`~discord.PartialMessageable` for. - This is not required to actually send messages, but it does allow the - :meth:`~discord.PartialMessageable.jump_url` and - :attr:`~discord.PartialMessageable.guild` properties to function properly. - type: Optional[:class:`.ChannelType`] - The underlying channel type for the :class:`~discord.PartialMessageable`. - - Returns - -------- - :class:`.PartialMessageable` - The partial messageable created - """ - return PartialMessageable(state=self._connection, id=id, guild_id=guild_id, type=type) - - def get_guild(self, id: int) -> Optional[Guild]: - """Returns a guild with the given ID. - - Parameters - ----------- - id: :class:`int` - The ID to search for. - - Returns - -------- - Optional[:class:`.Guild`] - The guild or ``None`` if not found. - """ - return self._connection._get_guild(id) - - def get_user(self, id: int) -> Optional[User]: - """Returns a user with the given ID. - - Parameters - ----------- - id: :class:`int` - The ID to search for. - - Returns - -------- - Optional[:class:`~discord.User`] - The user or ``None`` if not found. - """ - return self._connection.get_user(id) - - def get_emoji(self, id: int) -> Optional[Emoji]: - """Returns an emoji with the given ID. - - Parameters - ----------- - id: :class:`int` - The ID to search for. - - Returns - -------- - Optional[:class:`.Emoji`] - The custom emoji or ``None`` if not found. - """ - return self._connection.get_emoji(id) - - def get_all_channels(self) -> Iterator[GuildChannel]: - """A generator that retrieves every :class:`.abc.GuildChannel` the client can 'access'. - - This is equivalent to: :: - - for guild in client.guilds: - for channel in guild.channels: - yield channel - - .. note:: - - Just because you receive a :class:`.abc.GuildChannel` does not mean that - you can communicate in said channel. :meth:`.abc.GuildChannel.permissions_for` should - be used for that. - - Yields - ------ - :class:`.abc.GuildChannel` - A channel the client can 'access'. - """ - - for guild in self.guilds: - for channel in guild.channels: - yield channel - - def get_all_members(self) -> Iterator[Member]: - """Returns a generator with every :class:`.Member` the client can see. - - This is equivalent to: :: - - for guild in client.guilds: - for member in guild.members: - yield member - - Yields - ------ - :class:`.Member` - A member the client can see. - """ - for guild in self.guilds: - for member in guild.members: - yield member - - # listeners/waiters - - async def wait_until_ready(self) -> None: - """|coro| - - Waits until the client's internal cache is all ready. - """ - await self._ready.wait() - - def wait_for( - self, - event: str, - *, - check: Optional[Callable[[Any, ...], bool]] = None, - timeout: Optional[float] = None - ) -> Coroutine[Any, Any, Any]: - """|coro| - - Waits for a WebSocket event to be dispatched. - - This could be used to wait for a user to reply to a message, - or to react to a message, or to edit a message in a self-contained - way. - - The ``timeout`` parameter is passed onto :func:`asyncio.wait_for`. By default, - it does not timeout. Note that this does propagate the - :exc:`asyncio.TimeoutError` for you in case of timeout and is provided for - ease of use. - - In case the event returns multiple arguments, a :class:`tuple` containing those - arguments is returned instead. Please check the - :ref:`documentation ` for a list of events and their - parameters. - - This function returns the **first event that meets the requirements**. - - Examples - --------- - - Waiting for a user reply: :: - - @client.event - async def on_message(message): - if message.content.startswith('$greet'): - channel = message.channel - await channel.send('Say hello!') - - def check(m): - return m.content == 'hello' and m.channel == channel - - msg = await client.wait_for('message', check=check) - await channel.send('Hello {.author}!'.format(msg)) - - Waiting for a thumbs up reaction from the message author: :: - - @client.event - async def on_message(message): - if message.content.startswith('$thumb'): - channel = message.channel - await channel.send('Send me that \N{THUMBS UP SIGN} reaction, mate') - - def check(reaction, user): - return user == message.author and str(reaction.emoji) == '\N{THUMBS UP SIGN}' - - try: - reaction, user = await client.wait_for('reaction_add', timeout=60.0, check=check) - except asyncio.TimeoutError: - await channel.send('\N{THUMBS DOWN SIGN}') - else: - await channel.send('\N{THUMBS UP SIGN}') - - - Parameters - ------------ - event: :class:`str` - The event name, similar to the :ref:`event reference `, - but without the ``on_`` prefix, to wait for. - check: Optional[Callable[..., :class:`bool`]] - A predicate to check what to wait for. The arguments must meet the - parameters of the event being waited for. - timeout: Optional[:class:`float`] - The number of seconds to wait before timing out and raising - :exc:`asyncio.TimeoutError`. - - Raises - ------- - asyncio.TimeoutError - If a timeout is provided, and it was reached. - - Returns - -------- - Any - Returns no arguments, a single argument, or a :class:`tuple` of multiple - arguments that mirrors the parameters passed in the - :ref:`event reference `. - """ - - future = self.loop.create_future() - if check is None: - def _check(*args): - return True - check = _check - ev = event.lower() - try: - listeners = self._listeners[ev] - except KeyError: - listeners = [] - self._listeners[ev] = listeners - - listeners.append((future, check)) - return asyncio.wait_for(future, timeout) - - # event registration - - def event(self, coro: Coro) -> Coro: - """A decorator that registers an event to listen to. - - You can find more info about the events on the :ref:`documentation below `. - - The events must be a :ref:`coroutine `, if not, :exc:`TypeError` is raised. - - Example - --------- - .. code-block:: python3 - - @client.event - async def on_ready(): - print('Ready!') - - Raises - -------- - TypeError - The coroutine passed is not actually a coroutine. - """ - - if not asyncio.iscoroutinefunction(coro): - raise TypeError('event registered must be a coroutine function') - - setattr(self, coro.__name__, coro) - log.debug('%s has successfully been registered as an event', coro.__name__) - return coro - - def once( - self, name: str = MISSING, check: Callable[..., bool] | None = None - ) -> Coro: - """A decorator that registers an event to listen to only once. - For example if you want to perform a database connection once the bot is ready. - - You can find more info about the events on the :ref:`documentation below `. - - The events must be a :ref:`coroutine `, if not, :exc:`TypeError` is raised. - - Parameters - ---------- - name: :class:`str` - The name of the event we want to listen to. This is passed to - :meth:`~discord.Client.wait_for`. Defaults to ``func.__name__``. - check: Optional[Callable[..., :class:`bool`]] - A predicate to check what to wait for. The arguments must meet the - parameters of the event being waited for. - - Raises - ------ - TypeError - The coroutine passed is not actually a coroutine. - - Example - ------- - - .. code-block:: python - - @client.once() - async def ready(): - print('Beep bop, I\\'m ready!') - - @client.once(check=lambda msg: msg.author.id == 693088765333471284) - async def message(message): - await message.reply('Hey there, how are you?') - """ - - def decorator(func: Coro) -> Coro: - if not asyncio.iscoroutinefunction(func): - raise TypeError("event registered must be a coroutine function") - - async def wrapped() -> None: - nonlocal name - nonlocal check - - name = func.__name__ if name is MISSING else name - if name[:3] == 'on_': - name = name[3:] - - args = await self.wait_for(name, check=check) - - arg_len = func.__code__.co_argcount - if arg_len == 0 and args is None: - await func() - elif arg_len == 1: - await func(args) - else: - await func(*args) - - self.loop.create_task(wrapped()) - return func - - return decorator - - def on_click( - self, - custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] = None - ) -> Callable[[_ClickCallback], _ClickCallback]: - """ - A decorator with which you can assign a function to a specific :class:`~discord.Button` (or its custom_id). - - .. important:: - The function this is attached to must take the same parameters as a - :func:`~discord.on_raw_button_click` event. - - .. warning:: - The func must be a coroutine, if not, :exc:`TypeError` is raised. - - Parameters - ---------- - custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] - If the :attr:`custom_id` of the :class:`~discord.Button` could not be used as a function name, - or you want to give the function a different name then the custom_id use this one to set the custom_id. - You can also specify a regex and if the custom_id matches it, the function will be executed. - - .. note:: - As the ``custom_id`` is converted to a |pattern_object| put ``^`` in front and ``$`` at the end - of the :attr:`custom_id` if you want that the custom_id must exactly match the specified value. - Otherwise, something like 'cool blue Button is blue' will let the function bee invoked too. - - Example - ------- - .. code-block:: python - - # the button - Button(label='Hey im a cool blue Button', - custom_id='cool blue Button', - style=ButtonStyle.blurple) - - # function that's called when the button pressed - @client.on_click(custom_id='^cool blue Button$') - async def cool_blue_button(i: discord.ComponentInteraction, button: Button): - await i.respond(f'Hey you pressed a {button.custom_id}!', hidden=True) - - Returns - ------- - The decorator for the function called when the button clicked - - Raise - ----- - :exc:`TypeError` - The coroutine passed is not actually a coroutine. - """ - def decorator(func: _ClickCallback) -> _ClickCallback: - if not asyncio.iscoroutinefunction(func): - raise TypeError('event registered must be a coroutine function') - - _custom_id = re.compile(custom_id) if ( - custom_id is not None and not isinstance(custom_id, re.Pattern) - ) else re.compile(f'^{func.__name__}$') - - try: - listeners = self._listeners['raw_button_click'] - except KeyError: - listeners = [] - self._listeners['raw_button_click'] = listeners - - def _check(i: ComponentInteraction, c: Button) -> bool: - match = _custom_id.match(str(c.custom_id)) - if match: - i.match = match - return True - return False - - listeners.append((func, _check)) - return func - - return decorator - - def on_select( - self, - custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] = None - ) -> Callable[[_SelectCallback], _SelectCallback]: - """ - A decorator with which you can assign a function to a specific :class:`~discord.SelectMenu` (or its custom_id). - - .. important:: - The function this is attached to must take the same parameters as a - :func:`~discord.on_raw_selection_select` event. - - .. warning:: - The func must be a coroutine, if not, :exc:`TypeError` is raised. - - Parameters - ----------- - custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] = None - If the `custom_id` of the :class:`~discord.SelectMenu` could not be used as a function name, - or you want to give the function a different name then the custom_id use this one to set the custom_id. - You can also specify a regex and if the custom_id matches it, the function will be executed. - - .. note:: - As the ``custom_id`` is converted to a |pattern_object| put ``^`` in front and ``$`` at the end - of the :attr:`custom_id` if you want that the custom_id must exactly match the specified value. - Otherwise, something like 'choose_your_gender later' will let the function bee invoked too. - - Example - ------- - .. code-block:: python - - # the SelectMenu - SelectMenu(custom_id='choose_your_gender', - options=[ - SelectOption(label='Female', value='Female', emoji='♀️'), - SelectOption(label='Male', value='Male', emoji='♂️'), - SelectOption(label='Trans/Non Binary', value='Trans/Non Binary', emoji='⚧') - ], placeholder='Choose your Gender') - - # function that's called when the SelectMenu is used - @client.on_select() - async def choose_your_gender(i: discord.Interaction, select_menu): - await i.respond(f'You selected `{select_menu.values[0]}`!', hidden=True) - - Raises - ------- - :exc:`TypeError` - The coroutine passed is not actually a coroutine. - """ - def decorator(func: _SelectCallback) -> _SelectCallback: - if not asyncio.iscoroutinefunction(func): - raise TypeError('event registered must be a coroutine function') - - _custom_id = re.compile(custom_id) if ( - custom_id is not None and not isinstance(custom_id, re.Pattern) - ) else re.compile(f'^{func.__name__}$') - - try: - listeners = self._listeners['raw_selection_select'] - except KeyError: - listeners = [] - self._listeners['raw_selection_select'] = listeners - - def _check(i: ComponentInteraction, c: Button) -> bool: - match = _custom_id.match(str(c.custom_id)) - if match: - i.match = match - return True - return False - - listeners.append((func, _check)) - return func - - return decorator - - def on_submit( - self, - custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] = None - ) -> Callable[[_SubmitCallback], _SubmitCallback]: - """ - A decorator with which you can assign a function to a specific :class:`~discord.Modal` (or its custom_id). - - .. important:: - The function this is attached to must take the same parameters as a - :func:`~discord.on_modal_submit` event. - - .. warning:: - The func must be a coroutine, if not, :exc:`TypeError` is raised. - - - Parameters - ---------- - custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] - If the :attr:`~discord.Modal.custom_id` of the modal could not be used as a function name, - or you want to give the function a different name then the custom_id use this one to set the custom_id. - You can also specify a regex and if the custom_id matches it, the function will be executed. - - .. note:: - As the ``custom_id`` is converted to a |pattern_object| put ``^`` in front and ``$`` at the end - of the :attr:`custom_id` if you want that the custom_id must exactly match the specified value. - Otherwise, something like 'suggestions_modal_submit_private' will let the function bee invoked too. - - .. tip:: - The resulting |match_object| object will be - available under the :class:`~discord.ModalSubmitInteraction.match` attribute of the interaction. - - **See example below.** - - Examples - -------- - .. code-block:: python - :caption: Simple example of a Modal with a custom_id and a function that's called when the Modal is submitted. - :emphasize-lines: 9, 14 - - # the Modal - Modal( - title='Create a new suggestion', - custom_id='suggestions_modal', - components=[...] - ) - - # function that's called when the Modal is submitted - @client.on_submit(custom_id='^suggestions_modal$') - async def suggestions_modal_callback(i: discord.ModalSubmitInteraction): - ... - - # This can also be done based on the function name - @client.on_submit() - async def suggestions_modal(i: discord.ModalSubmitInteraction): - ... - - - .. code-block:: python - :caption: You can also use a more advanced RegEx containing groups to easily allow dynamic custom-id's - :emphasize-lines: 1, 3 - - @client.on_submit(custom_id='^ticket_answer:(?P[0-9]+)$') - async def ticket_answer_callback(i: discord.ModalSubmitInteraction): - user_id = int(i.match['id']) - user = client.get_user(user_id) or await client.fetch_user(user_id) - - Raises - ------ - :exc:`TypeError` - The coroutine passed is not actually a coroutine. - """ - def decorator(func: _SubmitCallback) -> _SubmitCallback: - if not asyncio.iscoroutinefunction(func): - raise TypeError('event registered must be a coroutine function') - - _custom_id = re.compile(custom_id) if ( - custom_id is not None and not isinstance(custom_id, re.Pattern) - ) else re.compile(f'^{func.__name__}$') - - try: - listeners = self._listeners['modal_submit'] - except KeyError: - listeners = [] - self._listeners['modal_submit'] = listeners - - def _check(i: ModalSubmitInteraction) -> bool: - match = _custom_id.match(str(i.custom_id)) - if match: - i.match = match - return True - return False - - listeners.append((func, _check)) - return func - - return decorator - - def slash_command( - self, - name: Optional[str] = None, - name_localizations: Optional[Localizations] = Localizations(), - description: Optional[str] = None, - description_localizations: Optional[Localizations] = Localizations(), - allow_dm: bool = MISSING, - allowed_contexts: Optional[List[InteractionContextType]] = MISSING, - allowed_integration_types: Optional[List[AppIntegrationType]] = MISSING, - is_nsfw: bool = MISSING, - default_required_permissions: Optional[Permissions] = None, - options: Optional[List] = [], - guild_ids: Optional[List[int]] = None, - connector: Optional[dict] = {}, - option_descriptions: Optional[dict] = {}, - option_descriptions_localizations: Optional[Dict[str, Localizations]] = {}, - base_name: Optional[str] = None, - base_name_localizations: Optional[Localizations] = Localizations(), - base_desc: Optional[str] = None, - base_desc_localizations: Optional[Localizations] = Localizations(), - group_name: Optional[str] = None, - group_name_localizations: Optional[Localizations] = Localizations(), - group_desc: Optional[str] = None, - group_desc_localizations: Optional[Localizations] = Localizations() - ) -> Callable[ - [Awaitable[Any]], - Union[SlashCommand, GuildOnlySlashCommand, SubCommand, GuildOnlySubCommand] - ]: - """A decorator that adds a slash-command to the client. The function this is attached to must be a :ref:`coroutine `. - - .. warning:: - :attr:`~discord.Client.sync_commands` of the :class:`Client` instance must be set to :obj:`True` - to register a command if it does not already exist and update it if changes where made. - - .. note:: - Any of the following parameters are only needed when the corresponding target was not used before - (e.g. there is already a command in the code that has these parameters set) - otherwise it will replace the previous value or update it for iterables. - - - ``allow_dm`` - - ``allowed_contexts`` (update) - - ``allowed_integration_types`` (update) - - ``is_nsfw`` - - ``base_name_localizations`` - - ``base_desc`` - - ``base_desc_localizations`` - - ``group_name_localizations`` - - ``group_desc`` - - ``group_desc_localizations`` - - Parameters - ----------- - name: Optional[:class:`str`] - The name of the command. Must only contain a-z, _ and - and be 1-32 characters long. - Default to the functions name. - name_localizations: Optional[:class:`~discord.Localizations`] - Localizations object for name field. Values follow the same restrictions as :attr:`name` - description: Optional[:class:`str`] - The description of the command shows up in the client. Must be between 1-100 characters long. - Default to the functions docstring or "No Description". - description_localizations: Optional[:class:`~discord.Localizations`] - Localizations object for description field. Values follow the same restrictions as :attr:`description` - allow_dm: Optional[:class:`bool`] - **Deprecated**: Use :attr:`allowed_contexts` instead. - Indicates whether the command is available in DMs with the app, only for globally-scoped commands. - By default, commands are visible. - allowed_contexts: Optional[List[:class:`~discord.InteractionContextType`]] - **global commands only**: The contexts in which the command is available. - By default, commands are available in all contexts. - allowed_integration_types: Optional[List[:class:`~discord.AppIntegrationType`]] - **global commands only**: The types of app integrations where the command is available. - Default to the app's :ddocs:`configured integration types ` - is_nsfw: :class:`bool` - Whether this command is an :sup-art:`NSFW command <10123937946007-Age-Restricted-Commands>` , default :obj:`False`. - - .. note:: - Currently all sub-commands of a command that is marked as *NSFW* are NSFW too. - - default_required_permissions: Optional[:class:`~discord.Permissions`] - Permissions that a Member needs by default to execute(see) the command. - options: Optional[List[:class:`~discord.SlashCommandOption`]] - A list of max. 25 options for the command. If not provided the options will be generated - using :meth:`generate_options` that creates the options out of the function parameters. - Required options **must** be listed before optional ones. - Use :attr:`options` to connect non-ascii option names with the parameter of the function. - guild_ids: Optional[List[:class:`int`]] - ID's of guilds this command should be registered in. If empty, the command will be global. - connector: Optional[Dict[:class:`str`, :class:`str`]] - A dictionary containing the name of function-parameters as keys and the name of the option as values. - Useful for using non-ascii Letters in your option names without getting ide-errors. - option_descriptions: Optional[Dict[:class:`str`, :class:`str`]] - Descriptions the :func:`generate_options` should take for the Options that will be generated. - The keys are the :attr:`~discord.SlashCommandOption.name` of the option and the value the :attr:`~discord.SlashCommandOption.description`. - - .. note:: - This will only be used if ``options`` is not set. - - option_descriptions_localizations: Optional[Dict[:class:`str`, :class:`~discord.Localizations`]] - Localized :attr:`~discord.SlashCommandOption.description` for the options. - In the format ``{'option_name': Localizations(...)}`` - base_name: Optional[:class:`str`] - The name of the base-command(a-z, _ and -, 1-32 characters) if you want the command - to be in a command-/sub-command-group. - If the base-command does not exist yet, it will be added. - base_name_localizations: Optional[:class:`~discord.Localizations`] - Localized ``base_name``'s for the command. - base_desc: Optional[:class:`str`] - The description of the base-command(1-100 characters). - base_desc_localizations: Optional[:class:`~discord.Localizations`] - Localized ``base_description``'s for the command. - group_name: Optional[:class:`str`] - The name of the command-group(a-z, _ and -, 1-32 characters) if you want the command to be in a sub-command-group. - group_name_localizations: Optional[:class:`~discord.Localizations`] - Localized ``group_name``'s for the command. - group_desc: Optional[:class:`str`] - The description of the sub-command-group(1-100 characters). - group_desc_localizations: Optional[:class:`~discord.Localizations`] - Localized ``group_desc``'s for the command. - - Raises - ------ - :exc:`TypeError`: - The function the decorator is attached to is not actual a :ref:`coroutine ` - or a parameter passed to :class:`SlashCommandOption` is invalid for the ``option_type`` or the ``option_type`` - itself is invalid. - :exc:`~discord.InvalidArgument`: - You passed ``group_name`` but no ``base_name``. - :exc:`ValueError`: - Any of ``name``, ``description``, ``options``, ``base_name``, ``base_desc``, ``group_name`` or ``group_desc`` is not valid. - - Returns - ------- - Union[:class:`SlashCommand`, :class:`GuildOnlySlashCommand`, :class:`SubCommand`, :class:`GuildOnlySubCommand`]: - - If neither ``guild_ids`` nor ``base_name`` passed: An instance of :class:`~discord.SlashCommand`. - - If ``guild_ids`` and no ``base_name`` where passed: An instance of :class:`~discord.GuildOnlySlashCommand` representing the guild-only slash-commands. - - If ``base_name`` and no ``guild_ids`` where passed: An instance of :class:`~discord.SubCommand`. - - If ``base_name`` and ``guild_ids`` passed: instance of :class:`~discord.GuildOnlySubCommand` representing the guild-only sub-commands. - """ - - def decorator(func: Awaitable[Any]) -> Union[SlashCommand, GuildOnlySlashCommand, SubCommand, GuildOnlySubCommand]: - """ - - Parameters - ---------- - func: Awaitable[Any] - The function for the decorator. This must be a :ref:`coroutine `. - - Returns - ------- - The slash-command registered. - - If neither ``guild_ids`` nor ``base_name`` passed: An instance of :class:`~discord.SlashCommand`. - - If ``guild_ids`` and no ``base_name`` where passed: An instance of :class:`~discord.GuildOnlySlashCommand` representing the guild-only slash-commands. - - If ``base_name` and no ``guild_ids`` where passed: An instance of :class:`~discord.SubCommand`. - - If ``base_name`` and ``guild_ids`` passed: instance of :class:`~discord.GuildOnlySubCommand` representing the guild-only sub-commands. - """ - if not asyncio.iscoroutinefunction(func): - raise TypeError('The slash-command registered must be a coroutine.') - _name = (name or func.__name__).lower() - _description = description if description else (inspect.cleandoc(func.__doc__)[:100] if func.__doc__ else 'No Description') - _options = options or generate_options( - func, - descriptions=option_descriptions, - descriptions_localizations=option_descriptions_localizations, - connector=connector - ) - if group_name and not base_name: - raise InvalidArgument( - 'You have to provide the `base_name` parameter if you want to create a sub-command or sub-command-group.' - ) - guild_cmds = [] - if guild_ids: - guild_app_cmds = self._guild_specific_application_commands - for guild_id in guild_ids: - base, base_command, sub_command_group = None, None, None - try: - guild_app_cmds[guild_id] - except KeyError: - guild_app_cmds[guild_id] = {'chat_input': {}, 'message': {}, 'user': {}} - if base_name: - try: - base_command = guild_app_cmds[guild_id]['chat_input'][base_name] - except KeyError: - base_command = guild_app_cmds[guild_id]['chat_input'][base_name] = SlashCommand( - name=base_name, - name_localizations=base_name_localizations, - description=base_desc or 'No Description', - description_localizations=base_desc_localizations, - default_member_permissions=default_required_permissions, - is_nsfw=is_nsfw if is_nsfw is not MISSING else False, - guild_id=guild_id - ) - else: - - if base_desc: - base_command.description = base_command.description - if is_nsfw is not MISSING: - base_command.is_nsfw = is_nsfw - if allow_dm is not MISSING: - base_command.allow_dm = allow_dm - base_command.name_localizations.update(base_name_localizations) - base_command.description_localizations.update(base_desc_localizations) - base = base_command - if group_name: - try: - sub_command_group = guild_app_cmds[guild_id]['chat_input'][base_name]._sub_commands[group_name] - except KeyError: - sub_command_group = guild_app_cmds[guild_id]['chat_input'][base_name]._sub_commands[group_name] = SubCommandGroup( - parent=base_command, - name=group_name, - name_localizations=group_name_localizations, - description=group_desc or 'No Description', - description_localizations=group_desc_localizations, - guild_id=guild_id - ) - else: - if group_desc: - sub_command_group.description = group_desc - sub_command_group.name_localizations.update(group_name_localizations) - sub_command_group.description_localizations.update(group_desc_localizations) - base = sub_command_group - if base: - base._sub_commands[_name] = SubCommand( - parent=base, - name=_name, - name_localizations=name_localizations, - description=_description, - description_localizations=description_localizations, - options=_options, - connector=connector, - func=func - ) - guild_cmds.append(base._sub_commands[_name]) - else: - guild_app_cmds[guild_id]['chat_input'][_name] = SlashCommand( - func=func, - guild_id=guild_id, - name=_name, - name_localizations=name_localizations, - description=_description, - description_localizations=description_localizations, - default_member_permissions=default_required_permissions, - is_nsfw=is_nsfw if is_nsfw is not MISSING else False, - options=_options, - connector=connector - ) - guild_cmds.append(guild_app_cmds[guild_id]['chat_input'][_name]) - if base_name: - base = GuildOnlySlashCommand( - client=self, - guild_ids=guild_ids, - name=_name, - description=_description, - default_member_permissions=default_required_permissions, - is_nsfw=is_nsfw if is_nsfw is not MISSING else False, - options=_options - ) - if group_name: - base = GuildOnlySubCommandGroup( - client=self, - parent=base, - guild_ids=guild_ids, - name=_name, - description=_description, - default_member_permissions=default_required_permissions, - options=_options - ) - return GuildOnlySubCommand( - client=self, - parent=base, - func=func, - guild_ids=guild_ids, - commands=guild_cmds, - name=_name, - description=_description, - options=_options, - connector=connector - ) - return GuildOnlySlashCommand( - client=self, - func=func, - guild_ids=guild_ids, - commands=guild_cmds, - name=_name, - description=_description, - default_member_permission=default_required_permissions, - is_nsfw=is_nsfw if is_nsfw is not MISSING else False, - options=_options, - connector=connector - ) - else: - app_cmds = self._application_commands_by_type - base, base_command, sub_command_group = None, None, None - if base_name: - try: - base_command = app_cmds['chat_input'][base_name] - except KeyError: - base_command = app_cmds['chat_input'][base_name] = SlashCommand( - name=base_name, - name_localizations=base_name_localizations, - description=base_desc or 'No Description', - description_localizations=base_desc_localizations, - default_member_permissions=default_required_permissions, - allow_dm=allow_dm if allow_dm is not MISSING else True, - is_nsfw=is_nsfw if is_nsfw is not MISSING else False, - integration_types=allowed_integration_types if allowed_integration_types is not MISSING else None, - contexts=allowed_contexts if allowed_contexts is not MISSING else None - ) - else: - if base_desc: - base_command.description = base_desc - if is_nsfw is not MISSING: - base_command.is_nsfw = is_nsfw - if allow_dm is not MISSING: - base_command.allow_dm = allow_dm - if allowed_integration_types is not MISSING: - base_command.integration_types.update(allowed_integration_types) - if allowed_contexts is not MISSING: - base_command.contexts.update(allowed_contexts) - base_command.name_localizations.update(base_name_localizations) - base_command.description_localizations.update(base_desc_localizations) - base = base_command - if group_name: - try: - sub_command_group = app_cmds['chat_input'][base_name]._sub_commands[group_name] - except KeyError: - sub_command_group = app_cmds['chat_input'][base_name]._sub_commands[group_name] = SubCommandGroup( - parent=base_command, - name=group_name, - name_localizations=group_name_localizations, - description=group_desc or 'No Description', - description_localizations=group_desc_localizations - ) - else: - if group_desc: - sub_command_group.description = group_desc - sub_command_group.name_localizations.update(group_name_localizations) - sub_command_group.description_localizations.update(group_desc_localizations) - base = sub_command_group - if base: - command = base._sub_commands[_name] = SubCommand( - parent=base, - func=func, - name=_name, - name_localizations=name_localizations, - description=_description, - description_localizations=description_localizations, - options=_options, - connector=connector - ) - else: - command = app_cmds['chat_input'][_name] = SlashCommand( - func=func, - name=_name, - name_localizations=name_localizations, - description=_description or 'No Description', - description_localizations=description_localizations, - default_member_permissions=default_required_permissions, - allow_dm=allow_dm if allow_dm is not MISSING else True, - integration_types=allowed_integration_types if allowed_integration_types is not MISSING else None, - contexts=allowed_contexts if allowed_contexts is not MISSING else None, - is_nsfw=is_nsfw if is_nsfw is not MISSING else False, - options=_options, - connector=connector - ) - - return command - return decorator - - def activity_primary_entry_point_command( - self, - name: Optional[str] = 'launch', - name_localizations: Localizations = Localizations(), - description: Optional[str] = '', - description_localizations: Localizations = Localizations(), - default_required_permissions: Optional[Permissions] = None, - allowed_contexts: Optional[List[InteractionContextType]] = None, - allowed_integration_types: Optional[List[AppIntegrationType]] = None, - is_nsfw: bool = False, - ) -> Callable[[Awaitable[Any]], SlashCommand]: - """ - A decorator that sets the handler function for the - :ddocs:`primary entry point ` of an activity. - - **This overwrites the default activity command created by Discord.** - - .. note:: - If you only want to change the name, description, permissions, etc. of the default activity command, - use :meth:`update_activity_command` instead. - - Parameters - ---------- - name: Optional[:class:`str`] - The name of the activity command. Default to 'launch'. - name_localizations: :class:`Localizations` - Localized ``name``'s. - description: :class:`str` - The description of the activity command. - description_localizations: :class:`Localizations` - Localized ``description``'s. - default_required_permissions: Optional[:class:`Permissions`] - Permissions that a member needs by default to execute(see) the command. - allowed_contexts: Optional[List[:class:`~discord.InteractionContextType`]] - The contexts in which the command is available. - By default, commands are available in all contexts. - allowed_integration_types: Optional[List[:class:`~discord.AppIntegrationType`]] - **global commands only**: The types of app integrations where the command is available. - Default to the app's :ddocs:`configured integration types ` - is_nsfw: :class:`bool` - Whether this command is an :sup-art:`NSFW command <10123937946007-Age-Restricted-Commands>`, default :obj:`False`. - - Returns - ------- - ~discord.ActivityEntryPointCommand: - The activity command to be registered as the primary entry point. - - Raises - ------ - :exc:`TypeError`: - The function the decorator is attached to is not actual a :ref:`coroutine `. - """ - - def decorator(func: Awaitable[Any]) -> SlashCommand: - if not asyncio.iscoroutinefunction(func): - raise TypeError('The activity command function registered must be a coroutine.') - _name = name or func.__name__ - cmd = SlashCommand( - func=func, - name=_name, - name_localizations=name_localizations, - description=description, - description_localizations=description_localizations, - default_member_permissions=default_required_permissions, - contexts=allowed_contexts, - integration_types=allowed_integration_types, - is_nsfw=is_nsfw - ) - self._application_commands_by_type['primary_entry_point'][_name] = cmd - self._activity_primary_entry_point_command = cmd - return cmd - return decorator - - def message_command( - self, - name: Optional[str] = None, - name_localizations: Localizations = Localizations(), - default_required_permissions: Optional[Permissions] = None, - allow_dm: bool = True, - allowed_contexts: Optional[List[InteractionContextType]] = MISSING, - allowed_integration_types: Optional[List[AppIntegrationType]] = MISSING, - is_nsfw: bool = False, - guild_ids: Optional[List[int]] = None - ) -> Callable[[Awaitable[Any]], MessageCommand]: - """ - A decorator that registers a :class:`MessageCommand` (shows up under ``Apps`` when right-clicking on a message) - to the client. The function this is attached to must be a :ref:`coroutine `. - - .. note:: - - :attr:`~discord.Client.sync_commands` of the :class:`~discord.Client` instance must be set to :obj:`True` - to register a command if it does not already exit and update it if changes where made. - - Parameters - ---------- - name: Optional[:class:`str`] - The name of the message-command, default to the functions name. - Must be between 1-32 characters long. - name_localizations: :class:`Localizations` - Localized ``name``'s. - default_required_permissions: Optional[:class:`Permissions`] - Permissions that a member needs by default to execute(see) the command. - allow_dm: :class:`bool` - **Deprecated**: Use :attr:`allowed_contexts` instead. - Indicates whether the command is available in DMs with the app, only for globally-scoped commands. - By default, commands are visible. - allowed_contexts: Optional[List[:class:`~discord.InteractionContextType`]] - **global commands only**: The contexts in which the command is available. - By default, commands are available in all contexts. - allowed_integration_types: Optional[List[:class:`~discord.AppIntegrationType`]] - **global commands only**: The types of app integrations where the command is available. - Default to the app's :ddocs:`configured integration types ` - is_nsfw: :class:`bool` - Whether this command is an :sup-art:`NSFW command <10123937946007-Age-Restricted-Commands>` , default :obj:`False`. - guild_ids: Optional[List[:class:`int`]] - ID's of guilds this command should be registered in. If empty, the command will be global. - - Returns - ------- - ~discord.MessageCommand: - The message-command registered. - - Raises - ------ - :exc:`TypeError`: - The function the decorator is attached to is not actual a :ref:`coroutine `. - """ - def decorator(func: Awaitable[Any]) -> MessageCommand: - if not asyncio.iscoroutinefunction(func): - raise TypeError('The message-command function registered must be a coroutine.') - _name = name or func.__name__ - cmd = MessageCommand( - guild_ids=guild_ids, - func=func, - name=_name, - name_localizations=name_localizations, - default_member_permissions=default_required_permissions, - allow_dm=allow_dm, - integration_types=allowed_integration_types if allowed_integration_types is not MISSING else None, - contexts=allowed_contexts if allowed_contexts is not MISSING else None, - is_nsfw=is_nsfw - ) - if guild_ids: - for guild_id in guild_ids: - guild_cmd = MessageCommand( - guild_id=guild_id, - func=func, - name=_name, - name_localizations=name_localizations, - default_member_permissions=default_required_permissions, - allow_dm=allow_dm, - is_nsfw=is_nsfw - ) - try: - self._guild_specific_application_commands[guild_id]['message'][_name] = guild_cmd - except KeyError: - self._guild_specific_application_commands[guild_id] = { - 'chat_input': {}, - 'message': {_name: guild_cmd}, - 'user': {} - } - else: - self._application_commands_by_type['message'][_name] = cmd - - return cmd - return decorator - - def user_command( - self, - name: Optional[str] = None, - name_localizations: Localizations = Localizations(), - default_required_permissions: Optional[Permissions] = None, - allow_dm: bool = True, - allowed_contexts: Optional[List[InteractionContextType]] = MISSING, - allowed_integration_types: Optional[List[AppIntegrationType]] = MISSING, - is_nsfw: bool = False, - guild_ids: Optional[List[int]] = None - ) -> Callable[[Awaitable[Any]], UserCommand]: - """ - A decorator that registers a :class:`UserCommand` (shows up under ``Apps`` when right-clicking on a user) to the client. - The function this is attached to must be a :ref:`coroutine `. - - .. note:: - :attr:`~discord.Client.sync_commands` of the :class:`~discord.Client` instance must be set to :obj:`True` - to register a command if it does not already exist and update it if changes where made. - - Parameters - ---------- - name: Optional[:class:`str`] - The name of the user-command, default to the functions name. - Must be between 1-32 characters long. - name_localizations: :class:`Localizations` - Localized ``name``'s. - default_required_permissions: Optional[:class:`Permissions`] - Permissions that a member needs by default to execute(see) the command. - allow_dm: :class:`bool` - **Deprecated**: Use :attr:`allowed_contexts` instead. - Indicates whether the command is available in DMs with the app, only for globally-scoped commands. - By default, commands are visible. - allowed_contexts: Optional[List[:class:`~discord.InteractionContextType`]] - **global commands only**: The contexts in which the command is available. - By default, commands are available in all contexts. - allowed_integration_types: Optional[List[:class:`~discord.AppIntegrationType`]] - **global commands only**: The types of app integrations where the command is available. - Default to the app's :ddocs:`configured integration types ` - is_nsfw: :class:`bool` - Whether this command is an :sup-art:`NSFW command <10123937946007-Age-Restricted-Commands>` , default :obj:`False`. - guild_ids: Optional[List[:class:`int`]] - ID's of guilds this command should be registered in. If empty, the command will be global. - - Returns - ------- - ~discord.UserCommand: - The user-command registered. - - Raises - ------ - :exc:`TypeError`: - The function the decorator is attached to is not actual a :ref:`coroutine `. - """ - def decorator(func: Awaitable[Any]) -> UserCommand: - if not asyncio.iscoroutinefunction(func): - raise TypeError('The user-command function registered must be a coroutine.') - _name = name or func.__name__ - cmd = UserCommand( - guild_ids=guild_ids, - func=func, - name=_name, - name_localizations=name_localizations, - default_member_permissions=default_required_permissions, - allow_dm=allow_dm, - integration_types=allowed_integration_types if allowed_integration_types is not MISSING else None, - contexts=allowed_contexts if allowed_contexts is not MISSING else None, - is_nsfw=is_nsfw - ) - if guild_ids: - for guild_id in guild_ids: - guild_cmd = UserCommand( - guild_id=guild_id, - func=func, - name=_name, - name_localizations=name_localizations, - default_member_permissions=default_required_permissions, - allow_dm=allow_dm, - is_nsfw=is_nsfw - ) - try: - self._guild_specific_application_commands[guild_id]['user'][_name] = guild_cmd - except KeyError: - self._guild_specific_application_commands[guild_id] = { - 'chat_input': {}, - 'message': {}, - 'user': {_name: guild_cmd} - } - else: - self._application_commands_by_type['user'][_name] = cmd - - return cmd - return decorator - - async def _sync_commands(self) -> None: - if not hasattr(self, 'app'): - await self.application_info() - state = self._connection # Speedup attribute access - get_commands = self.http.get_application_commands - application_id = self.app.id - - to_send = [] - to_cep = [] - to_maybe_remove = [] - - any_changed = False - has_update = False - - log.info('Checking for changes on application-commands for application %s (%s)...', self.app.name, application_id) - - global_registered_raw: List[Dict] = await get_commands(application_id) - global_registered: Dict[str, List[ApplicationCommand]] = ApplicationCommand._sorted_by_type(global_registered_raw) - self._minimal_registered_global_commands_raw = minimal_registered_global_commands_raw = [] - - for x, commands in global_registered.items(): - for command in commands: - if command['name'] in self._application_commands_by_type[x].keys(): - cmd = self._application_commands_by_type[x][command['name']] - if cmd != command: - any_changed = has_update = True - c = cmd.to_dict() - c['id'] = command['id'] - to_send.append(c) - else: - to_cep.append(command) - else: - to_maybe_remove.append(command) - any_changed = True - cmd_names = [c['name'] for c in commands] - for command in self._application_commands_by_type[x].values(): - if command.name not in cmd_names: - any_changed = True - to_send.append(command.to_dict()) - - if any_changed is True: - updated = None - if (to_send_count := len(to_send)) == 1 and has_update and not to_maybe_remove: - log.info('Detected changes on global application-command %s, updating.', to_send[0]['name']) - updated = await self.http.edit_application_command(application_id, to_send[0]['id'], to_send[0]) - elif len == 1 and not has_update and not to_maybe_remove: - log.info('Registering one new global application-command %s.', to_send[0]['name']) - updated = await self.http.create_application_command(application_id, to_send[0]) - else: - if to_send_count > 0: - log.info( - f'Detected %s updated/new global application-commands, bulk overwriting them...', - to_send_count - ) - if not self.delete_not_existing_commands: - to_send.extend(to_maybe_remove) - else: - if (to_maybe_remove_count := len(to_maybe_remove)) > 0: - log.info( - 'Removing %s global application-command(s) that isn\'t/arent used in this code anymore.' - ' To prevent this set `delete_not_existing_commands` of %s to False', - to_maybe_remove_count, - self.__class__.__name__ - ) - to_send.extend(to_cep) - global_registered_raw = await self.http.bulk_overwrite_application_commands(application_id, to_send) - if updated: - global_registered_raw = await self.http.get_application_commands(application_id) - log.info('Synced global application-commands.') - else: - log.info('No changes on global application-commands found.') - - for updated in global_registered_raw: - command_type = str(ApplicationCommandType.try_value(updated['type'])) - minimal_registered_global_commands_raw.append({'id': int(updated['id']), 'type': command_type, 'name': updated['name']}) - try: - command = self._application_commands_by_type[command_type][updated['name']] - except KeyError: - command = ApplicationCommand._from_type(state, data=updated) - command.func = None - self._application_commands[command.id] = command - else: - command._fill_data(updated) - command._state = state - self._application_commands[command.id] = command - - log.info('Checking for changes on guild-specific application-commands...') - - any_guild_commands_changed = False - self._minimal_registered_guild_commands_raw = minimal_registered_guild_commands_raw = {} - - for guild_id, command_types in self._guild_specific_application_commands.items(): - to_send = [] - to_cep = [] - to_maybe_remove = [] - any_changed = False - has_update = False - try: - registered_guild_commands_raw = await self.http.get_application_commands( - application_id, - guild_id=guild_id - ) - except HTTPException: - warnings.warn( - 'Missing access to guild %s or don\'t have the application.commands scope in there, skipping!' - % guild_id - ) - continue - minimal_registered_guild_commands_raw[int(guild_id)] = minimal_registered_guild_commands = [] - registered_guild_commands = ApplicationCommand._sorted_by_type(registered_guild_commands_raw) - - for x, commands in registered_guild_commands.items(): - for command in commands: - if command['name'] in self._guild_specific_application_commands[guild_id][x].keys(): - cmd = self._guild_specific_application_commands[guild_id][x][command['name']] - if cmd != command: - any_changed = has_update = any_guild_commands_changed = True - c = cmd.to_dict() - c['id'] = command['id'] - to_send.append(c) - else: - to_cep.append(command) - else: - to_maybe_remove.append(command) - any_changed = True - cmd_names = [c['name'] for c in commands] - for command in self._guild_specific_application_commands[guild_id][x].values(): - if command.name not in cmd_names: - any_changed = True - to_send.append(command.to_dict()) - - if any_changed is True: - updated = None - if len(to_send) == 1 and has_update and not to_maybe_remove: - log.info( - 'Detected changes on application-command %s in guild %s (%s), updating.', - to_send[0]['name'], - self.get_guild(int(guild_id)), - guild_id - ) - updated = await self.http.edit_application_command( - application_id, - to_send[0]['id'], - to_send[0], - guild_id - ) - elif len(to_send) == 1 and not has_update and not to_maybe_remove: - log.info( - 'Registering one new application-command %s in guild %s (%s).', - to_send[0]['name'], - self.get_guild(int(guild_id)), - guild_id - ) - updated = await self.http.create_application_command(application_id, to_send[0], guild_id) - else: - if not self.delete_not_existing_commands: - if to_send: - to_send.extend(to_maybe_remove) - else: - if len(to_maybe_remove) > 0: - log.info( - 'Removing %s application-command(s) from guild %s (%s) that isn\'t/arent used in this code anymore.' - 'To prevent this set `delete_not_existing_commands` of %s to False', - len(to_maybe_remove), - self.get_guild(int(guild_id)), - guild_id, - self.__class__.__name__ - ) - if len(to_send) != 0: - log.info( - 'Detected %s updated/new application-command(s) for guild %s (%s), bulk overwriting them...', - len(to_send), - self.get_guild(int(guild_id)), - guild_id - ) - to_send.extend(to_cep) - registered_guild_commands_raw = await self.http.bulk_overwrite_application_commands( - application_id, - to_send, - guild_id - ) - if updated: - registered_guild_commands_raw = await self.http.get_application_commands( - application_id, - guild_id=guild_id - ) - log.info('Synced application-commands for %s (%s).' % (guild_id, self.get_guild(int(guild_id)))) - any_guild_commands_changed = True - - for updated in registered_guild_commands_raw: - command_type = str(ApplicationCommandType.try_value(updated['type'])) - minimal_registered_guild_commands.append({'id': int(updated['id']), 'type': command_type, 'name': updated['name']}) - try: - command = self._guild_specific_application_commands[int(guild_id)][command_type][updated['name']] - except KeyError: - command = ApplicationCommand._from_type(state, data=updated) - command.func = None - self._application_commands[command.id] = command - self.get_guild(int(guild_id))._application_commands[command.id] = command - else: - command._fill_data(updated) - command._state = self._connection - self._application_commands[command.id] = command - - if not any_guild_commands_changed: - log.info('No changes on guild-specific application-commands found.') - - log.info('Successful synced all global and guild-specific application-commands.') - - def _get_application_command(self, cmd_id: int) -> Optional[ApplicationCommand]: - return self._application_commands.get(cmd_id) - - def _remove_application_command(self, command: ApplicationCommand, from_cache: bool = True): - if isinstance(command, GuildOnlySlashCommand): - for guild_id in command.guild_ids: - try: - cmd = self._guild_specific_application_commands[guild_id][command.type.name][command.name] - except KeyError: - continue - else: - if from_cache: - del cmd - else: - cmd.disabled = True - self._application_commands[cmd.id] = copy.copy(cmd) - del cmd - del command - else: - if from_cache: - del command - else: - command.disabled = True - self._application_commands[command.id] = copy.copy(command) - if command.guild_id: - self._guild_specific_application_commands[command.guild_id][command.type.name].pop(command.name, None) - else: - self._application_commands_by_type[command.type.name].pop(command.name, None) - - @property - def application_commands(self) -> List[ApplicationCommand]: - """List[:class:`ApplicationCommand`]: Returns a list of any application command that is registered for the bot`""" - return list(self._application_commands.values()) - - @property - def global_application_commands(self) -> List[ApplicationCommand]: - """ - Returns a list of all global application commands that are registered for the bot - - .. note:: - This requires the bot running and all commands cached, otherwise the list will be empty - - Returns - -------- - List[:class:`ApplicationCommand`] - A list of registered global application commands for the bot - """ - commands = [] - for command in self.application_commands: - if not command.guild_id: - commands.append(command) - return commands - - async def change_presence( - self, - *, - activity: Optional[BaseActivity] = None, - status: Optional[Status] = 'online' - ) -> None: - """|coro| - - Changes the client's presence. - - .. versionchanged:: 2.0 - Removed the ``afk`` parameter - - Example - --------- - - .. code-block:: python3 - - game = discord.Game("with the API") - await client.change_presence(status=discord.Status.idle, activity=game) - - Parameters - ---------- - activity: Optional[:class:`.BaseActivity`] - The activity being done. ``None`` if no currently active activity is done. - status: Optional[:class:`.Status`] - Indicates what status to change to. If ``None``, then - :attr:`.Status.online` is used. - - Raises - ------ - :exc:`.InvalidArgument` - If the ``activity`` parameter is not the proper type. - """ - - if status is None: - status = 'online' - status_enum = Status.online - elif status is Status.offline: - status = 'invisible' - status_enum = Status.offline - else: - status_enum = status - status = str(status) - - await self.ws.change_presence(activity=activity, status=status) - - for guild in self._connection.guilds: - me = guild.me - if me is None: - continue - - if activity is not None: - me.activities = (activity,) - else: - me.activities = () - - me.status = status_enum - - # Guild stuff - - def fetch_guilds( - self, - *, - limit: Optional[int] = 100, - before: Union[Snowflake, datetime.datetime, None] = None, - after: Union[Snowflake, datetime.datetime, None] = None - ) -> GuildIterator: - """Retrieves an :class:`.AsyncIterator` that enables receiving your guilds. - - .. note:: - - Using this, you will only receive :attr:`.Guild.owner`, :attr:`.Guild.icon`, - :attr:`.Guild.id`, and :attr:`.Guild.name` per :class:`.Guild`. - - .. note:: - - This method is an API call. For general usage, consider :attr:`guilds` instead. - - Examples - --------- - - Usage :: - - async for guild in client.fetch_guilds(limit=150): - print(guild.name) - - Flattening into a list :: - - guilds = await client.fetch_guilds(limit=150).flatten() - # guilds is now a list of Guild... - - All parameters are optional. - - Parameters - ----------- - limit: Optional[:class:`int`] - The number of guilds to retrieve. - If ``None``, it retrieves every guild you have access to. Note, however, - that this would make it a slow operation. - Defaults to ``100``. - before: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] - Retrieves guilds before this date or object. - If a date is provided it must be a timezone-naive datetime representing UTC time. - after: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] - Retrieve guilds after this date or object. - If a date is provided it must be a timezone-naive datetime representing UTC time. - - Raises - ------ - discord.HTTPException - Getting the guilds failed. - - Yields - -------- - :class:`.Guild` - The guild with the guild data parsed. - """ - return GuildIterator(self, limit=limit, before=before, after=after) - - async def fetch_template(self, code: Union[Template, str]) -> Template: - """|coro| - - Gets a :class:`.Template` from a discord.new URL or code. - - Parameters - ----------- - code: Union[:class:`.Template`, :class:`str`] - The Discord Template Code or URL (must be a discord.new URL). - - Raises - ------- - :exc:`.NotFound` - The template is invalid. - :exc:`.HTTPException` - Getting the template failed. - - Returns - -------- - :class:`.Template` - The template from the URL/code. - """ - code = utils.resolve_template(code) - data = await self.http.get_template(code) - return Template(data=data, state=self._connection) - - async def fetch_guild(self, guild_id: int) -> Guild: - """|coro| - - Retrieves a :class:`.Guild` from an ID. - - .. note:: - - Using this, you will **not** receive :attr:`.Guild.channels`, :attr:`.Guild.members`, - :attr:`.Member.activity` and :attr:`.Member.voice` per :class:`.Member`. - - .. note:: - - This method is an API call. For general usage, consider :meth:`get_guild` instead. - - Parameters - ----------- - guild_id: :class:`int` - The guild's ID to fetch from. - - Raises - ------ - :exc:`.Forbidden` - You do not have access to the guild. - :exc:`.HTTPException` - Getting the guild failed. - - Returns - -------- - :class:`.Guild` - The guild from the ID. - """ - data = await self.http.get_guild(guild_id) - return Guild(data=data, state=self._connection) - - async def create_guild( - self, - name: str, - region: Optional[VoiceRegion] = None, - icon: Optional[bytes] = None, - *, - code: Optional[str] = None - ) -> Guild: - """|coro| - - Creates a :class:`.Guild`. - - Bot accounts in more than 10 guilds are not allowed to create guilds. - - Parameters - ---------- - name: :class:`str` - The name of the guild. - region: :class:`.VoiceRegion` - The region for the voice communication server. - Defaults to :attr:`.VoiceRegion.us_west`. - icon: :class:`bytes` - The :term:`py:bytes-like object` representing the icon. See :meth:`.ClientUser.edit` - for more details on what is expected. - code: Optional[:class:`str`] - The code for a template to create the guild with. - - .. versionadded:: 1.4 - - Raises - ------ - :exc:`.HTTPException` - Guild creation failed. - :exc:`.InvalidArgument` - Invalid icon image format given. Must be PNG or JPG. - - Returns - ------- - :class:`.Guild` - The guild created. This is not the same guild that is - added to cache. - """ - if icon is not None: - icon = utils._bytes_to_base64_data(icon) - - region = region or VoiceRegion.us_west - region_value = region.value - - if code: - data = await self.http.create_from_template(code, name, region_value, icon) - else: - data = await self.http.create_guild(name, region_value, icon) - return Guild(data=data, state=self._connection) - - # Invite management - - async def fetch_invite(self, url: Union[Invite, str], *, with_counts: bool = True) -> Invite: - """|coro| - - Gets an :class:`.Invite` from a discord.gg URL or ID. - - .. note:: - - If the invite is for a guild you have not joined, the guild and channel - attributes of the returned :class:`.Invite` will be :class:`.PartialInviteGuild` and - :class:`.PartialInviteChannel` respectively. - - Parameters - ----------- - url: Union[:class:`.Invite`, :class:`str`] - The Discord invite ID or URL (must be a discord.gg URL). - with_counts: :class:`bool` - Whether to include count information in the invite. This fills the - :attr:`.Invite.approximate_member_count` and :attr:`.Invite.approximate_presence_count` - fields. - - Raises - ------- - :exc:`.NotFound` - The invite has expired or is invalid. - :exc:`.HTTPException` - Getting the invite failed. - - Returns - -------- - :class:`.Invite` - The invite from the URL/ID. - """ - - invite_id = utils.resolve_invite(url) - data = await self.http.get_invite(invite_id, with_counts=with_counts) - return Invite.from_incomplete(state=self._connection, data=data) - - async def delete_invite(self, invite: Union[Invite, str]) -> None: - """|coro| - - Revokes an :class:`.Invite`, URL, or ID to an invite. - - You must have the :attr:`~.Permissions.manage_channels` permission in - the associated guild to do this. - - Parameters - ---------- - invite: Union[:class:`.Invite`, :class:`str`] - The invite to revoke. - - Raises - ------- - :exc:`.Forbidden` - You do not have permissions to revoke invites. - :exc:`.NotFound` - The invite is invalid or expired. - :exc:`.HTTPException` - Revoking the invite failed. - """ - - invite_id = utils.resolve_invite(invite) - await self.http.delete_invite(invite_id) - - # Miscellaneous stuff - - async def fetch_widget(self, guild_id: int) -> Widget: - """|coro| - - Gets a :class:`.Widget` from a guild ID. - - .. note:: - - The guild must have the widget enabled to get this information. - - Parameters - ----------- - guild_id: :class:`int` - The ID of the guild. - - Raises - ------- - :exc:`.Forbidden` - The widget for this guild is disabled. - :exc:`.HTTPException` - Retrieving the widget failed. - - Returns - -------- - :class:`.Widget` - The guild's widget. - """ - data = await self.http.get_widget(guild_id) - - return Widget(state=self._connection, data=data) - - async def application_info(self) -> AppInfo: - """|coro| - - Retrieves the bot's application information. - - Raises - ------- - :exc:`.HTTPException` - Retrieving the information failed somehow. - - Returns - -------- - :class:`.AppInfo` - The bot's application information. - """ - data = await self.http.application_info() - if 'rpc_origins' not in data: - data['rpc_origins'] = None - self.app = app = AppInfo(state=self._connection, data=data) - return app - - async def fetch_user(self, user_id): - """|coro| - - Retrieves a :class:`~discord.User` based on their ID. This can only - be used by bot accounts. You do not have to share any guilds - with the user to get this information, however many operations - do require that you do. - - .. note:: - - This method is an API call. If you have :attr:`Intents.members` and member cache enabled, consider :meth:`get_user` instead. - - Parameters - ----------- - user_id: :class:`int` - The user's ID to fetch from. - - Raises - ------- - :exc:`.NotFound` - A user with this ID does not exist. - :exc:`.HTTPException` - Fetching the user failed. - - Returns - -------- - :class:`~discord.User` - The user you requested. - """ - data = await self.http.get_user(user_id) - return User(state=self._connection, data=data) - - async def fetch_channel(self, channel_id: int): - """|coro| - - Retrieves a :class:`.abc.GuildChannel` or :class:`.abc.PrivateChannel` with the specified ID. - - .. note:: - - This method is an API call. For general usage, consider :meth:`get_channel` instead. - - .. versionadded:: 1.2 - - Raises - ------- - :exc:`.InvalidData` - An unknown channel type was received from Discord. - :exc:`.HTTPException` - Retrieving the channel failed. - :exc:`.NotFound` - Invalid Channel ID. - :exc:`.Forbidden` - You do not have permission to fetch this channel. - - Returns - -------- - Union[:class:`.abc.GuildChannel`, :class:`.abc.PrivateChannel`] - The channel from the ID. - """ - data = await self.http.get_channel(channel_id) - - factory, ch_type = _channel_factory(data['type']) - if factory is None: - raise InvalidData('Unknown channel type {type} for channel ID {id}.'.format_map(data)) - - if ch_type in (ChannelType.group, ChannelType.private): - channel = factory(me=self.user, data=data, state=self._connection) - else: - guild_id = int(data['guild_id']) - guild = self.get_guild(guild_id) or Object(id=guild_id) - channel = factory(guild=guild, state=self._connection, data=data) - - return channel - - async def fetch_webhook(self, webhook_id: int): - """|coro| - - Retrieves a :class:`.Webhook` with the specified ID. - - Raises - -------- - :exc:`.HTTPException` - Retrieving the webhook failed. - :exc:`.NotFound` - Invalid webhook ID. - :exc:`.Forbidden` - You do not have permission to fetch this webhook. - - Returns - --------- - :class:`.Webhook` - The webhook you requested. - """ - data = await self.http.get_webhook(webhook_id) - return Webhook.from_state(data, state=self._connection) - - async def fetch_all_nitro_stickers(self) -> List[StickerPack]: - """|coro| - - Retrieves a :class:`list` with all build-in :class:`~discord.StickerPack` 's. - - Returns - -------- - :class:`~discord.StickerPack` - A list containing all build-in sticker-packs. - """ - data = await self.http.get_all_nitro_stickers() - packs = [StickerPack(state=self._connection, data=d) for d in data['sticker_packs']] - return packs - - async def fetch_voice_regions(self) -> List[VoiceRegionInfo]: - """|coro| - - Returns a list of :class:`.VoiceRegionInfo` that can be used when creating or editing a - :attr:`VoiceChannel` or :attr:`StageChannel`\'s region. - - .. note:: - - This method is an API call. - For general usage, consider using the :class:`VoiceRegion` enum instead. - - Returns - -------- - List[:class:`.VoiceRegionInfo`] - The voice regions that can be used. - """ - data = await self.http.get_voice_regions() - return [VoiceRegionInfo(data=d) for d in data] - - async def create_test_entitlement( - self, - sku_id: int, - target: Union[User, Guild, Snowflake], - owner_type: Optional[Literal['guild', 'user']] = MISSING - ) -> Entitlement: - """|coro| - - .. note:: - - This method is only temporary and probably will be removed with or even before a stable v2 release - as discord is already redesigning the testing system based on developer feedback. - - See https://github.com/discord/discord-api-docs/pull/6502 for more information. - - Creates a test entitlement to a given :class:`SKU` for a given guild or user. - Discord will act as though that user or guild has entitlement to your premium offering. - - After creating a test entitlement, you'll need to reload your Discord client. - After doing so, you'll see that your server or user now has premium access. - - Parameters - ---------- - sku_id: :class:`int` - The ID of the SKU to create a test entitlement for. - target: Union[:class:`User`, :class:`Guild`, :class:`Snowflake`] - The target to create a test entitlement for. - - This can be a user, guild or just the ID, if so the owner_type parameter must be set. - owner_type: :class:`str` - The type of the ``target``, could be ``guild`` or ``user``. - - Returns - -------- - :class:`Entitlement` - The created test entitlement. - """ - target = target.id - - if isinstance(target, Guild): - owner_type = 1 - elif isinstance(target, User): - owner_type = 2 - else: - if owner_type is MISSING: - raise TypeError('owner_type must be set if target is not a Guild or user-like object.') - else: - owner_type = 1 if owner_type == 'guild' else 2 - - data = await self.http.create_test_entitlement( - self.app.id, - sku_id=sku_id, - owner_id=target, - owner_type=owner_type - ) - return Entitlement(data=data, state=self._connection) - - async def delete_test_entitlement(self, entitlement_id: int) -> None: - """|coro| - - .. note:: - - This method is only temporary and probably will be removed with or even before a stable v2 release - as discord is already redesigning the testing system based on developer feedback. - - See https://github.com/discord/discord-api-docs/pull/6502 for more information. - - Deletes a currently-active test entitlement. - Discord will act as though that user or guild no longer has entitlement to your premium offering. - - Parameters - ---------- - entitlement_id: :class:`int` - The ID of the entitlement to delete. - """ - await self.http.delete_test_entitlement(self.app.id, entitlement_id) - - async def fetch_entitlements( - self, - *, - limit: int = 100, - user: Optional[User] = None, - guild: Optional[Guild] = None, - sku_ids: Optional[List[int]] = None, - before: Optional[Union[datetime.datetime, Snowflake]] = None, - after: Optional[Union[datetime.datetime, Snowflake]] = None, - exclude_ended: bool = False - ) -> EntitlementIterator: - """|coro| - - Parameters - ---------- - limit: :class:`int` - The maximum amount of entitlements to fetch. - Defaults to ``100``. - user: Optional[:class:`User`] - The user to fetch entitlements for. - guild: Optional[:class:`Guild`] - The guild to fetch entitlements for. - sku_ids: Optional[List[:class:`int`]] - Optional list of SKU IDs to check entitlements for - before: Optional[Union[:class:`datetime.datetime`, :class:`Snowflake`]] - Retrieve entitlements before this date or object. - If a date is provided it must be a timezone-naive datetime representing UTC time. - after: Optional[Union[:class:`datetime.datetime`, :class:`Snowflake`]] - Retrieve entitlements after this date or object. - If a date is provided it must be a timezone-naive datetime representing UTC time. - exclude_ended: :class:`bool` - Whether ended entitlements should be fetched or not. Defaults to ``False``. - - Return - ------ - :class:`AsyncIterator` - An iterator to fetch all entitlements for the current application. - """ - return EntitlementIterator( - state=self._connection, - limit=limit, - user_id=user.id, - guild_id=guild.id, - sku_ids=sku_ids, - before=before, - after=after, - exclude_ended=exclude_ended - ) - - async def consume_entitlement(self, entitlement_id: int) -> None: - """|coro| - - For one-time purchase :attr:`~discord.SKUType.consumable` SKUs, - marks a given entitlement for the user as consumed. - :attr:`~discord.Entitlement.consumed` will be ``False`` for this entitlement - when using :meth:`.fetch_entitlements`. - - Parameters - ---------- - entitlement_id: :class:`int` - The ID of the entitlement to consume. - """ - await self.http.consume_entitlement(self.app.id, entitlement_id) - - async def update_primary_entry_point_command( - self, - name: Optional[str] = MISSING, - name_localizations: Localizations = MISSING, - description: Optional[str] = MISSING, - description_localizations: Localizations = MISSING, - default_required_permissions: Optional[Permissions] = MISSING, - allowed_contexts: Optional[List[InteractionContextType]] = MISSING, - allowed_integration_types: Optional[List[AppIntegrationType]] = MISSING, - is_nsfw: bool = MISSING, - handler: EntryPointHandlerType = MISSING - ): - """|coro| - - Update the :ddocs:`primary entry point command `_ of the application. - - If you don't want to handle the command, set ``handler`` to :attr:`EntryPointHandlerType.discord` - - Parameters - ---------- - name: Optional[:class:`str`] - The name of the activity command. - name_localizations: :class:`Localizations` - Localized ``name``'s. - description: Optional[:class:`str`] - The description of the activity command. - description_localizations: :class:`Localizations` - Localized ``description``'s. - default_required_permissions: Optional[:class:`Permissions`] - Permissions that a member needs by default to execute(see) the command. - allowed_contexts: Optional[List[:class:`~discord.InteractionContextType`]] - The contexts in which the command is available. - By default, commands are available in all contexts. - allowed_integration_types: Optional[List[:class:`~discord.AppIntegrationType`]] - The types of app integrations where the command is available. - Default to the app's :ddocs:`configured integration types ` - is_nsfw: :class:`bool` - Whether this command is an :sup-art:`NSFW command <10123937946007-Age-Restricted-Commands>` , default :obj:`False`. - handler: :class:`EntryPointHandlerType` - The handler for the primary entry point command. - Default to :attr:`EntryPointHandlerType.discord`, unless :attr:`.activity_primary_entry_point_command` is set. - - Returns - ------- - :class:`~discord.ActivityEntryPointCommand` - The updated primary entry point command. - - Raises - ------ - :exc:`~discord.HTTPException` - Editing the command failed. - """ - - try: - primary_entry_point_command = list(self._application_commands_by_type['primary_entry_point'].values())[0] - except IndexError: - # Request global application commands to get the primary entry point command - - # Update this if discord adds a way to get the primary entry point command directly - commands_raw = await self.http.get_application_commands(self.app.id) - commands_sorted = ApplicationCommand._sorted_by_type(commands_raw) - try: - primary_entry_point_command = commands_sorted['primary_entry_point'][0] - except IndexError: - # create a primary entry point command if it does not exist - primary_entry_point_command = ActivityEntryPointCommand( - name='launch' if name is MISSING else name, - name_localizations=None if name_localizations is MISSING else name_localizations, - description='launch tis activity' if description is MISSING else description, - description_localizations=None if description_localizations is MISSING else description_localizations, - default_member_permissions=None if default_required_permissions is MISSING else default_required_permissions, - contexts=None if allowed_contexts is MISSING else allowed_contexts, - integration_types=None if allowed_integration_types is MISSING else allowed_integration_types, - is_nsfw=False if is_nsfw is MISSING else is_nsfw, - handler=EntryPointHandlerType.discord if handler is MISSING else handler - ) - - self._application_commands_by_type['primary_entry_point'][primary_entry_point_command.name] = primary_entry_point_command - - # Register the primary entry point command - data = await self.http.create_application_command( - self.app.id, - primary_entry_point_command.to_dict() - ) - primary_entry_point_command._fill_data(data) - self._application_commands[primary_entry_point_command.id] = primary_entry_point_command - return primary_entry_point_command - - data = {} - if name is not MISSING: - data['name'] = name - if name_localizations is not MISSING: - data['name_localizations'] = name_localizations.to_dict() if name_localizations else None - if description is not MISSING: - data['description'] = description - if description_localizations is not MISSING: - data['description_localizations'] = description_localizations.to_dict() if description_localizations else None - if default_required_permissions is not MISSING: - data['default_member_permissions'] = default_required_permissions.value - if allowed_contexts is not MISSING: - data['contexts'] = [ctx.value for ctx in allowed_contexts] - if allowed_integration_types is not MISSING: - data['integration_types'] = [integration_type.value for integration_type in allowed_integration_types] - if is_nsfw is not MISSING: - data['is_nsfw'] = is_nsfw - if handler is not MISSING: - data['handler'] = handler.value - - response = await self.http.edit_application_command(self.app.id, primary_entry_point_command.id, data) - new_command = primary_entry_point_command.from_dict(self._connection, response) - - if (callback := primary_entry_point_command.func) and handler != EntryPointHandlerType.discord: - new_command.func = callback - - self._application_commands_by_type['primary_entry_point'][new_command.name] = new_command - self._application_commands[new_command.id] = new_command - return new_command +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz & (c) 2021-present mccoderpy + +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. +""" +from __future__ import annotations + +import aiohttp +import asyncio +import copy +import inspect +import logging +import signal +import sys +import re +import traceback +import warnings + +from typing import ( + Any, + Dict, + List, + Union, + Tuple, + AnyStr, + TypeVar, + Iterator, + Optional, + Callable, + Awaitable, + Coroutine, + TYPE_CHECKING +) + +from typing_extensions import Literal + +from .application_commands import ActivityEntryPointCommand +from .auto_updater import AutoUpdateChecker +from .sticker import StickerPack +from .user import ClientUser, User +from .invite import Invite +from .template import Template +from .widget import Widget +from .guild import Guild +from .channel import _channel_factory, PartialMessageable +from .enums import ChannelType, ApplicationCommandType, Locale, InteractionContextType, AppIntegrationType, \ + EntryPointHandlerType +from .mentions import AllowedMentions +from .monetization import Entitlement, SKU +from .errors import * +from .enums import Status, VoiceRegion +from .gateway import * +from .activity import BaseActivity, create_activity +from .voice_client import VoiceRegionInfo, VoiceClient +from .http import HTTPClient +from .state import ConnectionState +from . import utils +from .object import Object +from .backoff import ExponentialBackoff +from .webhook import Webhook +from .iterators import GuildIterator, EntitlementIterator +from .appinfo import AppInfo +from .application_commands import * +from .soundboard import SoundboardSound + +if TYPE_CHECKING: + import datetime + from re import Pattern + + from .abc import ( + GuildChannel, + Messageable, + PrivateChannel, + VoiceProtocol, + Snowflake + ) + from .components import Button, Select + from .emoji import Emoji + from .flags import Intents + from .interactions import ApplicationCommandInteraction, ComponentInteraction, ModalSubmitInteraction + from .member import Member + from .message import Message + from .permissions import Permissions + from .sticker import Sticker + + _ClickCallback = Callable[[ComponentInteraction, Button], Coroutine[Any, Any, Any]] + _SelectCallback = Callable[[ComponentInteraction, Select], Coroutine[Any, Any, Any]] + _SubmitCallback = Callable[[ModalSubmitInteraction], Coroutine[Any, Any, Any]] + + +T = TypeVar('T') +Coro = TypeVar('Coro', bound=Callable[..., Coroutine[Any, Any, Any]]) + +log = logging.getLogger(__name__) +MISSING = utils.MISSING + +__all__ = ( + 'Client', +) + + +def _cancel_tasks(loop): + try: + task_retriever = asyncio.Task.all_tasks + except AttributeError: + # future proofing for 3.9 I guess + task_retriever = asyncio.all_tasks + + tasks = {t for t in task_retriever(loop=loop) if not t.done()} + + if not tasks: + return + + log.info('Cleaning up after %d tasks.', len(tasks)) + for task in tasks: + task.cancel() + + loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) + log.info('All tasks finished cancelling.') + + for task in tasks: + if task.cancelled(): + continue + if task.exception() is not None: + loop.call_exception_handler({ + 'message': 'Unhandled exception during Client.run shutdown.', + 'exception': task.exception(), + 'task': task + }) + + +def _cleanup_loop(loop): + try: + _cancel_tasks(loop) + if sys.version_info >= (3, 6): + loop.run_until_complete(loop.shutdown_asyncgens()) + finally: + log.info('Closing the event loop.') + loop.close() + + +class _ClientEventTask(asyncio.Task): + def __init__(self, original_coro, event_name, coro, *, loop): + super().__init__(coro, loop=loop) + self.__event_name = event_name + self.__original_coro = original_coro + + def __repr__(self): + info = [ + ('state', self._state.lower()), + ('event', self.__event_name), + ('coro', repr(self.__original_coro)), + ] + if self._exception is not None: + info.append(('exception', repr(self._exception))) + return ''.format(' '.join('%s=%s' % t for t in info)) + + +class Client: + r"""Represents a client connection that connects to Discord. + This class is used to interact with the Discord WebSocket and API. + + A number of options can be passed to the :class:`Client`. + + Parameters + ----------- + max_messages: Optional[:class:`int`] + The maximum number of messages to store in the internal message cache. + This defaults to ``1000``. Passing in ``None`` disables the message cache. + + .. versionchanged:: 1.3 + Allow disabling the message cache and change the default size to ``1000``. + loop: Optional[:class:`asyncio.AbstractEventLoop`] + The :class:`asyncio.AbstractEventLoop` to use for asynchronous operations. + Defaults to ``None``, in which case the default event loop is used via + :func:`asyncio.get_event_loop()`. + connector: :class:`aiohttp.BaseConnector` + The connector to use for connection pooling. + proxy: Optional[:class:`str`] + Proxy URL. + proxy_auth: Optional[:class:`aiohttp.BasicAuth`] + An object that represents proxy HTTP Basic Authorization. + shard_id: Optional[:class:`int`] + Integer starting at ``0`` and less than :attr:`.shard_count`. + shard_count: Optional[:class:`int`] + The total number of shards. + intents: :class:`Intents` + The intents that you want to enable for the _session. This is a way of + disabling and enabling certain gateway events from triggering and being sent. + If not given, defaults to a regularly constructed :class:`Intents` class. + gateway_version: :class:`int` + The gateway and api version to use. Defaults to ``v10``. + api_error_locale: :class:`discord.Locale` + The locale language to use for api errors. This will be applied to the ``X-Discord-Local`` header in requests. + Default to :attr:`Locale.en_US` + member_cache_flags: :class:`MemberCacheFlags` + Allows for finer control over how the library caches members. + If not given, defaults to cache as much as possible with the + currently selected intents. + fetch_offline_members: :class:`bool` + A deprecated alias of ``chunk_guilds_at_startup``. + chunk_guilds_at_startup: :class:`bool` + Indicates if :func:`.on_ready` should be delayed to chunk all guilds + at start-up if necessary. This operation is incredibly slow for large + amounts of guilds. The default is ``True`` if :attr:`Intents.members` + is ``True``. + status: Optional[:class:`.Status`] + A status to start your presence with upon logging on to Discord. + activity: Optional[:class:`.BaseActivity`] + An activity to start your presence with upon logging on to Discord. + allowed_mentions: Optional[:class:`AllowedMentions`] + Control how the client handles mentions by default on every message sent. + heartbeat_timeout: :class:`float` + The maximum numbers of seconds before timing out and restarting the + WebSocket in the case of not receiving a HEARTBEAT_ACK. Useful if + processing the initial packets take too long to the point of disconnecting + you. The default timeout is 60 seconds. + guild_ready_timeout: :class:`float` + The maximum number of seconds to wait for the GUILD_CREATE stream to end before + preparing the member cache and firing READY. The default timeout is 2 seconds. + + .. versionadded:: 1.4 + guild_subscriptions: :class:`bool` + Whether to dispatch presence or typing events. Defaults to :obj:`True`. + + .. versionadded:: 1.3 + + .. warning:: + + If this is set to :obj:`False` then the following features will be disabled: + + - No user related updates (:func:`on_user_update` will not dispatch) + - All member related events will be disabled. + - :func:`on_member_update` + - :func:`on_member_join` + - :func:`on_member_remove` + + - Typing events will be disabled (:func:`on_typing`). + - If ``fetch_offline_members`` is set to ``False`` then the user cache will not exist. + This makes it difficult or impossible to do many things, for example: + + - Computing permissions + - Querying members in a voice channel via :attr:`VoiceChannel.members` will be empty. + - Most forms of receiving :class:`Member` will be + receiving :class:`User` instead, except for message events. + - :attr:`Guild.owner` will usually resolve to ``None``. + - :meth:`Guild.get_member` will usually be unavailable. + - Anything that involves using :class:`Member`. + - :attr:`users` will not be as populated. + - etc. + + In short, this makes it so the only member you can reliably query is the + message author. Useful for bots that do not require any state. + assume_unsync_clock: :class:`bool` + Whether to assume the system clock is unsynced. This applies to the ratelimit handling + code. If this is set to ``True``, the default, then the library uses the time to reset + a rate limit bucket given by Discord. If this is ``False`` then your system clock is + used to calculate how long to sleep for. If this is set to ``False`` it is recommended to + sync your system clock to Google's NTP server. + + .. versionadded:: 1.3 + + sync_commands: :class:`bool` + Whether to sync application-commands on startup, default :obj:`False`. + + This will register global and guild application-commands(slash-, user- and message-commands) + that are not registered yet, update changes and remove application-commands that could not be found + in the code anymore if :attr:`delete_not_existing_commands` is set to :obj:`True` what it is by default. + + delete_not_existing_commands: :class:`bool` + Whether to remove global and guild-only application-commands that are not in the code anymore, default :obj:`True`. + + auto_check_for_updates: :class:`bool` + Whether to check for available updates automatically, default :obj:`False` for legal reasons. + For more info see :func:`discord.on_update_available`. + + .. note:: + + For now, this may only work on the original repository, **not on forks**. + This is because it uses an internal API that listens to a private application that is on the original repo. + + In the future this API might be open-sourced, or it will be possible to add your forks URL as a valid source. + + Attributes + ----------- + ws + The websocket gateway the client is currently connected to. Could be ``None``. + loop: :class:`asyncio.AbstractEventLoop` + The event loop that the client uses for HTTP requests and websocket operations. + """ + def __init__(self, *, loop: Optional[asyncio.AbstractEventLoop] = None, **options): + self.ws: DiscordWebSocket = None + self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() if loop is None else loop + self._listeners = {} + self.sync_commands: bool = options.get('sync_commands', False) + self.delete_not_existing_commands: bool = options.get('delete_not_existing_commands', True) + self._application_commands_by_type: Dict[ + str, + Dict[str, Union[SlashCommand, UserCommand, MessageCommand, ActivityEntryPointCommand]] + ] = { + 'chat_input': {}, 'message': {}, 'user': {}, 'primary_entry_point': {} + } + self._guild_specific_application_commands: Dict[ + int, Dict[str, Dict[str, Union[SlashCommand, UserCommand, MessageCommand]]]] = {} + self._application_commands: Dict[int, ApplicationCommand] = {} + self.shard_id = options.get('shard_id') + self.shard_count = options.get('shard_count') + + connector = options.pop('connector', None) + proxy = options.pop('proxy', None) + proxy_auth = options.pop('proxy_auth', None) + unsync_clock = options.pop('assume_unsync_clock', True) + self.gateway_version: int = options.get('gateway_version', 10) + self.api_error_locale: Locale = options.pop('api_error_locale', 'en-US') + self.auto_check_for_updates: bool = options.pop('auto_check_for_updates', False) + self.http = HTTPClient( + connector, + proxy=proxy, + proxy_auth=proxy_auth, + unsync_clock=unsync_clock, + loop=self.loop, + api_version=self.gateway_version, + api_error_locale=self.api_error_locale + ) + + self._handlers = { + 'ready': self._handle_ready, + 'connect': lambda: self._ws_connected.set(), + 'resumed': lambda: self._ws_connected.set() + } + + self._hooks = { + 'before_identify': self._call_before_identify_hook + } + + self._connection = self._get_state(**options) + self._connection.shard_count = self.shard_count + self._closed = False + self._ready = asyncio.Event() + self._ws_connected = asyncio.Event() + self._connection._get_websocket = self._get_websocket + self._connection._get_client = lambda: self + + if VoiceClient.warn_nacl: + VoiceClient.warn_nacl = False + log.warning("PyNaCl is not installed, voice will NOT be supported") + if self.auto_check_for_updates: + self._auto_update_checker: Optional[AutoUpdateChecker] = AutoUpdateChecker(client=self) + else: + self._auto_update_checker: Optional[AutoUpdateChecker] = None + + # internals + def _get_websocket(self, guild_id=None, *, shard_id=None): + return self.ws + + def _get_state(self, **options): + return ConnectionState(dispatch=self.dispatch, handlers=self._handlers, + hooks=self._hooks, syncer=self._syncer, http=self.http, loop=self.loop, **options) + + async def _syncer(self, guilds): + await self.ws.request_sync(guilds) + + def _handle_ready(self): + self._ready.set() + + @property + def latency(self) -> float: + """:class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. + + This could be referred to as the Discord WebSocket protocol latency. + """ + ws = self.ws + return float('nan') if not ws else ws.latency + + def is_ws_ratelimited(self) -> bool: + """: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) -> ClientUser: + """Optional[:class:`.ClientUser`]: Represents the connected client. ``None`` if not logged in.""" + return self._connection.user + + @property + def guilds(self) -> List[Guild]: + """List[:class:`.Guild`]: The guilds that the connected client is a member of.""" + return self._connection.guilds + + @property + def emojis(self) -> List[Emoji]: + """List[:class:`.Emoji`]: The emojis that the connected client has.""" + return self._connection.emojis + + @property + def stickers(self) -> List[Sticker]: + """List[:class:`.Sticker`]: The stickers that the connected client has.""" + return self._connection.stickers + + @property + def cached_messages(self) -> utils.SequenceProxy[Message]: + """Sequence[:class:`.Message`]: Read-only list of messages the connected client has cached. + + .. versionadded:: 1.1 + """ + return utils.SequenceProxy(self._connection._messages or []) + + @property + def private_channels(self) -> List[PrivateChannel]: + """List[:class:`.abc.PrivateChannel`]: The private channels that the connected client is participating on. + + .. note:: + + This returns only up to 128 most recent private channels due to an internal working + on how Discord deals with private channels. + """ + return self._connection.private_channels + + @property + def voice_clients(self) -> List[VoiceProtocol]: + """List[:class:`.VoiceProtocol`]: Represents a list of voice connections. + + These are usually :class:`.VoiceClient` instances. + """ + return self._connection.voice_clients + + def is_ready(self) -> bool: + """:class:`bool`: Specifies if the client's internal cache is ready for use.""" + return self._ready.is_set() + + async def _run_event(self, coro: Coro, event_name: str, *args, **kwargs): + try: + await coro(*args, **kwargs) + except asyncio.CancelledError: + pass + except Exception: + try: + await self.on_error(event_name, *args, **kwargs) + except asyncio.CancelledError: + pass + + def _schedule_event(self, coro: Coro, event_name: str, *args, **kwargs) -> _ClientEventTask: + wrapped = self._run_event(coro, event_name, *args, **kwargs) + #print(coro, event_name, *args, **kwargs) + # Schedules the task + return _ClientEventTask(original_coro=coro, event_name=event_name, coro=wrapped, loop=self.loop) + + def dispatch(self, event: str, *args, **kwargs) -> None: + log.debug('Dispatching event %s', event) + method = 'on_' + event + #print(method) + + listeners = self._listeners.get(event) + if listeners: + removed = [] + for i, (future, condition) in enumerate(listeners): + if isinstance(future, asyncio.Future): + if future.cancelled(): + removed.append(i) + continue + + try: + result = condition(*args) + except Exception as exc: + future.set_exception(exc) + removed.append(i) + else: + if result: + if len(args) == 0: + future.set_result(None) + elif len(args) == 1: + future.set_result(args[0]) + else: + future.set_result(args) + removed.append(i) + + if len(removed) == len(listeners): + self._listeners.pop(event) + else: + for idx in reversed(removed): + del listeners[idx] + else: + result = condition(*args) + if result: + self._schedule_event(future, method, *args, **kwargs) + + try: + coro = getattr(self, method) + except AttributeError: + pass + else: + self._schedule_event(coro, method, *args, **kwargs) + + async def on_error(self, event_method: str, *args, **kwargs) -> None: + """|coro| + + The default error handler provided by the client. + + By default, this prints to :data:`sys.stderr` however it could be + overridden to have a different implementation. + Check :func:`~discord.on_error` for more details. + """ + print('Ignoring exception in {}'.format(event_method), file=sys.stderr) + traceback.print_exc() + + async def on_application_command_error( + self, + cmd: ApplicationCommand, + interaction: ApplicationCommandInteraction, + exception: BaseException + ) -> None: + """|coro| + + The default error handler when an Exception was raised when invoking an application-command. + + By default, this prints to :data:`sys.stderr` however it could be + overridden to have a different implementation. + Check :func:`~discord.on_application_command_error` for more details. + """ + if hasattr(cmd, 'on_error'): + return + if isinstance(cmd, (SlashCommand, SubCommand)): + name = cmd.qualified_name + else: + name = cmd.name + print('Ignoring exception in {type} command "{name}" ({id})'.format( + type=str(interaction.command.type).upper(), + name=name, + id=interaction.command.id + ), + file=sys.stderr + ) + traceback.print_exception(type(exception), exception, exception.__traceback__, file=sys.stderr) + + async def _request_sync_commands(self, is_cog_reload: bool = False, *, reload_failed: bool = False) -> None: + """Used to sync commands if the ``GUILD_CREATE`` stream is over or a :class:`~discord.ext.commands.Cog` was reloaded. + + .. warning:: + **DO NOT OVERWRITE THIS METHOD!!! + IF YOU DO SO, THE APPLICATION-COMMANDS WILL NOT BE SYNCED AND NO COMMAND REGISTERED WILL BE DISPATCHED.** + """ + if not hasattr(self, 'app'): + await self.application_info() + if (is_cog_reload and not reload_failed and getattr(self, 'sync_commands_on_cog_reload', False) is True) or ( + not is_cog_reload and self.sync_commands is True + ): + return await self._sync_commands() + state = self._connection # Speedup attribute access + if not is_cog_reload: + app_id = self.app.id + log.info('Collecting global application-commands for application %s (%s)', self.app.name, self.app.id) + + self._minimal_registered_global_commands_raw = minimal_registered_global_commands_raw = [] + get_commands = self.http.get_application_commands + global_registered_raw = await get_commands(app_id) + + for raw_command in global_registered_raw: + command_type = str(ApplicationCommandType.try_value(raw_command['type'])) + minimal_registered_global_commands_raw.append({'id': int(raw_command['id']), 'type': command_type, 'name': raw_command['name']}) + try: + command = self._application_commands_by_type[command_type][raw_command['name']] + except KeyError: + command = ApplicationCommand._from_type(state, data=raw_command) + command.func = None + self._application_commands[command.id] = self._application_commands_by_type[command_type][command.name] = command + else: + command._fill_data(raw_command) + command._state = state + self._application_commands[command.id] = command + + log.info( + 'Done! Cached %s global application-commands', + sum([len(cmds) for cmds in self._application_commands_by_type.values()]) + ) + log.info('Collecting guild-specific application-commands for application %s (%s)', self.app.name, app_id) + + self._minimal_registered_guild_commands_raw = minimal_registered_guild_commands_raw = {} + + for guild in self.guilds: + try: + registered_guild_commands_raw = await get_commands(app_id, guild_id=guild.id) + except Forbidden: + log.info( + 'Missing access to guild %s (%s) or don\'t have the application.commands scope in there, ' + 'skipping!' % (guild.name, guild.id)) + continue + except HTTPException: + raise + if registered_guild_commands_raw: + minimal_registered_guild_commands_raw[guild.id] = minimal_registered_guild_commands = [] + try: + guild_commands = self._guild_specific_application_commands[guild.id] + except KeyError: + self._guild_specific_application_commands[guild.id] = guild_commands = { + 'chat_input': {}, 'user': {}, 'message': {} + } + for raw_command in registered_guild_commands_raw: + command_type = str(ApplicationCommandType.try_value(raw_command['type'])) + minimal_registered_guild_commands.append( + {'id': int(raw_command['id']), 'type': command_type, 'name': raw_command['name']} + ) + try: + command = guild_commands[command_type][raw_command['name']] + except KeyError: + command = ApplicationCommand._from_type(state, data=raw_command) + command.func = None + self._application_commands[command.id] = guild._application_commands[command.id] \ + = guild_commands[command_type][command.name] = command + else: + command._fill_data(raw_command) + command._state = state + self._application_commands[command.id] = guild._application_commands[command.id] = command + + log.info( + 'Done! Cached %s commands for %s guilds', + sum([ + len(commands) for commands in list(minimal_registered_guild_commands_raw.values()) + ]), + len(minimal_registered_guild_commands_raw.keys()) + ) + + else: + # re-assign metadata to the commands (for commands added from cogs) + log.info('Re-assigning metadata to commands') + # For logging purposes + no_longer_in_code_global = 0 + no_longer_in_code_guild_specific = 0 + no_longer_in_code_guilds = set() + + for raw_command in self._minimal_registered_global_commands_raw: + command_type = raw_command['type'] + try: + command = self._application_commands_by_type[command_type][raw_command['name']] + except KeyError: + no_longer_in_code_global += 1 + self._application_commands[raw_command['id']].func = None + continue # Should already be cached in self._application_commands so skip that part here + else: + if command.disabled: + no_longer_in_code_global += 1 + else: + command._fill_data(raw_command) + command._state = state + self._application_commands[command.id] = command + for guild_id, raw_commands in self._minimal_registered_guild_commands_raw.items(): + try: + guild_commands = self._guild_specific_application_commands[guild_id] + except KeyError: + no_longer_in_code_guilds.add(guild_id) + no_longer_in_code_guild_specific += len(raw_commands) + continue # Should already be cached in self._application_commands so skip that part here again + else: + guild = self.get_guild(guild_id) + for raw_command in raw_commands: + command_type = raw_command['type'] + try: + command = guild_commands[command_type][raw_command['name']] + except KeyError: + if guild_id not in no_longer_in_code_guilds: + no_longer_in_code_guilds.add(guild_id) + no_longer_in_code_guild_specific += 1 + self._application_commands[raw_command['id']].func = None + pass # Should already be cached in self._application_commands so skip that part here another once again + else: + if command.disabled: + no_longer_in_code_guild_specific += 1 + else: + command._fill_data(raw_command) + command._state = state + self._application_commands[command.id] = guild._application_commands[command.id] = command + log.info('Done!') + if no_longer_in_code_global: + log.warning( + '%s global application-commands where removed from code but are still registered in discord', + no_longer_in_code_global + ) + if no_longer_in_code_guild_specific: + log.warning( + 'In total %s guild-specific application-commands from %s guild(s) where removed from code ' + 'but are still registered in discord', no_longer_in_code_guild_specific, + len(no_longer_in_code_guilds) + ) + if no_longer_in_code_global or no_longer_in_code_guild_specific: + log.warning( + 'To prevent the above, set `sync_commands_on_cog_reload` of %s to True', + self.__class__.__name__ + ) + + @utils.deprecated('Guild.chunk') + async def request_offline_members(self, *guilds): + r"""|coro| + + Requests previously offline members from the guild to be filled up + into the :attr:`.Guild.members` cache. This function is usually not + called. It should only be used if you have the ``fetch_offline_members`` + parameter set to ``False``. + + When the client logs on and connects to the websocket, Discord does + not provide the library with offline members if the number of members + in the guild is larger than 250. You can check if a guild is large + if :attr:`.Guild.large` is ``True``. + + .. warning:: + + This method is deprecated. Use :meth:`Guild.chunk` instead. + + Parameters + ----------- + \*guilds: :class:`.Guild` + An argument list of guilds to request offline members for. + + Raises + ------- + :exc:`.InvalidArgument` + If any guild is unavailable in the collection. + """ + if any(g.unavailable for g in guilds): + raise InvalidArgument('An unavailable guild was passed.') + + for guild in guilds: + await self._connection.chunk_guild(guild) + + async def fetch_soundboard_sounds(self, guild_id): + """|coro| + + Requests all soundboard sounds for the given guilds. + + This method retrieves the list of soundboard sounds from the Discord API for each guild ID provided. + + .. note:: + + You must have the :attr:`~Permissions.manage_guild_expressions` permission + in each guild to retrieve its soundboard sounds. + + Parameters + ---------- + guild_ids: List[:class:`int`] + A list of guild IDs to fetch soundboard sounds from. + + Raises + ------- + HTTPException + Retrieving soundboard sounds failed. + NotFound + One of the provided guilds does not exist or is inaccessible. + Forbidden + Missing permissions to view soundboard sounds in one or more guilds. + + Returns + ------- + Dict[:class:`int`, List[:class:`SoundboardSound`]] + A dictionary mapping each guild ID to a list of its soundboard sounds. + """ + guild = self.get_guild(guild_id) + + data = await self.http.all_soundboard_sounds(guild_id) + data = data["items"] + return SoundboardSound._from_list(guild=guild, state=self._connection, data_list=data) + #await self.ws.request_soundboard_sounds(guild_ids) + + # hooks + + async def _call_before_identify_hook(self, shard_id, *, initial=False): + # This hook is an internal hook that actually calls the public one. + # It allows the library to have its own hook without stepping on the + # toes of those who need to override their own hook. + await self.before_identify_hook(shard_id, initial=initial) + + async def before_identify_hook(self, shard_id: int, *, initial: bool = False): + """|coro| + + A hook that is called before IDENTIFYing a _session. This is useful + if you wish to have more control over the synchronization of multiple + IDENTIFYing clients. + + The default implementation sleeps for 5 seconds. + + .. versionadded:: 1.4 + + Parameters + ------------ + shard_id: :class:`int` + The shard ID that requested being IDENTIFY'd + initial: :class:`bool` + Whether this IDENTIFY is the first initial IDENTIFY. + """ + + if not initial: + await asyncio.sleep(5.0) + + # login state management + + async def login(self, token: str) -> None: + """|coro| + + Logs in the client with the specified credentials. + + This function can be used in two different ways. + + + Parameters + ----------- + token: :class:`str` + The authentication token. Do not prefix this token with + anything as the library will do it for you. + + Raises + ------ + :exc:`.LoginFailure` + The wrong credentials are passed. + :exc:`.HTTPException` + An unknown HTTP related error occurred, + usually when it isn't 200 or the known incorrect credentials + passing status code. + """ + + log.info('logging in using static token') + await self.http.static_login(token.strip()) + + @utils.deprecated('Client.close') + async def logout(self): + """|coro| + + Logs out of Discord and closes all connections. + + .. deprecated:: 1.7 + + .. note:: + + This is just an alias to :meth:`close`. If you want + to do extraneous cleanup when subclassing, it is suggested + to override :meth:`close` instead. + """ + await self.close() + + async def connect(self, *, reconnect: bool = True) -> None: + """|coro| + + Creates a websocket connection and lets the websocket listen + to messages from Discord. This is a loop that runs the entire + event system and miscellaneous aspects of the library. Control + is not resumed until the WebSocket connection is terminated. + + Parameters + ----------- + reconnect: :class:`bool` + If we should attempt reconnecting, either due to internet + failure or a specific failure on Discord's part. Certain + disconnects that lead to bad state will not be handled (such as + invalid sharding payloads or bad tokens). + + Raises + ------- + :exc:`.GatewayNotFound` + If the gateway to connect to Discord is not found. Usually if this + is thrown then there is a Discord API outage. + :exc:`.ConnectionClosed` + The websocket connection has been terminated. + """ + + backoff = ExponentialBackoff() + ws_params = { + 'initial': True, + 'shard_id': self.shard_id, + } + if self.auto_check_for_updates: + self._auto_update_checker.start() + while not self.is_closed(): + try: + coro = DiscordWebSocket.from_client(self, **ws_params) + self.ws = await asyncio.wait_for(coro, timeout=60.0) + ws_params['initial'] = False + while True: + await self.ws.poll_event() + except ReconnectWebSocket as e: + log.info('Got a request to %s the websocket.', e.op) + self._ws_connected.clear() + self.dispatch('disconnect') + ws_params.update( + sequence=self.ws.sequence, + resume=e.resume, + session=self.ws.session_id, + resume_gateway_url=self.ws.resume_gateway_url if e.resume else None + ) + continue + except (OSError, + HTTPException, + GatewayNotFound, + ConnectionClosed, + aiohttp.ClientError, + asyncio.TimeoutError) as exc: + self._ws_connected.clear() + self.dispatch('disconnect') + if not reconnect: + await self.close() + if isinstance(exc, ConnectionClosed) and exc.code == 1000: + # clean close, don't re-raise this + return + raise + + if self.is_closed(): + return + + # If we get connection reset by peer then try to RESUME + if isinstance(exc, OSError) and exc.errno in (54, 10054): + ws_params.update( + sequence=self.ws.sequence, + initial=False, + resume=True, + session=self.ws.session_id, + resume_gateway_url=self.ws.resume_gateway_url + ) + continue + + # We should only get this when an unhandled close code happens, + # such as a clean disconnect (1000) or a bad state (bad token, no sharding, etc) + # sometimes, discord sends us 1000 for unknown reasons, so we should reconnect + # regardless and rely on is_closed instead + if isinstance(exc, ConnectionClosed): + if exc.code == 4014: + if self.shard_count and self.shard_count > 0: + raise PrivilegedIntentsRequired(exc.shard_id) + else: + sys.stderr.write(str(PrivilegedIntentsRequired(exc.shard_id))) + if exc.code != 1000: + await self.close() + if not exc.code == 4014: + raise + + retry = backoff.delay() + log.exception("Attempting a reconnect in %.2fs", retry) + await asyncio.sleep(retry) + # Always try to RESUME the connection + # If the connection is not RESUME-able then the gateway will invalidate the _session. + # This is apparently what the official Discord client does. + ws_params.update( + sequence=self.ws.sequence, + resume=True, + session=self.ws.session_id, + resume_gateway_url=self.ws.resume_gateway_url + ) + + async def close(self) -> None: + """|coro| + + Closes the connection to Discord. + """ + if self._closed: + return + + for voice in self.voice_clients: + try: + await voice.disconnect() # type: ignore + except Exception: + # if an error happens during disconnects, disregard it. + pass + + await self.http.close() + if self._auto_update_checker: + await self._auto_update_checker.close() + self._closed = True + + if self.ws is not None and self.ws.open: + await self.ws.close(code=1000) + + self._ws_connected.clear() + self._ready.clear() + + def clear(self) -> None: + """Clears the internal state of the bot. + + After this, the bot can be considered "re-opened", i.e. :meth:`is_closed` + and :meth:`is_ready` both return ``False`` along with the bot's internal + cache cleared. + """ + self._closed = False + self._ready.clear() + self._ws_connected.clear() + self._connection.clear() + self.http.recreate() + + async def start(self, token: str, reconnect: bool = True) -> None: + """|coro| + + A shorthand coroutine for :meth:`login` + :meth:`connect`. + + Raises + ------- + TypeError + An unexpected keyword argument was received. + """ + await self.login(token) + await self.connect(reconnect=reconnect) + + def run( + self, + token: str, + reconnect: bool = True, + *, + log_handler: Optional[logging.Handler] = MISSING, + log_formatter: logging.Formatter = MISSING, + log_level: int = MISSING, + root_logger: bool = False + ) -> None: + """A blocking call that abstracts away the event loop + initialisation from you. + + If you want more control over the event loop then this + function should not be used. Use :meth:`start` coroutine + or :meth:`connect` + :meth:`login`. + + Roughly Equivalent to: :: + + try: + loop.run_until_complete(start(*args, **kwargs)) + except KeyboardInterrupt: + loop.run_until_complete(close()) + # cancel all tasks lingering + finally: + loop.close() + + This function also sets up the `:mod:`logging` library to make it easier + for beginners to know what is going on with the library. For more + advanced users, this can be disabled by passing :obj:`None` to + the ``log_handler`` parameter. + + .. warning:: + + This function must be the last function to call due to the fact that it + is blocking. That means that registration of events or anything being + called after this function call will not execute until it returns. + + Parameters + ----------- + token: :class:`str` + The authentication token. **Do not prefix this token with anything as the library will do it for you.** + reconnect: :class:`bool` + If we should attempt reconnecting, either due to internet + failure or a specific failure on Discord's part. Certain + disconnects that lead to bad state will not be handled (such as + invalid sharding payloads or bad tokens). + log_handler: Optional[:class:`logging.Handler`] + The log handler to use for the library's logger. If this is :obj:`None` + then the library will not set up anything logging related. Logging + will still work if :obj:`None` is passed, though it is your responsibility + to set it up. + The default log handler if not provided is :class:`logging.StreamHandler`. + log_formatter: :class:`logging.Formatter` + The formatter to use with the given log handler. If not provided then it + defaults to a colour based logging formatter (if available). + log_level: :class:`int` + The default log level for the library's logger. This is only applied if the + ``log_handler`` parameter is not :obj:`None`. Defaults to :attr:`logging.INFO`. + root_logger: :class:`bool` + Whether to set up the root logger rather than the library logger. + By default, only the library logger (``'discord'``) is set up. If this + is set to :obj:`True` then the root logger is set up as well. + Defaults to :obj:`False`. + """ + loop = self.loop + + try: + loop.add_signal_handler(signal.SIGINT, lambda: loop.stop()) + loop.add_signal_handler(signal.SIGTERM, lambda: loop.stop()) + except NotImplementedError: + pass + + async def runner(): + try: + await self.start(token, reconnect) + finally: + if not self.is_closed(): + await self.close() + + if log_handler is not None: + utils.setup_logging( + handler=log_handler, + formatter=log_formatter, + level=log_level, + root=root_logger + ) + + def stop_loop_on_completion(f): + loop.stop() + + future = asyncio.ensure_future(runner(), loop=loop) + future.add_done_callback(stop_loop_on_completion) + try: + loop.run_forever() + except KeyboardInterrupt: + log.info('Received signal to terminate bot and event loop.') + finally: + future.remove_done_callback(stop_loop_on_completion) + log.info('Cleaning up tasks.') + _cleanup_loop(loop) + + if not future.cancelled(): + try: + return future.result() + except KeyboardInterrupt: + # I am unsure why this gets raised here but suppress it anyway + return None + + # properties + + def is_closed(self) -> bool: + """:class:`bool`: Indicates if the websocket connection is closed.""" + return self._closed + + @property + def activity(self) -> Optional[BaseActivity]: + """Optional[:class:`.BaseActivity`]: The activity being used upon + logging in. + """ + return create_activity(self._connection._activity) + + @activity.setter + def activity(self, value: Optional[BaseActivity]): + if value is None: + self._connection._activity = None + elif isinstance(value, BaseActivity): + self._connection._activity = value.to_dict() + else: + raise TypeError('activity must derive from BaseActivity.') + + @property + def allowed_mentions(self) -> Optional[AllowedMentions]: + """Optional[:class:`~discord.AllowedMentions`]: The allowed mention configuration. + + .. versionadded:: 1.4 + """ + return self._connection.allowed_mentions + + @allowed_mentions.setter + def allowed_mentions(self, value: Optional[AllowedMentions]): + if value is None or isinstance(value, AllowedMentions): + self._connection.allowed_mentions = value + else: + raise TypeError('allowed_mentions must be AllowedMentions not {0.__class__!r}'.format(value)) + + @property + def intents(self) -> Intents: + """:class:`~discord.Intents`: The intents configured for this connection. + + .. versionadded:: 1.5 + """ + return self._connection.intents + + # helpers/getters + + @property + def users(self) -> List[User]: + """List[:class:`~discord.User`]: Returns a list of all the users the bot can see.""" + return list(self._connection._users.values()) + + def get_message(self, id: int) -> Optional[Message]: + """Returns a :class:`~discord.Message` with the given ID if it exists in the cache, else :obj:`None`""" + return self._connection._get_message(id) + + def get_channel(self, id: int) -> Optional[Union[Messageable, GuildChannel]]: + """Returns a channel with the given ID. + + Parameters + ----------- + id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[Union[:class:`.abc.GuildChannel`, :class:`.abc.PrivateChannel`]] + The returned channel or ``None`` if not found. + """ + return self._connection.get_channel(id) + + def get_partial_messageable( + self, + id: int, + *, + guild_id: Optional[int] = None, + type: Optional[ChannelType] = None + ) -> PartialMessageable: + """Returns a :class:`~discord.PartialMessageable` with the given channel ID. + This is useful if you have the ID of a channel but don't want to do an API call + to send messages to it. + + Parameters + ----------- + id: :class:`int` + The channel ID to create a :class:`~discord.PartialMessageable` for. + guild_id: Optional[:class:`int`] + The optional guild ID to create a :class:`~discord.PartialMessageable` for. + This is not required to actually send messages, but it does allow the + :meth:`~discord.PartialMessageable.jump_url` and + :attr:`~discord.PartialMessageable.guild` properties to function properly. + type: Optional[:class:`.ChannelType`] + The underlying channel type for the :class:`~discord.PartialMessageable`. + + Returns + -------- + :class:`.PartialMessageable` + The partial messageable created + """ + return PartialMessageable(state=self._connection, id=id, guild_id=guild_id, type=type) + + def get_guild(self, id: int) -> Optional[Guild]: + """Returns a guild with the given ID. + + Parameters + ----------- + id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`.Guild`] + The guild or ``None`` if not found. + """ + return self._connection._get_guild(id) + + def get_user(self, id: int) -> Optional[User]: + """Returns a user with the given ID. + + Parameters + ----------- + id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`~discord.User`] + The user or ``None`` if not found. + """ + return self._connection.get_user(id) + + def get_emoji(self, id: int) -> Optional[Emoji]: + """Returns an emoji with the given ID. + + Parameters + ----------- + id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`.Emoji`] + The custom emoji or ``None`` if not found. + """ + return self._connection.get_emoji(id) + + def get_all_channels(self) -> Iterator[GuildChannel]: + """A generator that retrieves every :class:`.abc.GuildChannel` the client can 'access'. + + This is equivalent to: :: + + for guild in client.guilds: + for channel in guild.channels: + yield channel + + .. note:: + + Just because you receive a :class:`.abc.GuildChannel` does not mean that + you can communicate in said channel. :meth:`.abc.GuildChannel.permissions_for` should + be used for that. + + Yields + ------ + :class:`.abc.GuildChannel` + A channel the client can 'access'. + """ + + for guild in self.guilds: + for channel in guild.channels: + yield channel + + def get_all_members(self) -> Iterator[Member]: + """Returns a generator with every :class:`.Member` the client can see. + + This is equivalent to: :: + + for guild in client.guilds: + for member in guild.members: + yield member + + Yields + ------ + :class:`.Member` + A member the client can see. + """ + for guild in self.guilds: + for member in guild.members: + yield member + + # listeners/waiters + + async def wait_until_ready(self) -> None: + """|coro| + + Waits until the client's internal cache is all ready. + """ + await self._ready.wait() + + def wait_for( + self, + event: str, + *, + check: Optional[Callable[[Any, ...], bool]] = None, + timeout: Optional[float] = None + ) -> Coroutine[Any, Any, Any]: + """|coro| + + Waits for a WebSocket event to be dispatched. + + This could be used to wait for a user to reply to a message, + or to react to a message, or to edit a message in a self-contained + way. + + The ``timeout`` parameter is passed onto :func:`asyncio.wait_for`. By default, + it does not timeout. Note that this does propagate the + :exc:`asyncio.TimeoutError` for you in case of timeout and is provided for + ease of use. + + In case the event returns multiple arguments, a :class:`tuple` containing those + arguments is returned instead. Please check the + :ref:`documentation ` for a list of events and their + parameters. + + This function returns the **first event that meets the requirements**. + + Examples + --------- + + Waiting for a user reply: :: + + @client.event + async def on_message(message): + if message.content.startswith('$greet'): + channel = message.channel + await channel.send('Say hello!') + + def check(m): + return m.content == 'hello' and m.channel == channel + + msg = await client.wait_for('message', check=check) + await channel.send('Hello {.author}!'.format(msg)) + + Waiting for a thumbs up reaction from the message author: :: + + @client.event + async def on_message(message): + if message.content.startswith('$thumb'): + channel = message.channel + await channel.send('Send me that \N{THUMBS UP SIGN} reaction, mate') + + def check(reaction, user): + return user == message.author and str(reaction.emoji) == '\N{THUMBS UP SIGN}' + + try: + reaction, user = await client.wait_for('reaction_add', timeout=60.0, check=check) + except asyncio.TimeoutError: + await channel.send('\N{THUMBS DOWN SIGN}') + else: + await channel.send('\N{THUMBS UP SIGN}') + + + Parameters + ------------ + event: :class:`str` + The event name, similar to the :ref:`event reference `, + but without the ``on_`` prefix, to wait for. + check: Optional[Callable[..., :class:`bool`]] + A predicate to check what to wait for. The arguments must meet the + parameters of the event being waited for. + timeout: Optional[:class:`float`] + The number of seconds to wait before timing out and raising + :exc:`asyncio.TimeoutError`. + + Raises + ------- + asyncio.TimeoutError + If a timeout is provided, and it was reached. + + Returns + -------- + Any + Returns no arguments, a single argument, or a :class:`tuple` of multiple + arguments that mirrors the parameters passed in the + :ref:`event reference `. + """ + + future = self.loop.create_future() + if check is None: + def _check(*args): + return True + check = _check + ev = event.lower() + try: + listeners = self._listeners[ev] + except KeyError: + listeners = [] + self._listeners[ev] = listeners + + listeners.append((future, check)) + return asyncio.wait_for(future, timeout) + + # event registration + + def event(self, coro: Coro) -> Coro: + """A decorator that registers an event to listen to. + + You can find more info about the events on the :ref:`documentation below `. + + The events must be a :ref:`coroutine `, if not, :exc:`TypeError` is raised. + + Example + --------- + .. code-block:: python3 + + @client.event + async def on_ready(): + print('Ready!') + + Raises + -------- + TypeError + The coroutine passed is not actually a coroutine. + """ + + if not asyncio.iscoroutinefunction(coro): + raise TypeError('event registered must be a coroutine function') + + setattr(self, coro.__name__, coro) + log.debug('%s has successfully been registered as an event', coro.__name__) + return coro + + def once( + self, name: str = MISSING, check: Callable[..., bool] | None = None + ) -> Coro: + """A decorator that registers an event to listen to only once. + For example if you want to perform a database connection once the bot is ready. + + You can find more info about the events on the :ref:`documentation below `. + + The events must be a :ref:`coroutine `, if not, :exc:`TypeError` is raised. + + Parameters + ---------- + name: :class:`str` + The name of the event we want to listen to. This is passed to + :meth:`~discord.Client.wait_for`. Defaults to ``func.__name__``. + check: Optional[Callable[..., :class:`bool`]] + A predicate to check what to wait for. The arguments must meet the + parameters of the event being waited for. + + Raises + ------ + TypeError + The coroutine passed is not actually a coroutine. + + Example + ------- + + .. code-block:: python + + @client.once() + async def ready(): + print('Beep bop, I\\'m ready!') + + @client.once(check=lambda msg: msg.author.id == 693088765333471284) + async def message(message): + await message.reply('Hey there, how are you?') + """ + + def decorator(func: Coro) -> Coro: + if not asyncio.iscoroutinefunction(func): + raise TypeError("event registered must be a coroutine function") + + async def wrapped() -> None: + nonlocal name + nonlocal check + + name = func.__name__ if name is MISSING else name + if name[:3] == 'on_': + name = name[3:] + + args = await self.wait_for(name, check=check) + + arg_len = func.__code__.co_argcount + if arg_len == 0 and args is None: + await func() + elif arg_len == 1: + await func(args) + else: + await func(*args) + + self.loop.create_task(wrapped()) + return func + + return decorator + + def on_click( + self, + custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] = None + ) -> Callable[[_ClickCallback], _ClickCallback]: + """ + A decorator with which you can assign a function to a specific :class:`~discord.Button` (or its custom_id). + + .. important:: + The function this is attached to must take the same parameters as a + :func:`~discord.on_raw_button_click` event. + + .. warning:: + The func must be a coroutine, if not, :exc:`TypeError` is raised. + + Parameters + ---------- + custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] + If the :attr:`custom_id` of the :class:`~discord.Button` could not be used as a function name, + or you want to give the function a different name then the custom_id use this one to set the custom_id. + You can also specify a regex and if the custom_id matches it, the function will be executed. + + .. note:: + As the ``custom_id`` is converted to a |pattern_object| put ``^`` in front and ``$`` at the end + of the :attr:`custom_id` if you want that the custom_id must exactly match the specified value. + Otherwise, something like 'cool blue Button is blue' will let the function bee invoked too. + + Example + ------- + .. code-block:: python + + # the button + Button(label='Hey im a cool blue Button', + custom_id='cool blue Button', + style=ButtonStyle.blurple) + + # function that's called when the button pressed + @client.on_click(custom_id='^cool blue Button$') + async def cool_blue_button(i: discord.ComponentInteraction, button: Button): + await i.respond(f'Hey you pressed a {button.custom_id}!', hidden=True) + + Returns + ------- + The decorator for the function called when the button clicked + + Raise + ----- + :exc:`TypeError` + The coroutine passed is not actually a coroutine. + """ + def decorator(func: _ClickCallback) -> _ClickCallback: + if not asyncio.iscoroutinefunction(func): + raise TypeError('event registered must be a coroutine function') + + _custom_id = re.compile(custom_id) if ( + custom_id is not None and not isinstance(custom_id, re.Pattern) + ) else re.compile(f'^{func.__name__}$') + + try: + listeners = self._listeners['raw_button_click'] + except KeyError: + listeners = [] + self._listeners['raw_button_click'] = listeners + + def _check(i: ComponentInteraction, c: Button) -> bool: + match = _custom_id.match(str(c.custom_id)) + if match: + i.match = match + return True + return False + + listeners.append((func, _check)) + return func + + return decorator + + def on_select( + self, + custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] = None + ) -> Callable[[_SelectCallback], _SelectCallback]: + """ + A decorator with which you can assign a function to a specific :class:`~discord.SelectMenu` (or its custom_id). + + .. important:: + The function this is attached to must take the same parameters as a + :func:`~discord.on_raw_selection_select` event. + + .. warning:: + The func must be a coroutine, if not, :exc:`TypeError` is raised. + + Parameters + ----------- + custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] = None + If the `custom_id` of the :class:`~discord.SelectMenu` could not be used as a function name, + or you want to give the function a different name then the custom_id use this one to set the custom_id. + You can also specify a regex and if the custom_id matches it, the function will be executed. + + .. note:: + As the ``custom_id`` is converted to a |pattern_object| put ``^`` in front and ``$`` at the end + of the :attr:`custom_id` if you want that the custom_id must exactly match the specified value. + Otherwise, something like 'choose_your_gender later' will let the function bee invoked too. + + Example + ------- + .. code-block:: python + + # the SelectMenu + SelectMenu(custom_id='choose_your_gender', + options=[ + SelectOption(label='Female', value='Female', emoji='♀️'), + SelectOption(label='Male', value='Male', emoji='♂️'), + SelectOption(label='Trans/Non Binary', value='Trans/Non Binary', emoji='⚧') + ], placeholder='Choose your Gender') + + # function that's called when the SelectMenu is used + @client.on_select() + async def choose_your_gender(i: discord.Interaction, select_menu): + await i.respond(f'You selected `{select_menu.values[0]}`!', hidden=True) + + Raises + ------- + :exc:`TypeError` + The coroutine passed is not actually a coroutine. + """ + def decorator(func: _SelectCallback) -> _SelectCallback: + if not asyncio.iscoroutinefunction(func): + raise TypeError('event registered must be a coroutine function') + + _custom_id = re.compile(custom_id) if ( + custom_id is not None and not isinstance(custom_id, re.Pattern) + ) else re.compile(f'^{func.__name__}$') + + try: + listeners = self._listeners['raw_selection_select'] + except KeyError: + listeners = [] + self._listeners['raw_selection_select'] = listeners + + def _check(i: ComponentInteraction, c: Button) -> bool: + match = _custom_id.match(str(c.custom_id)) + if match: + i.match = match + return True + return False + + listeners.append((func, _check)) + return func + + return decorator + + def on_submit( + self, + custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] = None + ) -> Callable[[_SubmitCallback], _SubmitCallback]: + """ + A decorator with which you can assign a function to a specific :class:`~discord.Modal` (or its custom_id). + + .. important:: + The function this is attached to must take the same parameters as a + :func:`~discord.on_modal_submit` event. + + .. warning:: + The func must be a coroutine, if not, :exc:`TypeError` is raised. + + + Parameters + ---------- + custom_id: Optional[Union[Pattern[AnyStr], AnyStr]] + If the :attr:`~discord.Modal.custom_id` of the modal could not be used as a function name, + or you want to give the function a different name then the custom_id use this one to set the custom_id. + You can also specify a regex and if the custom_id matches it, the function will be executed. + + .. note:: + As the ``custom_id`` is converted to a |pattern_object| put ``^`` in front and ``$`` at the end + of the :attr:`custom_id` if you want that the custom_id must exactly match the specified value. + Otherwise, something like 'suggestions_modal_submit_private' will let the function bee invoked too. + + .. tip:: + The resulting |match_object| object will be + available under the :class:`~discord.ModalSubmitInteraction.match` attribute of the interaction. + + **See example below.** + + Examples + -------- + .. code-block:: python + :caption: Simple example of a Modal with a custom_id and a function that's called when the Modal is submitted. + :emphasize-lines: 9, 14 + + # the Modal + Modal( + title='Create a new suggestion', + custom_id='suggestions_modal', + components=[...] + ) + + # function that's called when the Modal is submitted + @client.on_submit(custom_id='^suggestions_modal$') + async def suggestions_modal_callback(i: discord.ModalSubmitInteraction): + ... + + # This can also be done based on the function name + @client.on_submit() + async def suggestions_modal(i: discord.ModalSubmitInteraction): + ... + + + .. code-block:: python + :caption: You can also use a more advanced RegEx containing groups to easily allow dynamic custom-id's + :emphasize-lines: 1, 3 + + @client.on_submit(custom_id='^ticket_answer:(?P[0-9]+)$') + async def ticket_answer_callback(i: discord.ModalSubmitInteraction): + user_id = int(i.match['id']) + user = client.get_user(user_id) or await client.fetch_user(user_id) + + Raises + ------ + :exc:`TypeError` + The coroutine passed is not actually a coroutine. + """ + def decorator(func: _SubmitCallback) -> _SubmitCallback: + if not asyncio.iscoroutinefunction(func): + raise TypeError('event registered must be a coroutine function') + + _custom_id = re.compile(custom_id) if ( + custom_id is not None and not isinstance(custom_id, re.Pattern) + ) else re.compile(f'^{func.__name__}$') + + try: + listeners = self._listeners['modal_submit'] + except KeyError: + listeners = [] + self._listeners['modal_submit'] = listeners + + def _check(i: ModalSubmitInteraction) -> bool: + match = _custom_id.match(str(i.custom_id)) + if match: + i.match = match + return True + return False + + listeners.append((func, _check)) + return func + + return decorator + + def slash_command( + self, + name: Optional[str] = None, + name_localizations: Optional[Localizations] = Localizations(), + description: Optional[str] = None, + description_localizations: Optional[Localizations] = Localizations(), + allow_dm: bool = MISSING, + allowed_contexts: Optional[List[InteractionContextType]] = MISSING, + allowed_integration_types: Optional[List[AppIntegrationType]] = MISSING, + is_nsfw: bool = MISSING, + default_required_permissions: Optional[Permissions] = None, + options: Optional[List] = [], + guild_ids: Optional[List[int]] = None, + connector: Optional[dict] = {}, + option_descriptions: Optional[dict] = {}, + option_descriptions_localizations: Optional[Dict[str, Localizations]] = {}, + base_name: Optional[str] = None, + base_name_localizations: Optional[Localizations] = Localizations(), + base_desc: Optional[str] = None, + base_desc_localizations: Optional[Localizations] = Localizations(), + group_name: Optional[str] = None, + group_name_localizations: Optional[Localizations] = Localizations(), + group_desc: Optional[str] = None, + group_desc_localizations: Optional[Localizations] = Localizations() + ) -> Callable[ + [Awaitable[Any]], + Union[SlashCommand, GuildOnlySlashCommand, SubCommand, GuildOnlySubCommand] + ]: + """A decorator that adds a slash-command to the client. The function this is attached to must be a :ref:`coroutine `. + + .. warning:: + :attr:`~discord.Client.sync_commands` of the :class:`Client` instance must be set to :obj:`True` + to register a command if it does not already exist and update it if changes where made. + + .. note:: + Any of the following parameters are only needed when the corresponding target was not used before + (e.g. there is already a command in the code that has these parameters set) - otherwise it will replace the previous value or update it for iterables. + + - ``allow_dm`` + - ``allowed_contexts`` (update) + - ``allowed_integration_types`` (update) + - ``is_nsfw`` + - ``base_name_localizations`` + - ``base_desc`` + - ``base_desc_localizations`` + - ``group_name_localizations`` + - ``group_desc`` + - ``group_desc_localizations`` + + Parameters + ----------- + name: Optional[:class:`str`] + The name of the command. Must only contain a-z, _ and - and be 1-32 characters long. + Default to the functions name. + name_localizations: Optional[:class:`~discord.Localizations`] + Localizations object for name field. Values follow the same restrictions as :attr:`name` + description: Optional[:class:`str`] + The description of the command shows up in the client. Must be between 1-100 characters long. + Default to the functions docstring or "No Description". + description_localizations: Optional[:class:`~discord.Localizations`] + Localizations object for description field. Values follow the same restrictions as :attr:`description` + allow_dm: Optional[:class:`bool`] + **Deprecated**: Use :attr:`allowed_contexts` instead. + Indicates whether the command is available in DMs with the app, only for globally-scoped commands. + By default, commands are visible. + allowed_contexts: Optional[List[:class:`~discord.InteractionContextType`]] + **global commands only**: The contexts in which the command is available. + By default, commands are available in all contexts. + allowed_integration_types: Optional[List[:class:`~discord.AppIntegrationType`]] + **global commands only**: The types of app integrations where the command is available. + Default to the app's :ddocs:`configured integration types ` + is_nsfw: :class:`bool` + Whether this command is an :sup-art:`NSFW command <10123937946007-Age-Restricted-Commands>` , default :obj:`False`. + + .. note:: + Currently all sub-commands of a command that is marked as *NSFW* are NSFW too. + + default_required_permissions: Optional[:class:`~discord.Permissions`] + Permissions that a Member needs by default to execute(see) the command. + options: Optional[List[:class:`~discord.SlashCommandOption`]] + A list of max. 25 options for the command. If not provided the options will be generated + using :meth:`generate_options` that creates the options out of the function parameters. + Required options **must** be listed before optional ones. + Use :attr:`options` to connect non-ascii option names with the parameter of the function. + guild_ids: Optional[List[:class:`int`]] + ID's of guilds this command should be registered in. If empty, the command will be global. + connector: Optional[Dict[:class:`str`, :class:`str`]] + A dictionary containing the name of function-parameters as keys and the name of the option as values. + Useful for using non-ascii Letters in your option names without getting ide-errors. + option_descriptions: Optional[Dict[:class:`str`, :class:`str`]] + Descriptions the :func:`generate_options` should take for the Options that will be generated. + The keys are the :attr:`~discord.SlashCommandOption.name` of the option and the value the :attr:`~discord.SlashCommandOption.description`. + + .. note:: + This will only be used if ``options`` is not set. + + option_descriptions_localizations: Optional[Dict[:class:`str`, :class:`~discord.Localizations`]] + Localized :attr:`~discord.SlashCommandOption.description` for the options. + In the format ``{'option_name': Localizations(...)}`` + base_name: Optional[:class:`str`] + The name of the base-command(a-z, _ and -, 1-32 characters) if you want the command + to be in a command-/sub-command-group. + If the base-command does not exist yet, it will be added. + base_name_localizations: Optional[:class:`~discord.Localizations`] + Localized ``base_name``'s for the command. + base_desc: Optional[:class:`str`] + The description of the base-command(1-100 characters). + base_desc_localizations: Optional[:class:`~discord.Localizations`] + Localized ``base_description``'s for the command. + group_name: Optional[:class:`str`] + The name of the command-group(a-z, _ and -, 1-32 characters) if you want the command to be in a sub-command-group. + group_name_localizations: Optional[:class:`~discord.Localizations`] + Localized ``group_name``'s for the command. + group_desc: Optional[:class:`str`] + The description of the sub-command-group(1-100 characters). + group_desc_localizations: Optional[:class:`~discord.Localizations`] + Localized ``group_desc``'s for the command. + + Raises + ------ + :exc:`TypeError`: + The function the decorator is attached to is not actual a :ref:`coroutine ` + or a parameter passed to :class:`SlashCommandOption` is invalid for the ``option_type`` or the ``option_type`` + itself is invalid. + :exc:`~discord.InvalidArgument`: + You passed ``group_name`` but no ``base_name``. + :exc:`ValueError`: + Any of ``name``, ``description``, ``options``, ``base_name``, ``base_desc``, ``group_name`` or ``group_desc`` is not valid. + + Returns + ------- + Union[:class:`SlashCommand`, :class:`GuildOnlySlashCommand`, :class:`SubCommand`, :class:`GuildOnlySubCommand`]: + - If neither ``guild_ids`` nor ``base_name`` passed: An instance of :class:`~discord.SlashCommand`. + - If ``guild_ids`` and no ``base_name`` where passed: An instance of :class:`~discord.GuildOnlySlashCommand` representing the guild-only slash-commands. + - If ``base_name`` and no ``guild_ids`` where passed: An instance of :class:`~discord.SubCommand`. + - If ``base_name`` and ``guild_ids`` passed: instance of :class:`~discord.GuildOnlySubCommand` representing the guild-only sub-commands. + """ + + def decorator(func: Awaitable[Any]) -> Union[SlashCommand, GuildOnlySlashCommand, SubCommand, GuildOnlySubCommand]: + """ + + Parameters + ---------- + func: Awaitable[Any] + The function for the decorator. This must be a :ref:`coroutine `. + + Returns + ------- + The slash-command registered. + - If neither ``guild_ids`` nor ``base_name`` passed: An instance of :class:`~discord.SlashCommand`. + - If ``guild_ids`` and no ``base_name`` where passed: An instance of :class:`~discord.GuildOnlySlashCommand` representing the guild-only slash-commands. + - If ``base_name` and no ``guild_ids`` where passed: An instance of :class:`~discord.SubCommand`. + - If ``base_name`` and ``guild_ids`` passed: instance of :class:`~discord.GuildOnlySubCommand` representing the guild-only sub-commands. + """ + if not asyncio.iscoroutinefunction(func): + raise TypeError('The slash-command registered must be a coroutine.') + _name = (name or func.__name__).lower() + _description = description if description else (inspect.cleandoc(func.__doc__)[:100] if func.__doc__ else 'No Description') + _options = options or generate_options( + func, + descriptions=option_descriptions, + descriptions_localizations=option_descriptions_localizations, + connector=connector + ) + if group_name and not base_name: + raise InvalidArgument( + 'You have to provide the `base_name` parameter if you want to create a sub-command or sub-command-group.' + ) + guild_cmds = [] + if guild_ids: + guild_app_cmds = self._guild_specific_application_commands + for guild_id in guild_ids: + base, base_command, sub_command_group = None, None, None + try: + guild_app_cmds[guild_id] + except KeyError: + guild_app_cmds[guild_id] = {'chat_input': {}, 'message': {}, 'user': {}} + if base_name: + try: + base_command = guild_app_cmds[guild_id]['chat_input'][base_name] + except KeyError: + base_command = guild_app_cmds[guild_id]['chat_input'][base_name] = SlashCommand( + name=base_name, + name_localizations=base_name_localizations, + description=base_desc or 'No Description', + description_localizations=base_desc_localizations, + default_member_permissions=default_required_permissions, + is_nsfw=is_nsfw if is_nsfw is not MISSING else False, + guild_id=guild_id + ) + else: + + if base_desc: + base_command.description = base_command.description + if is_nsfw is not MISSING: + base_command.is_nsfw = is_nsfw + if allow_dm is not MISSING: + base_command.allow_dm = allow_dm + base_command.name_localizations.update(base_name_localizations) + base_command.description_localizations.update(base_desc_localizations) + base = base_command + if group_name: + try: + sub_command_group = guild_app_cmds[guild_id]['chat_input'][base_name]._sub_commands[group_name] + except KeyError: + sub_command_group = guild_app_cmds[guild_id]['chat_input'][base_name]._sub_commands[group_name] = SubCommandGroup( + parent=base_command, + name=group_name, + name_localizations=group_name_localizations, + description=group_desc or 'No Description', + description_localizations=group_desc_localizations, + guild_id=guild_id + ) + else: + if group_desc: + sub_command_group.description = group_desc + sub_command_group.name_localizations.update(group_name_localizations) + sub_command_group.description_localizations.update(group_desc_localizations) + base = sub_command_group + if base: + base._sub_commands[_name] = SubCommand( + parent=base, + name=_name, + name_localizations=name_localizations, + description=_description, + description_localizations=description_localizations, + options=_options, + connector=connector, + func=func + ) + guild_cmds.append(base._sub_commands[_name]) + else: + guild_app_cmds[guild_id]['chat_input'][_name] = SlashCommand( + func=func, + guild_id=guild_id, + name=_name, + name_localizations=name_localizations, + description=_description, + description_localizations=description_localizations, + default_member_permissions=default_required_permissions, + is_nsfw=is_nsfw if is_nsfw is not MISSING else False, + options=_options, + connector=connector + ) + guild_cmds.append(guild_app_cmds[guild_id]['chat_input'][_name]) + if base_name: + base = GuildOnlySlashCommand( + client=self, + guild_ids=guild_ids, + name=_name, + description=_description, + default_member_permissions=default_required_permissions, + is_nsfw=is_nsfw if is_nsfw is not MISSING else False, + options=_options + ) + if group_name: + base = GuildOnlySubCommandGroup( + client=self, + parent=base, + guild_ids=guild_ids, + name=_name, + description=_description, + default_member_permissions=default_required_permissions, + options=_options + ) + return GuildOnlySubCommand( + client=self, + parent=base, + func=func, + guild_ids=guild_ids, + commands=guild_cmds, + name=_name, + description=_description, + options=_options, + connector=connector + ) + return GuildOnlySlashCommand( + client=self, + func=func, + guild_ids=guild_ids, + commands=guild_cmds, + name=_name, + description=_description, + default_member_permission=default_required_permissions, + is_nsfw=is_nsfw if is_nsfw is not MISSING else False, + options=_options, + connector=connector + ) + else: + app_cmds = self._application_commands_by_type + base, base_command, sub_command_group = None, None, None + if base_name: + try: + base_command = app_cmds['chat_input'][base_name] + except KeyError: + base_command = app_cmds['chat_input'][base_name] = SlashCommand( + name=base_name, + name_localizations=base_name_localizations, + description=base_desc or 'No Description', + description_localizations=base_desc_localizations, + default_member_permissions=default_required_permissions, + allow_dm=allow_dm if allow_dm is not MISSING else True, + is_nsfw=is_nsfw if is_nsfw is not MISSING else False, + integration_types=allowed_integration_types if allowed_integration_types is not MISSING else None, + contexts=allowed_contexts if allowed_contexts is not MISSING else None + ) + else: + if base_desc: + base_command.description = base_desc + if is_nsfw is not MISSING: + base_command.is_nsfw = is_nsfw + if allow_dm is not MISSING: + base_command.allow_dm = allow_dm + if allowed_integration_types is not MISSING: + base_command.integration_types.update(allowed_integration_types) + if allowed_contexts is not MISSING: + base_command.contexts.update(allowed_contexts) + base_command.name_localizations.update(base_name_localizations) + base_command.description_localizations.update(base_desc_localizations) + base = base_command + if group_name: + try: + sub_command_group = app_cmds['chat_input'][base_name]._sub_commands[group_name] + except KeyError: + sub_command_group = app_cmds['chat_input'][base_name]._sub_commands[group_name] = SubCommandGroup( + parent=base_command, + name=group_name, + name_localizations=group_name_localizations, + description=group_desc or 'No Description', + description_localizations=group_desc_localizations + ) + else: + if group_desc: + sub_command_group.description = group_desc + sub_command_group.name_localizations.update(group_name_localizations) + sub_command_group.description_localizations.update(group_desc_localizations) + base = sub_command_group + if base: + command = base._sub_commands[_name] = SubCommand( + parent=base, + func=func, + name=_name, + name_localizations=name_localizations, + description=_description, + description_localizations=description_localizations, + options=_options, + connector=connector + ) + else: + command = app_cmds['chat_input'][_name] = SlashCommand( + func=func, + name=_name, + name_localizations=name_localizations, + description=_description or 'No Description', + description_localizations=description_localizations, + default_member_permissions=default_required_permissions, + allow_dm=allow_dm if allow_dm is not MISSING else True, + integration_types=allowed_integration_types if allowed_integration_types is not MISSING else None, + contexts=allowed_contexts if allowed_contexts is not MISSING else None, + is_nsfw=is_nsfw if is_nsfw is not MISSING else False, + options=_options, + connector=connector + ) + + return command + return decorator + + def activity_primary_entry_point_command( + self, + name: Optional[str] = 'launch', + name_localizations: Localizations = Localizations(), + description: Optional[str] = '', + description_localizations: Localizations = Localizations(), + default_required_permissions: Optional[Permissions] = None, + allowed_contexts: Optional[List[InteractionContextType]] = None, + allowed_integration_types: Optional[List[AppIntegrationType]] = None, + is_nsfw: bool = False, + ) -> Callable[[Awaitable[Any]], SlashCommand]: + """ + A decorator that sets the handler function for the + :ddocs:`primary entry point ` of an activity. + + **This overwrites the default activity command created by Discord.** + + .. note:: + If you only want to change the name, description, permissions, etc. of the default activity command, + use :meth:`update_activity_command` instead. + + Parameters + ---------- + name: Optional[:class:`str`] + The name of the activity command. Default to 'launch'. + name_localizations: :class:`Localizations` + Localized ``name``'s. + description: :class:`str` + The description of the activity command. + description_localizations: :class:`Localizations` + Localized ``description``'s. + default_required_permissions: Optional[:class:`Permissions`] + Permissions that a member needs by default to execute(see) the command. + allowed_contexts: Optional[List[:class:`~discord.InteractionContextType`]] + The contexts in which the command is available. + By default, commands are available in all contexts. + allowed_integration_types: Optional[List[:class:`~discord.AppIntegrationType`]] + **global commands only**: The types of app integrations where the command is available. + Default to the app's :ddocs:`configured integration types ` + is_nsfw: :class:`bool` + Whether this command is an :sup-art:`NSFW command <10123937946007-Age-Restricted-Commands>`, default :obj:`False`. + + Returns + ------- + ~discord.ActivityEntryPointCommand: + The activity command to be registered as the primary entry point. + + Raises + ------ + :exc:`TypeError`: + The function the decorator is attached to is not actual a :ref:`coroutine `. + """ + + def decorator(func: Awaitable[Any]) -> SlashCommand: + if not asyncio.iscoroutinefunction(func): + raise TypeError('The activity command function registered must be a coroutine.') + _name = name or func.__name__ + cmd = SlashCommand( + func=func, + name=_name, + name_localizations=name_localizations, + description=description, + description_localizations=description_localizations, + default_member_permissions=default_required_permissions, + contexts=allowed_contexts, + integration_types=allowed_integration_types, + is_nsfw=is_nsfw + ) + self._application_commands_by_type['primary_entry_point'][_name] = cmd + self._activity_primary_entry_point_command = cmd + return cmd + return decorator + + def message_command( + self, + name: Optional[str] = None, + name_localizations: Localizations = Localizations(), + default_required_permissions: Optional[Permissions] = None, + allow_dm: bool = True, + allowed_contexts: Optional[List[InteractionContextType]] = MISSING, + allowed_integration_types: Optional[List[AppIntegrationType]] = MISSING, + is_nsfw: bool = False, + guild_ids: Optional[List[int]] = None + ) -> Callable[[Awaitable[Any]], MessageCommand]: + """ + A decorator that registers a :class:`MessageCommand` (shows up under ``Apps`` when right-clicking on a message) + to the client. The function this is attached to must be a :ref:`coroutine `. + + .. note:: + + :attr:`~discord.Client.sync_commands` of the :class:`~discord.Client` instance must be set to :obj:`True` + to register a command if it does not already exit and update it if changes where made. + + Parameters + ---------- + name: Optional[:class:`str`] + The name of the message-command, default to the functions name. + Must be between 1-32 characters long. + name_localizations: :class:`Localizations` + Localized ``name``'s. + default_required_permissions: Optional[:class:`Permissions`] + Permissions that a member needs by default to execute(see) the command. + allow_dm: :class:`bool` + **Deprecated**: Use :attr:`allowed_contexts` instead. + Indicates whether the command is available in DMs with the app, only for globally-scoped commands. + By default, commands are visible. + allowed_contexts: Optional[List[:class:`~discord.InteractionContextType`]] + **global commands only**: The contexts in which the command is available. + By default, commands are available in all contexts. + allowed_integration_types: Optional[List[:class:`~discord.AppIntegrationType`]] + **global commands only**: The types of app integrations where the command is available. + Default to the app's :ddocs:`configured integration types ` + is_nsfw: :class:`bool` + Whether this command is an :sup-art:`NSFW command <10123937946007-Age-Restricted-Commands>` , default :obj:`False`. + guild_ids: Optional[List[:class:`int`]] + ID's of guilds this command should be registered in. If empty, the command will be global. + + Returns + ------- + ~discord.MessageCommand: + The message-command registered. + + Raises + ------ + :exc:`TypeError`: + The function the decorator is attached to is not actual a :ref:`coroutine `. + """ + def decorator(func: Awaitable[Any]) -> MessageCommand: + if not asyncio.iscoroutinefunction(func): + raise TypeError('The message-command function registered must be a coroutine.') + _name = name or func.__name__ + cmd = MessageCommand( + guild_ids=guild_ids, + func=func, + name=_name, + name_localizations=name_localizations, + default_member_permissions=default_required_permissions, + allow_dm=allow_dm, + integration_types=allowed_integration_types if allowed_integration_types is not MISSING else None, + contexts=allowed_contexts if allowed_contexts is not MISSING else None, + is_nsfw=is_nsfw + ) + if guild_ids: + for guild_id in guild_ids: + guild_cmd = MessageCommand( + guild_id=guild_id, + func=func, + name=_name, + name_localizations=name_localizations, + default_member_permissions=default_required_permissions, + allow_dm=allow_dm, + is_nsfw=is_nsfw + ) + try: + self._guild_specific_application_commands[guild_id]['message'][_name] = guild_cmd + except KeyError: + self._guild_specific_application_commands[guild_id] = { + 'chat_input': {}, + 'message': {_name: guild_cmd}, + 'user': {} + } + else: + self._application_commands_by_type['message'][_name] = cmd + + return cmd + return decorator + + def user_command( + self, + name: Optional[str] = None, + name_localizations: Localizations = Localizations(), + default_required_permissions: Optional[Permissions] = None, + allow_dm: bool = True, + allowed_contexts: Optional[List[InteractionContextType]] = MISSING, + allowed_integration_types: Optional[List[AppIntegrationType]] = MISSING, + is_nsfw: bool = False, + guild_ids: Optional[List[int]] = None + ) -> Callable[[Awaitable[Any]], UserCommand]: + """ + A decorator that registers a :class:`UserCommand` (shows up under ``Apps`` when right-clicking on a user) to the client. + The function this is attached to must be a :ref:`coroutine `. + + .. note:: + :attr:`~discord.Client.sync_commands` of the :class:`~discord.Client` instance must be set to :obj:`True` + to register a command if it does not already exist and update it if changes where made. + + Parameters + ---------- + name: Optional[:class:`str`] + The name of the user-command, default to the functions name. + Must be between 1-32 characters long. + name_localizations: :class:`Localizations` + Localized ``name``'s. + default_required_permissions: Optional[:class:`Permissions`] + Permissions that a member needs by default to execute(see) the command. + allow_dm: :class:`bool` + **Deprecated**: Use :attr:`allowed_contexts` instead. + Indicates whether the command is available in DMs with the app, only for globally-scoped commands. + By default, commands are visible. + allowed_contexts: Optional[List[:class:`~discord.InteractionContextType`]] + **global commands only**: The contexts in which the command is available. + By default, commands are available in all contexts. + allowed_integration_types: Optional[List[:class:`~discord.AppIntegrationType`]] + **global commands only**: The types of app integrations where the command is available. + Default to the app's :ddocs:`configured integration types ` + is_nsfw: :class:`bool` + Whether this command is an :sup-art:`NSFW command <10123937946007-Age-Restricted-Commands>` , default :obj:`False`. + guild_ids: Optional[List[:class:`int`]] + ID's of guilds this command should be registered in. If empty, the command will be global. + + Returns + ------- + ~discord.UserCommand: + The user-command registered. + + Raises + ------ + :exc:`TypeError`: + The function the decorator is attached to is not actual a :ref:`coroutine `. + """ + def decorator(func: Awaitable[Any]) -> UserCommand: + if not asyncio.iscoroutinefunction(func): + raise TypeError('The user-command function registered must be a coroutine.') + _name = name or func.__name__ + cmd = UserCommand( + guild_ids=guild_ids, + func=func, + name=_name, + name_localizations=name_localizations, + default_member_permissions=default_required_permissions, + allow_dm=allow_dm, + integration_types=allowed_integration_types if allowed_integration_types is not MISSING else None, + contexts=allowed_contexts if allowed_contexts is not MISSING else None, + is_nsfw=is_nsfw + ) + if guild_ids: + for guild_id in guild_ids: + guild_cmd = UserCommand( + guild_id=guild_id, + func=func, + name=_name, + name_localizations=name_localizations, + default_member_permissions=default_required_permissions, + allow_dm=allow_dm, + is_nsfw=is_nsfw + ) + try: + self._guild_specific_application_commands[guild_id]['user'][_name] = guild_cmd + except KeyError: + self._guild_specific_application_commands[guild_id] = { + 'chat_input': {}, + 'message': {}, + 'user': {_name: guild_cmd} + } + else: + self._application_commands_by_type['user'][_name] = cmd + + return cmd + return decorator + + async def _sync_commands(self) -> None: + if not hasattr(self, 'app'): + await self.application_info() + state = self._connection # Speedup attribute access + get_commands = self.http.get_application_commands + application_id = self.app.id + + to_send = [] + to_cep = [] + to_maybe_remove = [] + + any_changed = False + has_update = False + + log.info('Checking for changes on application-commands for application %s (%s)...', self.app.name, application_id) + + global_registered_raw: List[Dict] = await get_commands(application_id) + global_registered: Dict[str, List[ApplicationCommand]] = ApplicationCommand._sorted_by_type(global_registered_raw) + self._minimal_registered_global_commands_raw = minimal_registered_global_commands_raw = [] + + for x, commands in global_registered.items(): + for command in commands: + if command['name'] in self._application_commands_by_type[x].keys(): + cmd = self._application_commands_by_type[x][command['name']] + if cmd != command: + any_changed = has_update = True + c = cmd.to_dict() + c['id'] = command['id'] + to_send.append(c) + else: + to_cep.append(command) + else: + to_maybe_remove.append(command) + any_changed = True + cmd_names = [c['name'] for c in commands] + for command in self._application_commands_by_type[x].values(): + if command.name not in cmd_names: + any_changed = True + to_send.append(command.to_dict()) + + if any_changed is True: + updated = None + if (to_send_count := len(to_send)) == 1 and has_update and not to_maybe_remove: + log.info('Detected changes on global application-command %s, updating.', to_send[0]['name']) + updated = await self.http.edit_application_command(application_id, to_send[0]['id'], to_send[0]) + elif len == 1 and not has_update and not to_maybe_remove: + log.info('Registering one new global application-command %s.', to_send[0]['name']) + updated = await self.http.create_application_command(application_id, to_send[0]) + else: + if to_send_count > 0: + log.info( + f'Detected %s updated/new global application-commands, bulk overwriting them...', + to_send_count + ) + if not self.delete_not_existing_commands: + to_send.extend(to_maybe_remove) + else: + if (to_maybe_remove_count := len(to_maybe_remove)) > 0: + log.info( + 'Removing %s global application-command(s) that isn\'t/arent used in this code anymore.' + ' To prevent this set `delete_not_existing_commands` of %s to False', + to_maybe_remove_count, + self.__class__.__name__ + ) + to_send.extend(to_cep) + global_registered_raw = await self.http.bulk_overwrite_application_commands(application_id, to_send) + if updated: + global_registered_raw = await self.http.get_application_commands(application_id) + log.info('Synced global application-commands.') + else: + log.info('No changes on global application-commands found.') + + for updated in global_registered_raw: + command_type = str(ApplicationCommandType.try_value(updated['type'])) + minimal_registered_global_commands_raw.append({'id': int(updated['id']), 'type': command_type, 'name': updated['name']}) + try: + command = self._application_commands_by_type[command_type][updated['name']] + except KeyError: + command = ApplicationCommand._from_type(state, data=updated) + command.func = None + self._application_commands[command.id] = command + else: + command._fill_data(updated) + command._state = state + self._application_commands[command.id] = command + + log.info('Checking for changes on guild-specific application-commands...') + + any_guild_commands_changed = False + self._minimal_registered_guild_commands_raw = minimal_registered_guild_commands_raw = {} + + for guild_id, command_types in self._guild_specific_application_commands.items(): + to_send = [] + to_cep = [] + to_maybe_remove = [] + any_changed = False + has_update = False + try: + registered_guild_commands_raw = await self.http.get_application_commands( + application_id, + guild_id=guild_id + ) + except HTTPException: + warnings.warn( + 'Missing access to guild %s or don\'t have the application.commands scope in there, skipping!' + % guild_id + ) + continue + minimal_registered_guild_commands_raw[int(guild_id)] = minimal_registered_guild_commands = [] + registered_guild_commands = ApplicationCommand._sorted_by_type(registered_guild_commands_raw) + + for x, commands in registered_guild_commands.items(): + for command in commands: + if command['name'] in self._guild_specific_application_commands[guild_id][x].keys(): + cmd = self._guild_specific_application_commands[guild_id][x][command['name']] + if cmd != command: + any_changed = has_update = any_guild_commands_changed = True + c = cmd.to_dict() + c['id'] = command['id'] + to_send.append(c) + else: + to_cep.append(command) + else: + to_maybe_remove.append(command) + any_changed = True + cmd_names = [c['name'] for c in commands] + for command in self._guild_specific_application_commands[guild_id][x].values(): + if command.name not in cmd_names: + any_changed = True + to_send.append(command.to_dict()) + + if any_changed is True: + updated = None + if len(to_send) == 1 and has_update and not to_maybe_remove: + log.info( + 'Detected changes on application-command %s in guild %s (%s), updating.', + to_send[0]['name'], + self.get_guild(int(guild_id)), + guild_id + ) + updated = await self.http.edit_application_command( + application_id, + to_send[0]['id'], + to_send[0], + guild_id + ) + elif len(to_send) == 1 and not has_update and not to_maybe_remove: + log.info( + 'Registering one new application-command %s in guild %s (%s).', + to_send[0]['name'], + self.get_guild(int(guild_id)), + guild_id + ) + updated = await self.http.create_application_command(application_id, to_send[0], guild_id) + else: + if not self.delete_not_existing_commands: + if to_send: + to_send.extend(to_maybe_remove) + else: + if len(to_maybe_remove) > 0: + log.info( + 'Removing %s application-command(s) from guild %s (%s) that isn\'t/arent used in this code anymore.' + 'To prevent this set `delete_not_existing_commands` of %s to False', + len(to_maybe_remove), + self.get_guild(int(guild_id)), + guild_id, + self.__class__.__name__ + ) + if len(to_send) != 0: + log.info( + 'Detected %s updated/new application-command(s) for guild %s (%s), bulk overwriting them...', + len(to_send), + self.get_guild(int(guild_id)), + guild_id + ) + to_send.extend(to_cep) + registered_guild_commands_raw = await self.http.bulk_overwrite_application_commands( + application_id, + to_send, + guild_id + ) + if updated: + registered_guild_commands_raw = await self.http.get_application_commands( + application_id, + guild_id=guild_id + ) + log.info('Synced application-commands for %s (%s).' % (guild_id, self.get_guild(int(guild_id)))) + any_guild_commands_changed = True + + for updated in registered_guild_commands_raw: + command_type = str(ApplicationCommandType.try_value(updated['type'])) + minimal_registered_guild_commands.append({'id': int(updated['id']), 'type': command_type, 'name': updated['name']}) + try: + command = self._guild_specific_application_commands[int(guild_id)][command_type][updated['name']] + except KeyError: + command = ApplicationCommand._from_type(state, data=updated) + command.func = None + self._application_commands[command.id] = command + self.get_guild(int(guild_id))._application_commands[command.id] = command + else: + command._fill_data(updated) + command._state = self._connection + self._application_commands[command.id] = command + + if not any_guild_commands_changed: + log.info('No changes on guild-specific application-commands found.') + + log.info('Successful synced all global and guild-specific application-commands.') + + def _get_application_command(self, cmd_id: int) -> Optional[ApplicationCommand]: + return self._application_commands.get(cmd_id) + + def _remove_application_command(self, command: ApplicationCommand, from_cache: bool = True): + if isinstance(command, GuildOnlySlashCommand): + for guild_id in command.guild_ids: + try: + cmd = self._guild_specific_application_commands[guild_id][command.type.name][command.name] + except KeyError: + continue + else: + if from_cache: + del cmd + else: + cmd.disabled = True + self._application_commands[cmd.id] = copy.copy(cmd) + del cmd + del command + else: + if from_cache: + del command + else: + command.disabled = True + self._application_commands[command.id] = copy.copy(command) + if command.guild_id: + self._guild_specific_application_commands[command.guild_id][command.type.name].pop(command.name, None) + else: + self._application_commands_by_type[command.type.name].pop(command.name, None) + + @property + def application_commands(self) -> List[ApplicationCommand]: + """List[:class:`ApplicationCommand`]: Returns a list of any application command that is registered for the bot`""" + return list(self._application_commands.values()) + + @property + def global_application_commands(self) -> List[ApplicationCommand]: + """ + Returns a list of all global application commands that are registered for the bot + + .. note:: + This requires the bot running and all commands cached, otherwise the list will be empty + + Returns + -------- + List[:class:`ApplicationCommand`] + A list of registered global application commands for the bot + """ + commands = [] + for command in self.application_commands: + if not command.guild_id: + commands.append(command) + return commands + + async def change_presence( + self, + *, + activity: Optional[BaseActivity] = None, + status: Optional[Status] = 'online' + ) -> None: + """|coro| + + Changes the client's presence. + + .. versionchanged:: 2.0 + Removed the ``afk`` parameter + + Example + --------- + + .. code-block:: python3 + + game = discord.Game("with the API") + await client.change_presence(status=discord.Status.idle, activity=game) + + Parameters + ---------- + activity: Optional[:class:`.BaseActivity`] + The activity being done. ``None`` if no currently active activity is done. + status: Optional[:class:`.Status`] + Indicates what status to change to. If ``None``, then + :attr:`.Status.online` is used. + + Raises + ------ + :exc:`.InvalidArgument` + If the ``activity`` parameter is not the proper type. + """ + + if status is None: + status = 'online' + status_enum = Status.online + elif status is Status.offline: + status = 'invisible' + status_enum = Status.offline + else: + status_enum = status + status = str(status) + + await self.ws.change_presence(activity=activity, status=status) + + for guild in self._connection.guilds: + me = guild.me + if me is None: + continue + + if activity is not None: + me.activities = (activity,) + else: + me.activities = () + + me.status = status_enum + + # Guild stuff + + def fetch_guilds( + self, + *, + limit: Optional[int] = 100, + before: Union[Snowflake, datetime.datetime, None] = None, + after: Union[Snowflake, datetime.datetime, None] = None + ) -> GuildIterator: + """Retrieves an :class:`.AsyncIterator` that enables receiving your guilds. + + .. note:: + + Using this, you will only receive :attr:`.Guild.owner`, :attr:`.Guild.icon`, + :attr:`.Guild.id`, and :attr:`.Guild.name` per :class:`.Guild`. + + .. note:: + + This method is an API call. For general usage, consider :attr:`guilds` instead. + + Examples + --------- + + Usage :: + + async for guild in client.fetch_guilds(limit=150): + print(guild.name) + + Flattening into a list :: + + guilds = await client.fetch_guilds(limit=150).flatten() + # guilds is now a list of Guild... + + All parameters are optional. + + Parameters + ----------- + limit: Optional[:class:`int`] + The number of guilds to retrieve. + If ``None``, it retrieves every guild you have access to. Note, however, + that this would make it a slow operation. + Defaults to ``100``. + before: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] + Retrieves guilds before this date or object. + If a date is provided it must be a timezone-naive datetime representing UTC time. + after: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] + Retrieve guilds after this date or object. + If a date is provided it must be a timezone-naive datetime representing UTC time. + + Raises + ------ + discord.HTTPException + Getting the guilds failed. + + Yields + -------- + :class:`.Guild` + The guild with the guild data parsed. + """ + return GuildIterator(self, limit=limit, before=before, after=after) + + async def fetch_template(self, code: Union[Template, str]) -> Template: + """|coro| + + Gets a :class:`.Template` from a discord.new URL or code. + + Parameters + ----------- + code: Union[:class:`.Template`, :class:`str`] + The Discord Template Code or URL (must be a discord.new URL). + + Raises + ------- + :exc:`.NotFound` + The template is invalid. + :exc:`.HTTPException` + Getting the template failed. + + Returns + -------- + :class:`.Template` + The template from the URL/code. + """ + code = utils.resolve_template(code) + data = await self.http.get_template(code) + return Template(data=data, state=self._connection) + + async def fetch_guild(self, guild_id: int) -> Guild: + """|coro| + + Retrieves a :class:`.Guild` from an ID. + + .. note:: + + Using this, you will **not** receive :attr:`.Guild.channels`, :attr:`.Guild.members`, + :attr:`.Member.activity` and :attr:`.Member.voice` per :class:`.Member`. + + .. note:: + + This method is an API call. For general usage, consider :meth:`get_guild` instead. + + Parameters + ----------- + guild_id: :class:`int` + The guild's ID to fetch from. + + Raises + ------ + :exc:`.Forbidden` + You do not have access to the guild. + :exc:`.HTTPException` + Getting the guild failed. + + Returns + -------- + :class:`.Guild` + The guild from the ID. + """ + data = await self.http.get_guild(guild_id) + return Guild(data=data, state=self._connection) + + async def create_guild( + self, + name: str, + region: Optional[VoiceRegion] = None, + icon: Optional[bytes] = None, + *, + code: Optional[str] = None + ) -> Guild: + """|coro| + + Creates a :class:`.Guild`. + + Bot accounts in more than 10 guilds are not allowed to create guilds. + + Parameters + ---------- + name: :class:`str` + The name of the guild. + region: :class:`.VoiceRegion` + The region for the voice communication server. + Defaults to :attr:`.VoiceRegion.us_west`. + icon: :class:`bytes` + The :term:`py:bytes-like object` representing the icon. See :meth:`.ClientUser.edit` + for more details on what is expected. + code: Optional[:class:`str`] + The code for a template to create the guild with. + + .. versionadded:: 1.4 + + Raises + ------ + :exc:`.HTTPException` + Guild creation failed. + :exc:`.InvalidArgument` + Invalid icon image format given. Must be PNG or JPG. + + Returns + ------- + :class:`.Guild` + The guild created. This is not the same guild that is + added to cache. + """ + if icon is not None: + icon = utils._bytes_to_base64_data(icon) + + region = region or VoiceRegion.us_west + region_value = region.value + + if code: + data = await self.http.create_from_template(code, name, region_value, icon) + else: + data = await self.http.create_guild(name, region_value, icon) + return Guild(data=data, state=self._connection) + + # Invite management + + async def fetch_invite(self, url: Union[Invite, str], *, with_counts: bool = True) -> Invite: + """|coro| + + Gets an :class:`.Invite` from a discord.gg URL or ID. + + .. note:: + + If the invite is for a guild you have not joined, the guild and channel + attributes of the returned :class:`.Invite` will be :class:`.PartialInviteGuild` and + :class:`.PartialInviteChannel` respectively. + + Parameters + ----------- + url: Union[:class:`.Invite`, :class:`str`] + The Discord invite ID or URL (must be a discord.gg URL). + with_counts: :class:`bool` + Whether to include count information in the invite. This fills the + :attr:`.Invite.approximate_member_count` and :attr:`.Invite.approximate_presence_count` + fields. + + Raises + ------- + :exc:`.NotFound` + The invite has expired or is invalid. + :exc:`.HTTPException` + Getting the invite failed. + + Returns + -------- + :class:`.Invite` + The invite from the URL/ID. + """ + + invite_id = utils.resolve_invite(url) + data = await self.http.get_invite(invite_id, with_counts=with_counts) + return Invite.from_incomplete(state=self._connection, data=data) + + async def delete_invite(self, invite: Union[Invite, str]) -> None: + """|coro| + + Revokes an :class:`.Invite`, URL, or ID to an invite. + + You must have the :attr:`~.Permissions.manage_channels` permission in + the associated guild to do this. + + Parameters + ---------- + invite: Union[:class:`.Invite`, :class:`str`] + The invite to revoke. + + Raises + ------- + :exc:`.Forbidden` + You do not have permissions to revoke invites. + :exc:`.NotFound` + The invite is invalid or expired. + :exc:`.HTTPException` + Revoking the invite failed. + """ + + invite_id = utils.resolve_invite(invite) + await self.http.delete_invite(invite_id) + + # Miscellaneous stuff + + async def fetch_widget(self, guild_id: int) -> Widget: + """|coro| + + Gets a :class:`.Widget` from a guild ID. + + .. note:: + + The guild must have the widget enabled to get this information. + + Parameters + ----------- + guild_id: :class:`int` + The ID of the guild. + + Raises + ------- + :exc:`.Forbidden` + The widget for this guild is disabled. + :exc:`.HTTPException` + Retrieving the widget failed. + + Returns + -------- + :class:`.Widget` + The guild's widget. + """ + data = await self.http.get_widget(guild_id) + + return Widget(state=self._connection, data=data) + + async def application_info(self) -> AppInfo: + """|coro| + + Retrieves the bot's application information. + + Raises + ------- + :exc:`.HTTPException` + Retrieving the information failed somehow. + + Returns + -------- + :class:`.AppInfo` + The bot's application information. + """ + data = await self.http.application_info() + if 'rpc_origins' not in data: + data['rpc_origins'] = None + self.app = app = AppInfo(state=self._connection, data=data) + return app + + async def fetch_user(self, user_id): + """|coro| + + Retrieves a :class:`~discord.User` based on their ID. This can only + be used by bot accounts. You do not have to share any guilds + with the user to get this information, however many operations + do require that you do. + + .. note:: + + This method is an API call. If you have :attr:`Intents.members` and member cache enabled, consider :meth:`get_user` instead. + + Parameters + ----------- + user_id: :class:`int` + The user's ID to fetch from. + + Raises + ------- + :exc:`.NotFound` + A user with this ID does not exist. + :exc:`.HTTPException` + Fetching the user failed. + + Returns + -------- + :class:`~discord.User` + The user you requested. + """ + data = await self.http.get_user(user_id) + return User(state=self._connection, data=data) + + async def fetch_channel(self, channel_id: int): + """|coro| + + Retrieves a :class:`.abc.GuildChannel` or :class:`.abc.PrivateChannel` with the specified ID. + + .. note:: + + This method is an API call. For general usage, consider :meth:`get_channel` instead. + + .. versionadded:: 1.2 + + Raises + ------- + :exc:`.InvalidData` + An unknown channel type was received from Discord. + :exc:`.HTTPException` + Retrieving the channel failed. + :exc:`.NotFound` + Invalid Channel ID. + :exc:`.Forbidden` + You do not have permission to fetch this channel. + + Returns + -------- + Union[:class:`.abc.GuildChannel`, :class:`.abc.PrivateChannel`] + The channel from the ID. + """ + data = await self.http.get_channel(channel_id) + + factory, ch_type = _channel_factory(data['type']) + if factory is None: + raise InvalidData('Unknown channel type {type} for channel ID {id}.'.format_map(data)) + + if ch_type in (ChannelType.group, ChannelType.private): + channel = factory(me=self.user, data=data, state=self._connection) + else: + guild_id = int(data['guild_id']) + guild = self.get_guild(guild_id) or Object(id=guild_id) + channel = factory(guild=guild, state=self._connection, data=data) + + return channel + + async def fetch_webhook(self, webhook_id: int): + """|coro| + + Retrieves a :class:`.Webhook` with the specified ID. + + Raises + -------- + :exc:`.HTTPException` + Retrieving the webhook failed. + :exc:`.NotFound` + Invalid webhook ID. + :exc:`.Forbidden` + You do not have permission to fetch this webhook. + + Returns + --------- + :class:`.Webhook` + The webhook you requested. + """ + data = await self.http.get_webhook(webhook_id) + return Webhook.from_state(data, state=self._connection) + + async def fetch_all_nitro_stickers(self) -> List[StickerPack]: + """|coro| + + Retrieves a :class:`list` with all build-in :class:`~discord.StickerPack` 's. + + Returns + -------- + :class:`~discord.StickerPack` + A list containing all build-in sticker-packs. + """ + data = await self.http.get_all_nitro_stickers() + packs = [StickerPack(state=self._connection, data=d) for d in data['sticker_packs']] + return packs + + async def fetch_voice_regions(self) -> List[VoiceRegionInfo]: + """|coro| + + Returns a list of :class:`.VoiceRegionInfo` that can be used when creating or editing a + :attr:`VoiceChannel` or :attr:`StageChannel`\'s region. + + .. note:: + + This method is an API call. + For general usage, consider using the :class:`VoiceRegion` enum instead. + + Returns + -------- + List[:class:`.VoiceRegionInfo`] + The voice regions that can be used. + """ + data = await self.http.get_voice_regions() + return [VoiceRegionInfo(data=d) for d in data] + + async def create_test_entitlement( + self, + sku_id: int, + target: Union[User, Guild, Snowflake], + owner_type: Optional[Literal['guild', 'user']] = MISSING + ) -> Entitlement: + """|coro| + + .. note:: + + This method is only temporary and probably will be removed with or even before a stable v2 release + as discord is already redesigning the testing system based on developer feedback. + + See https://github.com/discord/discord-api-docs/pull/6502 for more information. + + Creates a test entitlement to a given :class:`SKU` for a given guild or user. + Discord will act as though that user or guild has entitlement to your premium offering. + + After creating a test entitlement, you'll need to reload your Discord client. + After doing so, you'll see that your server or user now has premium access. + + Parameters + ---------- + sku_id: :class:`int` + The ID of the SKU to create a test entitlement for. + target: Union[:class:`User`, :class:`Guild`, :class:`Snowflake`] + The target to create a test entitlement for. + + This can be a user, guild or just the ID, if so the owner_type parameter must be set. + owner_type: :class:`str` + The type of the ``target``, could be ``guild`` or ``user``. + + Returns + -------- + :class:`Entitlement` + The created test entitlement. + """ + target = target.id + + if isinstance(target, Guild): + owner_type = 1 + elif isinstance(target, User): + owner_type = 2 + else: + if owner_type is MISSING: + raise TypeError('owner_type must be set if target is not a Guild or user-like object.') + else: + owner_type = 1 if owner_type == 'guild' else 2 + + data = await self.http.create_test_entitlement( + self.app.id, + sku_id=sku_id, + owner_id=target, + owner_type=owner_type + ) + return Entitlement(data=data, state=self._connection) + + async def delete_test_entitlement(self, entitlement_id: int) -> None: + """|coro| + + .. note:: + + This method is only temporary and probably will be removed with or even before a stable v2 release + as discord is already redesigning the testing system based on developer feedback. + + See https://github.com/discord/discord-api-docs/pull/6502 for more information. + + Deletes a currently-active test entitlement. + Discord will act as though that user or guild no longer has entitlement to your premium offering. + + Parameters + ---------- + entitlement_id: :class:`int` + The ID of the entitlement to delete. + """ + await self.http.delete_test_entitlement(self.app.id, entitlement_id) + + async def fetch_entitlements( + self, + *, + limit: int = 100, + user: Optional[User] = None, + guild: Optional[Guild] = None, + sku_ids: Optional[List[int]] = None, + before: Optional[Union[datetime.datetime, Snowflake]] = None, + after: Optional[Union[datetime.datetime, Snowflake]] = None, + exclude_ended: bool = False + ) -> EntitlementIterator: + """|coro| + + Parameters + ---------- + limit: :class:`int` + The maximum amount of entitlements to fetch. + Defaults to ``100``. + user: Optional[:class:`User`] + The user to fetch entitlements for. + guild: Optional[:class:`Guild`] + The guild to fetch entitlements for. + sku_ids: Optional[List[:class:`int`]] + Optional list of SKU IDs to check entitlements for + before: Optional[Union[:class:`datetime.datetime`, :class:`Snowflake`]] + Retrieve entitlements before this date or object. + If a date is provided it must be a timezone-naive datetime representing UTC time. + after: Optional[Union[:class:`datetime.datetime`, :class:`Snowflake`]] + Retrieve entitlements after this date or object. + If a date is provided it must be a timezone-naive datetime representing UTC time. + exclude_ended: :class:`bool` + Whether ended entitlements should be fetched or not. Defaults to ``False``. + + Return + ------ + :class:`AsyncIterator` + An iterator to fetch all entitlements for the current application. + """ + return EntitlementIterator( + state=self._connection, + limit=limit, + user_id=user.id, + guild_id=guild.id, + sku_ids=sku_ids, + before=before, + after=after, + exclude_ended=exclude_ended + ) + + async def consume_entitlement(self, entitlement_id: int) -> None: + """|coro| + + For one-time purchase :attr:`~discord.SKUType.consumable` SKUs, + marks a given entitlement for the user as consumed. + :attr:`~discord.Entitlement.consumed` will be ``False`` for this entitlement + when using :meth:`.fetch_entitlements`. + + Parameters + ---------- + entitlement_id: :class:`int` + The ID of the entitlement to consume. + """ + await self.http.consume_entitlement(self.app.id, entitlement_id) + + async def update_primary_entry_point_command( + self, + name: Optional[str] = MISSING, + name_localizations: Localizations = MISSING, + description: Optional[str] = MISSING, + description_localizations: Localizations = MISSING, + default_required_permissions: Optional[Permissions] = MISSING, + allowed_contexts: Optional[List[InteractionContextType]] = MISSING, + allowed_integration_types: Optional[List[AppIntegrationType]] = MISSING, + is_nsfw: bool = MISSING, + handler: EntryPointHandlerType = MISSING + ): + """|coro| + + Update the :ddocs:`primary entry point command `_ of the application. + + If you don't want to handle the command, set ``handler`` to :attr:`EntryPointHandlerType.discord` + + Parameters + ---------- + name: Optional[:class:`str`] + The name of the activity command. + name_localizations: :class:`Localizations` + Localized ``name``'s. + description: Optional[:class:`str`] + The description of the activity command. + description_localizations: :class:`Localizations` + Localized ``description``'s. + default_required_permissions: Optional[:class:`Permissions`] + Permissions that a member needs by default to execute(see) the command. + allowed_contexts: Optional[List[:class:`~discord.InteractionContextType`]] + The contexts in which the command is available. + By default, commands are available in all contexts. + allowed_integration_types: Optional[List[:class:`~discord.AppIntegrationType`]] + The types of app integrations where the command is available. + Default to the app's :ddocs:`configured integration types ` + is_nsfw: :class:`bool` + Whether this command is an :sup-art:`NSFW command <10123937946007-Age-Restricted-Commands>` , default :obj:`False`. + handler: :class:`EntryPointHandlerType` + The handler for the primary entry point command. + Default to :attr:`EntryPointHandlerType.discord`, unless :attr:`.activity_primary_entry_point_command` is set. + + Returns + ------- + :class:`~discord.ActivityEntryPointCommand` + The updated primary entry point command. + + Raises + ------ + :exc:`~discord.HTTPException` + Editing the command failed. + """ + + try: + primary_entry_point_command = list(self._application_commands_by_type['primary_entry_point'].values())[0] + except IndexError: + # Request global application commands to get the primary entry point command + + # Update this if discord adds a way to get the primary entry point command directly + commands_raw = await self.http.get_application_commands(self.app.id) + commands_sorted = ApplicationCommand._sorted_by_type(commands_raw) + try: + primary_entry_point_command = commands_sorted['primary_entry_point'][0] + except IndexError: + # create a primary entry point command if it does not exist + primary_entry_point_command = ActivityEntryPointCommand( + name='launch' if name is MISSING else name, + name_localizations=None if name_localizations is MISSING else name_localizations, + description='launch tis activity' if description is MISSING else description, + description_localizations=None if description_localizations is MISSING else description_localizations, + default_member_permissions=None if default_required_permissions is MISSING else default_required_permissions, + contexts=None if allowed_contexts is MISSING else allowed_contexts, + integration_types=None if allowed_integration_types is MISSING else allowed_integration_types, + is_nsfw=False if is_nsfw is MISSING else is_nsfw, + handler=EntryPointHandlerType.discord if handler is MISSING else handler + ) + + self._application_commands_by_type['primary_entry_point'][primary_entry_point_command.name] = primary_entry_point_command + + # Register the primary entry point command + data = await self.http.create_application_command( + self.app.id, + primary_entry_point_command.to_dict() + ) + primary_entry_point_command._fill_data(data) + self._application_commands[primary_entry_point_command.id] = primary_entry_point_command + return primary_entry_point_command + + data = {} + if name is not MISSING: + data['name'] = name + if name_localizations is not MISSING: + data['name_localizations'] = name_localizations.to_dict() if name_localizations else None + if description is not MISSING: + data['description'] = description + if description_localizations is not MISSING: + data['description_localizations'] = description_localizations.to_dict() if description_localizations else None + if default_required_permissions is not MISSING: + data['default_member_permissions'] = default_required_permissions.value + if allowed_contexts is not MISSING: + data['contexts'] = [ctx.value for ctx in allowed_contexts] + if allowed_integration_types is not MISSING: + data['integration_types'] = [integration_type.value for integration_type in allowed_integration_types] + if is_nsfw is not MISSING: + data['is_nsfw'] = is_nsfw + if handler is not MISSING: + data['handler'] = handler.value + + response = await self.http.edit_application_command(self.app.id, primary_entry_point_command.id, data) + new_command = primary_entry_point_command.from_dict(self._connection, response) + + if (callback := primary_entry_point_command.func) and handler != EntryPointHandlerType.discord: + new_command.func = callback + + self._application_commands_by_type['primary_entry_point'][new_command.name] = new_command + self._application_commands[new_command.id] = new_command + return new_command diff --git a/discord/gateway.py b/discord/gateway.py index 9fc18479..4570688b 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -1,970 +1,985 @@ -# -*- coding: utf-8 -*- - -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz & (c) 2021-present mccoderpy - -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. -""" -from typing import ( - Union -) - -import asyncio -from collections import namedtuple, deque -from color_pprint import color_dumps -import concurrent.futures -import json -import logging -import struct -import sys -import time -import threading -import traceback -import zlib - -import aiohttp - -from . import utils -from .activity import BaseActivity -from .enums import SpeakingState -from .errors import ConnectionClosed, InvalidArgument - -from .types.gateway import GatewayPayload, VoiceGatewayPayload - -log = logging.getLogger(__name__) - -__all__ = ( - 'DiscordWebSocket', - 'KeepAliveHandler', - 'VoiceKeepAliveHandler', - 'DiscordVoiceWebSocket', - 'ReconnectWebSocket', -) - -# if you want to show your bot as online on mobile, change this to -# BROWSER = 'discord' -# DEVICE = 'android' -BROWSER = 'discord4py' -DEVICE = 'discord4py' - - -class ReconnectWebSocket(Exception): - """Signals to safely reconnect the websocket.""" - def __init__(self, shard_id, *, resume=True): - self.shard_id = shard_id - self.resume = resume - self.op = 'RESUME' if resume else 'IDENTIFY' - - -class WebSocketClosure(Exception): - """An exception to make up for the fact that aiohttp doesn't signal closure.""" - pass - - -EventListener = namedtuple('EventListener', 'predicate event result future') - - -class GatewayRatelimiter: - def __init__(self, count=110, per=60.0): - # The default is 110 to give room for at least 10 heartbeats per minute - self.max = count - self.remaining = count - self.window = 0.0 - self.per = per - self.lock = asyncio.Lock() - self.shard_id = None - - def is_ratelimited(self): - current = time.time() - if current > self.window + self.per: - return False - return self.remaining == 0 - - def get_delay(self): - current = time.time() - - if current > self.window + self.per: - self.remaining = self.max - - if self.remaining == self.max: - self.window = current - - if self.remaining == 0: - return self.per - (current - self.window) - - self.remaining -= 1 - if self.remaining == 0: - self.window = current - - return 0.0 - - async def block(self): - async with self.lock: - delta = self.get_delay() - if delta: - log.warning('WebSocket in shard ID %s is ratelimited, waiting %.2f seconds', self.shard_id, delta) - await asyncio.sleep(delta) - - -class KeepAliveHandler(threading.Thread): - def __init__(self, *args, **kwargs): - ws = kwargs.pop('ws', None) - interval = kwargs.pop('interval', None) - shard_id = kwargs.pop('shard_id', None) - threading.Thread.__init__(self, *args, **kwargs) - self.ws = ws - self._main_thread_id = ws.thread_id - self.interval = interval - self.daemon = True - self.shard_id = shard_id - self.msg = 'Keeping shard ID %s websocket alive with sequence %s.' - self.block_msg = 'Shard ID %s heartbeat blocked for more than %s seconds.' - self.behind_msg = 'Can\'t keep up, shard ID %s websocket is %.1fs behind.' - self._stop_ev = threading.Event() - self._last_ack = time.perf_counter() - self._last_send = time.perf_counter() - self._last_recv = time.perf_counter() - self.latency = float('inf') - self.heartbeat_timeout = ws._max_heartbeat_timeout - - def run(self): - while not self._stop_ev.wait(self.interval): - if self._last_recv + self.heartbeat_timeout < time.perf_counter(): - log.warning("Shard ID %s has stopped responding to the gateway. Closing and restarting.", self.shard_id) - coro = self.ws.close(4000) - f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) - - try: - f.result() - except Exception: - log.exception('An error occurred while stopping the gateway. Ignoring.') - finally: - self.stop() - return - - data = self.get_payload() - log.debug(self.msg, self.shard_id, data['d']) - coro = self.ws.send_heartbeat(data) - f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) - try: - # block until sending is complete - total = 0 - while True: - try: - f.result(10) - break - except concurrent.futures.TimeoutError: - total += 10 - try: - frame = sys._current_frames()[self._main_thread_id] - except KeyError: - msg = self.block_msg - else: - stack = traceback.format_stack(frame) - msg = '%s\nLoop thread traceback (most recent call last):\n%s' % (self.block_msg, ''.join(stack)) - log.warning(msg, self.shard_id, total) - - except Exception: - self.stop() - else: - self._last_send = time.perf_counter() - - def get_payload(self): - return { - 'op': self.ws.HEARTBEAT, - 'd': self.ws.sequence - } - - def stop(self): - self._stop_ev.set() - - def tick(self): - self._last_recv = time.perf_counter() - - def ack(self): - ack_time = time.perf_counter() - self._last_ack = ack_time - self.latency = ack_time - self._last_send - if self.latency > 10: - log.warning(self.behind_msg, self.shard_id, self.latency) - - -class VoiceKeepAliveHandler(KeepAliveHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.recent_ack_latencies = deque(maxlen=20) - self.msg = 'Keeping shard ID %s voice websocket alive with timestamp %s.' - self.block_msg = 'Shard ID %s voice heartbeat blocked for more than %s seconds' - self.behind_msg = 'High socket latency, shard ID %s heartbeat is %.1fs behind' - - def get_payload(self): - return { - 'op': self.ws.HEARTBEAT, - 'd': int(time.time() * 1000) - } - - def ack(self): - ack_time = time.perf_counter() - self._last_ack = ack_time - self._last_recv = ack_time - self.latency = ack_time - self._last_send - self.recent_ack_latencies.append(self.latency) - - -class DiscordClientWebSocketResponse(aiohttp.ClientWebSocketResponse): - async def close(self, *, code: int = 4000, message: bytes = b'') -> bool: - return await super().close(code=code, message=message) - - -class DiscordWebSocket: - """Implements a WebSocket for Discord's gateway v10. - - Attributes - ----------- - DISPATCH - Receive only. Denotes an event to be sent to Discord, such as READY. - HEARTBEAT - When received tells Discord to keep the connection alive. - When sent asks if your connection is currently alive. - IDENTIFY - Send only. Starts a new _session. - PRESENCE - Send only. Updates your presence. - VOICE_STATE - Send only. Starts a new connection to a voice guild. - VOICE_PING - Send only. Checks ping time to a voice guild, do not use. - RESUME - Send only. Resumes an existing connection. - RECONNECT - Receive only. Tells the client to reconnect to a new gateway. - REQUEST_MEMBERS - Send only. Asks for the full member list of a guild. - INVALIDATE_SESSION - Receive only. Tells the client to optionally invalidate the _session - and IDENTIFY again. - HELLO - Receive only. Tells the client the heartbeat interval. - HEARTBEAT_ACK - Receive only. Confirms receiving of a heartbeat. Not having it implies - a connection issue. - GUILD_SYNC - Send only. Requests a guild sync. - gateway - The gateway we are currently connected to. - token - The authentication token for discord. - """ - - DISPATCH = 0 - HEARTBEAT = 1 - IDENTIFY = 2 - PRESENCE = 3 - VOICE_STATE = 4 - VOICE_PING = 5 - RESUME = 6 - RECONNECT = 7 - REQUEST_MEMBERS = 8 - INVALIDATE_SESSION = 9 - HELLO = 10 - HEARTBEAT_ACK = 11 - GUILD_SYNC = 12 - - def __init__(self, socket, *, loop): - self.socket = socket - self.loop = loop - - # an empty dispatcher to prevent crashes - self._dispatch = lambda *args: None - # generic event listeners - self._dispatch_listeners = [] - # the keep alive - self._keep_alive = None - self.thread_id = threading.get_ident() - - # ws related stuff - self.session_id = None - self.sequence = None - self.resume_gateway_url = None - self._zlib = zlib.decompressobj() - self._buffer = bytearray() - self._close_code = None - self._rate_limiter = GatewayRatelimiter() - - @property - def open(self): - return not self.socket.closed - - def is_ratelimited(self): - return self._rate_limiter.is_ratelimited() - - @classmethod - async def from_client(cls, client, *, initial=False, gateway=None, resume_gateway_url=None, shard_id=None, session=None, sequence=None, resume=False): - """Creates a main websocket for Discord from a :class:`Client`. - - This is for internal use only. - """ - version = client.gateway_version - if resume is True and resume_gateway_url is not None: - gateway = resume_gateway_url - else: - gateway = gateway or await client.http.get_gateway(v=version) - socket = await client.http.ws_connect(gateway) - - ws = cls(socket, loop=client.loop) - - # dynamically add attributes needed - ws.token = client.http.token - ws._connection = client._connection - ws._discord_parsers = client._connection.parsers - ws._dispatch = client.dispatch - ws.gateway = gateway - ws.resume_gateway_url = resume_gateway_url - ws.call_hooks = client._connection.call_hooks - ws._initial_identify = initial - ws.shard_id = shard_id - ws._rate_limiter.shard_id = shard_id - ws.shard_count = client._connection.shard_count - ws.session_id = session - ws.sequence = sequence - ws._max_heartbeat_timeout = client._connection.heartbeat_timeout - - client._connection._update_references(ws) - - log.debug('Created websocket connected to %s', gateway) - - # poll event for OP Hello - await ws.poll_event() - - if not resume: - await ws.identify() - return ws - - await ws.resume() - return ws - - def wait_for(self, event, predicate, result=None): - """Waits for a DISPATCH'd event that meets the predicate. - - Parameters - ----------- - event: :class:`str` - The event name in all upper case to wait for. - predicate - A function that takes a data parameter to check for event - properties. The data parameter is the 'd' key in the JSON message. - result - A function that takes the same data parameter and executes to send - the result to the future. If ``None``, returns the data. - - Returns - -------- - asyncio.Future - A future to wait for. - """ - - future = self.loop.create_future() - entry = EventListener(event=event, predicate=predicate, result=result, future=future) - self._dispatch_listeners.append(entry) - return future - - async def identify(self): - """Sends the IDENTIFY packet.""" - payload = { - 'op': self.IDENTIFY, - 'd': { - 'token': self.token, - 'properties': { - 'os': sys.platform, - 'browser': BROWSER, - 'device': DEVICE, - 'referrer': '', - 'referring_domain': '' - }, - 'compress': True, - 'large_threshold': 250, - 'guild_subscriptions': self._connection.guild_subscriptions, - 'v': 3 - } - } - - if self.shard_id is not None and self.shard_count is not None: - payload['d']['shard'] = [self.shard_id, self.shard_count] - - state = self._connection - if state._activity is not None or state._status is not None: - payload['d']['presence'] = { - 'status': state._status, - 'game': state._activity, - 'since': 0, - 'afk': False - } - - if state._intents is not None: - payload['d']['intents'] = state._intents.value - - await self.call_hooks('before_identify', self.shard_id, initial=self._initial_identify) - await self.send_as_json(payload) - log.info('Shard ID %s has sent the IDENTIFY payload.', self.shard_id) - - async def resume(self): - """Sends the RESUME packet.""" - payload = { - 'op': self.RESUME, - 'd': { - 'seq': self.sequence, - 'session_id': self.session_id, - 'token': self.token - } - } - - await self.send_as_json(payload) - log.info('Shard ID %s has sent the RESUME payload.', self.shard_id) - - async def received_message(self, msg: Union[bytes, str]): - self._dispatch('socket_raw_receive', msg) - - if type(msg) is bytes: - self._buffer.extend(msg) - - if len(msg) < 4 or msg[-4:] != b'\x00\x00\xff\xff': - return - msg = self._zlib.decompress(self._buffer) - msg = msg.decode('utf-8') - self._buffer = bytearray() - msg: GatewayPayload = json.loads(msg) - - log.debug('For Shard ID %s: WebSocket Event: %s', self.shard_id, msg) - self._dispatch('socket_response', msg) - - op = msg.get('op') - data = msg.get('d') - seq = msg.get('s') - if seq is not None: - self.sequence = seq - - if self._keep_alive: - self._keep_alive.tick() - - if op != self.DISPATCH: - if op == self.RECONNECT: - # "reconnect" can only be handled by the Client - # , so we terminate our connection and raise an - # internal exception signalling to reconnect. - log.debug('Received RECONNECT opcode.') - await self.close() - raise ReconnectWebSocket(self.shard_id) - - if op == self.HEARTBEAT_ACK: - if self._keep_alive: - self._keep_alive.ack() - return - - if op == self.HEARTBEAT: - if self._keep_alive: - beat = self._keep_alive.get_payload() - await self.send_as_json(beat) - return - - if op == self.HELLO: - interval = data['heartbeat_interval'] / 1000.0 - self._keep_alive = KeepAliveHandler(ws=self, interval=interval, shard_id=self.shard_id) - # send a heartbeat immediately - await self.send_as_json(self._keep_alive.get_payload()) - self._keep_alive.start() - return - - if op == self.INVALIDATE_SESSION: - if data is True: - await self.close() - raise ReconnectWebSocket(self.shard_id) - - self.sequence = None - self.session_id = None - self.resume_gateway_url = None - log.info('Shard ID %s _session has been invalidated.', self.shard_id) - await self.close(code=1000) - raise ReconnectWebSocket(self.shard_id, resume=False) - - log.warning('Unknown OP code %s.', op) - return - - event = msg.get('t') - - if event == 'READY': - self._trace = trace = data.get('_trace', []) - self.sequence = msg['s'] - self.session_id = data['session_id'] - self.resume_gateway_url = f'{data["resume_gateway_url"]}?{self.gateway.split("?")[-1]}' # Weird way doing this but should prevent any future issues with that - # pass back shard ID to ready handler - data['__shard_id__'] = self.shard_id - - handler = logging.getLogger('discord').handlers[-1] - handler_level = handler.level - is_debug_logging = handler_level == logging.DEBUG - - log.info( - f'Shard ID %s has connected to Gateway (Session ID: %s){" - Set loglevel to DEBUG to show trace" if not is_debug_logging else ""}', - self.shard_id, - self.session_id - ) - if is_debug_logging: - if hasattr(handler, 'stream') and utils.stream_supports_colour(handler.stream): - log.debug('Trace: %s', ', '.join([color_dumps(tp, indent=None) for tp in map(json.loads, trace)])) - else: - log.debug('Trace: %s', ', '.join(trace)) - - elif event == 'RESUMED': - self._trace = trace = data.get('_trace', []) - # pass back the shard ID to the resumed handler - data['__shard_id__'] = self.shard_id - - handler = logging.getLogger('discord').handlers[-1] - handler_level = handler.level - is_debug_logging = handler_level == logging.DEBUG - - log.info( - f'Shard ID %s has resumed session %s{" - Set loglevel to DEBUG to show trace" if not is_debug_logging else ""}', - self.shard_id, - self.session_id - ) - if is_debug_logging: - if hasattr(handler, 'stream') and utils.stream_supports_colour(handler.stream): - log.debug('Trace: %s', ', '.join([color_dumps(tp, indent=None) for tp in map(json.loads, trace)])) - else: - log.debug('Trace: %s', ', '.join(trace)) - - try: - func = self._discord_parsers[event] - except KeyError: - log.debug('Unknown event %s.', event) - log.info(f'Unknown event %s! Please report this to the library developers!', event) - else: - func(data) - - # remove the dispatched listeners - removed = [] - for index, entry in enumerate(self._dispatch_listeners): - if entry.event != event: - continue - - future = entry.future - if future.cancelled(): - removed.append(index) - continue - - try: - valid = entry.predicate(data) - except Exception as exc: - future.set_exception(exc) - removed.append(index) - else: - if valid: - ret = data if entry.result is None else entry.result(data) - future.set_result(ret) - removed.append(index) - - for index in reversed(removed): - del self._dispatch_listeners[index] - - @property - def latency(self): - """:class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds.""" - heartbeat = self._keep_alive - return float('inf') if heartbeat is None else heartbeat.latency - - def _can_handle_close(self): - code = self._close_code or self.socket.close_code - return code not in (1000, 4004, 4010, 4011, 4012, 4013, 4014) - - async def poll_event(self): - """Polls for a DISPATCH event and handles the general gateway loop. - - Raises - ------ - ConnectionClosed - The websocket connection was terminated for unhandled reasons. - """ - try: - msg = await self.socket.receive(timeout=self._max_heartbeat_timeout) - if msg.type is aiohttp.WSMsgType.TEXT: - await self.received_message(msg.data) - elif msg.type is aiohttp.WSMsgType.BINARY: - await self.received_message(msg.data) - elif msg.type is aiohttp.WSMsgType.ERROR: - log.debug('Received %s', msg) - raise msg.data - elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSE): - log.debug('Received %s', msg) - raise WebSocketClosure - except (asyncio.TimeoutError, WebSocketClosure) as e: - # Ensure the keep alive handler is closed - if self._keep_alive: - self._keep_alive.stop() - self._keep_alive = None - - if isinstance(e, asyncio.TimeoutError): - log.info('Timed out receiving packet. Attempting a reconnect.') - raise ReconnectWebSocket(self.shard_id) from None - - code = self._close_code or self.socket.close_code - if self._can_handle_close(): - log.info('Websocket closed with %s, attempting a reconnect.', code) - raise ReconnectWebSocket(self.shard_id) from None - else: - log.info('Websocket closed with %s, cannot reconnect.', code) - raise ConnectionClosed(self.socket, shard_id=self.shard_id, code=code) from None - - async def send(self, data): - await self._rate_limiter.block() - self._dispatch('socket_raw_send', data) - await self.socket.send_str(data) - - async def send_as_json(self, data: GatewayPayload): - try: - await self.send(utils.to_json(data)) - except RuntimeError as exc: - if not self._can_handle_close(): - raise ConnectionClosed(self.socket, shard_id=self.shard_id) from exc - - async def send_heartbeat(self, data): - # This bypasses the rate limit handling code since it has a higher priority - try: - await self.socket.send_str(utils.to_json(data)) - except RuntimeError as exc: - if not self._can_handle_close(): - raise ConnectionClosed(self.socket, shard_id=self.shard_id) from exc - - async def change_presence(self, *, activity=None, status=None, afk=False, since=0.0): - if activity is not None: - if not isinstance(activity, BaseActivity): - raise InvalidArgument('activity must derive from BaseActivity.') - activity = activity.to_dict() - - if status == 'idle': - since = int(time.time() * 1000) - - payload = { - 'op': self.PRESENCE, - 'd': { - 'game': activity, - 'afk': afk, - 'since': since, - 'status': status - } - } - - sent = utils.to_json(payload) - log.debug('Sending "%s" to change status', sent) - await self.send(sent) - - async def request_sync(self, guild_ids): - payload = { - 'op': self.GUILD_SYNC, - 'd': list(guild_ids) - } - await self.send_as_json(payload) - - async def request_chunks(self, guild_id, query=None, *, limit, user_ids=None, presences=False, nonce=None): - payload = { - 'op': self.REQUEST_MEMBERS, - 'd': { - 'guild_id': guild_id, - 'presences': presences, - 'limit': limit - } - } - - if nonce: - payload['d']['nonce'] = nonce - - if user_ids: - payload['d']['user_ids'] = user_ids - - if query is not None: - payload['d']['query'] = query - - await self.send_as_json(payload) - - async def voice_state(self, guild_id, channel_id, self_mute=False, self_deaf=False): - payload = { - 'op': self.VOICE_STATE, - 'd': { - 'guild_id': guild_id, - 'channel_id': channel_id, - 'self_mute': self_mute, - 'self_deaf': self_deaf - } - } - - log.debug('Updating our voice state to %s.', payload) - await self.send_as_json(payload) - - async def close(self, code=4000): - if self._keep_alive: - self._keep_alive.stop() - self._keep_alive = None - - self._close_code = code - await self.socket.close(code=code) - - -class DiscordVoiceWebSocket: - """Implements the websocket protocol for handling voice connections. - - Attributes - ----------- - IDENTIFY - Send only. Starts a new voice _session. - SELECT_PROTOCOL - Send only. Tells discord what encryption mode and how to connect for voice. - READY - Receive only. Tells the websocket that the initial connection has completed. - HEARTBEAT - Send only. Keeps your websocket connection alive. - SESSION_DESCRIPTION - Receive only. Gives you the secret key required for voice. - SPEAKING - Send only. Notifies the client if you are currently speaking. - HEARTBEAT_ACK - Receive only. Tells you your heartbeat has been acknowledged. - RESUME - Sent only. Tells the client to resume its _session. - HELLO - Receive only. Tells you that your websocket connection was acknowledged. - RESUMED - Sent only. Tells you that your RESUME request has succeeded. - CLIENT_CONNECT - Indicates a user has connected to voice. - CLIENT_DISCONNECT - Receive only. Indicates a user has disconnected from voice. - """ - - IDENTIFY = 0 - SELECT_PROTOCOL = 1 - READY = 2 - HEARTBEAT = 3 - SESSION_DESCRIPTION = 4 - SPEAKING = 5 - HEARTBEAT_ACK = 6 - RESUME = 7 - HELLO = 8 - RESUMED = 9 - CLIENT_CONNECT = 12 - CLIENT_DISCONNECT = 13 - - def __init__(self, socket, loop, *, hook=None): - self.ws = socket - self.loop = loop - self._keep_alive = None - self._close_code = None - self.secret_key = None - self.ssrc_map = {} - if hook: - self._hook = hook - - async def _hook(self, *args): - pass - - async def send_as_json(self, data): - log.debug('Sending voice websocket frame: %s.', data) - await self.ws.send_str(utils.to_json(data)) - - send_heartbeat = send_as_json - - async def resume(self): - state = self._connection - payload = { - 'op': self.RESUME, - 'd': { - 'token': state.token, - 'server_id': str(state.server_id), - 'session_id': state.session_id - } - } - await self.send_as_json(payload) - - async def identify(self): - state = self._connection - payload = { - 'op': self.IDENTIFY, - 'd': { - 'server_id': str(state.server_id), - 'user_id': str(state.user.id), - 'session_id': state.session_id, - 'token': state.token - } - } - await self.send_as_json(payload) - - @classmethod - async def from_client(cls, client, *, resume=False): - """Creates a voice websocket for the :class:`VoiceClient`.""" - gateway = 'wss://' + client.endpoint + '/?v=4' - http = client._state.http - socket = await http.ws_connect(gateway, compress=15) - ws = cls(socket, loop=client.loop) - ws.gateway = gateway - ws._connection = client - ws._max_heartbeat_timeout = 60.0 - ws.thread_id = threading.get_ident() - - if resume: - await ws.resume() - else: - await ws.identify() - - return ws - - async def select_protocol(self, ip, port, mode): - payload = { - 'op': self.SELECT_PROTOCOL, - 'd': { - 'protocol': 'udp', - 'data': { - 'address': ip, - 'port': port, - 'mode': mode - } - } - } - - await self.send_as_json(payload) - - async def client_connect(self): - payload = { - 'op': self.CLIENT_CONNECT, - 'd': { - 'audio_ssrc': self._connection.ssrc - } - } - - await self.send_as_json(payload) - - async def speak(self, state=SpeakingState.voice): - payload = { - 'op': self.SPEAKING, - 'd': { - 'speaking': int(state), - 'delay': 0 - } - } - - await self.send_as_json(payload) - - async def received_message(self, msg: VoiceGatewayPayload): - log.debug('Voice websocket frame received: %s', msg) - op = msg['op'] - data = msg.get('d') - - if op == self.READY: - await self.initial_connection(data) - elif op == self.HEARTBEAT_ACK: - self._keep_alive.ack() - elif op == self.RESUMED: - log.info('Voice RESUME succeeded.') - elif op == self.SESSION_DESCRIPTION: - self._connection.mode = data['mode'] - await self.load_secret_key(data) - elif op == self.HELLO: - interval = data['heartbeat_interval'] / 1000.0 - self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=min(interval, 5.0)) - self._keep_alive.start() - elif op == self.SPEAKING: - ssrc = data['ssrc'] - user = int(data['user_id']) - speaking = data['speaking'] - if ssrc in self.ssrc_map: - self.ssrc_map[ssrc]['speaking'] = speaking - else: - self.ssrc_map.update({ssrc: {'user_id': user, 'speaking': speaking}}) - - await self._hook(self, msg) - - async def initial_connection(self, data): - state = self._connection - state.ssrc = data['ssrc'] - state.voice_port = data['port'] - state.endpoint_ip = data['ip'] - - packet = bytearray(74) - struct.pack_into('>H', packet, 0, 1) # 1 = Send - struct.pack_into('>H', packet, 2, 70) # 70 = Length - struct.pack_into('>I', packet, 4, state.ssrc) - state.socket.sendto(packet, (state.endpoint_ip, state.voice_port)) - recv = await self.loop.sock_recv(state.socket, 74) - log.debug('received packet in initial_connection: %s', recv) - - # the ip is ascii starting at the 8th byte and ending at the first null - ip_start = 8 - ip_end = recv.index(0, ip_start) - state.ip = recv[ip_start:ip_end].decode('ascii') - - state.port = struct.unpack_from('>H', recv, len(recv) - 2)[0] - log.debug('detected ip: %s port: %s', state.ip, state.port) - - # there *should* always be at least one supported mode (xsalsa20_poly1305) - modes = [mode for mode in data['modes'] if mode in self._connection.supported_modes] - log.debug('received supported encryption modes: %s', ", ".join(modes)) - - mode = modes[0] - await self.select_protocol(state.ip, state.port, mode) - log.info('selected the voice protocol for use (%s)', mode) - - @property - def latency(self): - """:class:`float`: Latency between a HEARTBEAT and its HEARTBEAT_ACK in seconds.""" - heartbeat = self._keep_alive - return float('inf') if heartbeat is None else heartbeat.latency - - @property - def average_latency(self): - """:class:`list`: Average of last 20 HEARTBEAT latencies.""" - heartbeat = self._keep_alive - if heartbeat is None or not heartbeat.recent_ack_latencies: - return float('inf') - - return sum(heartbeat.recent_ack_latencies) / len(heartbeat.recent_ack_latencies) - - async def load_secret_key(self, data): - log.info('received secret key for voice connection') - self.secret_key = self._connection.secret_key = data.get('secret_key') - await self.speak() - await self.speak(False) - - async def poll_event(self): - # This exception is handled up the chain - msg = await asyncio.wait_for(self.ws.receive(), timeout=30.0) - if msg.type is aiohttp.WSMsgType.TEXT: - await self.received_message(json.loads(msg.data)) - elif msg.type is aiohttp.WSMsgType.ERROR: - log.debug('Received %s', msg) - raise ConnectionClosed(self.ws, shard_id=None) from msg.data - elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING): - log.debug('Received %s', msg) - raise ConnectionClosed(self.ws, shard_id=None, code=self._close_code) - - async def close(self, code=1000): - if self._keep_alive is not None: - self._keep_alive.stop() - - self._close_code = code - await self.ws.close(code=code) +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz & (c) 2021-present mccoderpy + +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. +""" +from typing import ( + Union +) + +import asyncio +from collections import namedtuple, deque +from color_pprint import color_dumps +import concurrent.futures +import json +import logging +import struct +import sys +import time +import threading +import traceback +import zlib + +import aiohttp + +from . import utils +from .activity import BaseActivity +from .enums import SpeakingState +from .errors import ConnectionClosed, InvalidArgument + +from .types.gateway import GatewayPayload, VoiceGatewayPayload + +log = logging.getLogger(__name__) + +__all__ = ( + 'DiscordWebSocket', + 'KeepAliveHandler', + 'VoiceKeepAliveHandler', + 'DiscordVoiceWebSocket', + 'ReconnectWebSocket', +) + +# if you want to show your bot as online on mobile, change this to +# BROWSER = 'discord' +# DEVICE = 'android' +BROWSER = 'discord4py' +DEVICE = 'discord4py' + + +class ReconnectWebSocket(Exception): + """Signals to safely reconnect the websocket.""" + def __init__(self, shard_id, *, resume=True): + self.shard_id = shard_id + self.resume = resume + self.op = 'RESUME' if resume else 'IDENTIFY' + + +class WebSocketClosure(Exception): + """An exception to make up for the fact that aiohttp doesn't signal closure.""" + pass + + +EventListener = namedtuple('EventListener', 'predicate event result future') + + +class GatewayRatelimiter: + def __init__(self, count=110, per=60.0): + # The default is 110 to give room for at least 10 heartbeats per minute + self.max = count + self.remaining = count + self.window = 0.0 + self.per = per + self.lock = asyncio.Lock() + self.shard_id = None + + def is_ratelimited(self): + current = time.time() + if current > self.window + self.per: + return False + return self.remaining == 0 + + def get_delay(self): + current = time.time() + + if current > self.window + self.per: + self.remaining = self.max + + if self.remaining == self.max: + self.window = current + + if self.remaining == 0: + return self.per - (current - self.window) + + self.remaining -= 1 + if self.remaining == 0: + self.window = current + + return 0.0 + + async def block(self): + async with self.lock: + delta = self.get_delay() + if delta: + log.warning('WebSocket in shard ID %s is ratelimited, waiting %.2f seconds', self.shard_id, delta) + await asyncio.sleep(delta) + + +class KeepAliveHandler(threading.Thread): + def __init__(self, *args, **kwargs): + ws = kwargs.pop('ws', None) + interval = kwargs.pop('interval', None) + shard_id = kwargs.pop('shard_id', None) + threading.Thread.__init__(self, *args, **kwargs) + self.ws = ws + self._main_thread_id = ws.thread_id + self.interval = interval + self.daemon = True + self.shard_id = shard_id + self.msg = 'Keeping shard ID %s websocket alive with sequence %s.' + self.block_msg = 'Shard ID %s heartbeat blocked for more than %s seconds.' + self.behind_msg = 'Can\'t keep up, shard ID %s websocket is %.1fs behind.' + self._stop_ev = threading.Event() + self._last_ack = time.perf_counter() + self._last_send = time.perf_counter() + self._last_recv = time.perf_counter() + self.latency = float('inf') + self.heartbeat_timeout = ws._max_heartbeat_timeout + + def run(self): + while not self._stop_ev.wait(self.interval): + if self._last_recv + self.heartbeat_timeout < time.perf_counter(): + log.warning("Shard ID %s has stopped responding to the gateway. Closing and restarting.", self.shard_id) + coro = self.ws.close(4000) + f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) + + try: + f.result() + except Exception: + log.exception('An error occurred while stopping the gateway. Ignoring.') + finally: + self.stop() + return + + data = self.get_payload() + log.debug(self.msg, self.shard_id, data['d']) + coro = self.ws.send_heartbeat(data) + f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) + try: + # block until sending is complete + total = 0 + while True: + try: + f.result(10) + break + except concurrent.futures.TimeoutError: + total += 10 + try: + frame = sys._current_frames()[self._main_thread_id] + except KeyError: + msg = self.block_msg + else: + stack = traceback.format_stack(frame) + msg = '%s\nLoop thread traceback (most recent call last):\n%s' % (self.block_msg, ''.join(stack)) + log.warning(msg, self.shard_id, total) + + except Exception: + self.stop() + else: + self._last_send = time.perf_counter() + + def get_payload(self): + return { + 'op': self.ws.HEARTBEAT, + 'd': self.ws.sequence + } + + def stop(self): + self._stop_ev.set() + + def tick(self): + self._last_recv = time.perf_counter() + + def ack(self): + ack_time = time.perf_counter() + self._last_ack = ack_time + self.latency = ack_time - self._last_send + if self.latency > 10: + log.warning(self.behind_msg, self.shard_id, self.latency) + + +class VoiceKeepAliveHandler(KeepAliveHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.recent_ack_latencies = deque(maxlen=20) + self.msg = 'Keeping shard ID %s voice websocket alive with timestamp %s.' + self.block_msg = 'Shard ID %s voice heartbeat blocked for more than %s seconds' + self.behind_msg = 'High socket latency, shard ID %s heartbeat is %.1fs behind' + + def get_payload(self): + return { + 'op': self.ws.HEARTBEAT, + 'd': int(time.time() * 1000) + } + + def ack(self): + ack_time = time.perf_counter() + self._last_ack = ack_time + self._last_recv = ack_time + self.latency = ack_time - self._last_send + self.recent_ack_latencies.append(self.latency) + + +class DiscordClientWebSocketResponse(aiohttp.ClientWebSocketResponse): + async def close(self, *, code: int = 4000, message: bytes = b'') -> bool: + return await super().close(code=code, message=message) + + +class DiscordWebSocket: + """Implements a WebSocket for Discord's gateway v10. + + Attributes + ----------- + DISPATCH + Receive only. Denotes an event to be sent to Discord, such as READY. + HEARTBEAT + When received tells Discord to keep the connection alive. + When sent asks if your connection is currently alive. + IDENTIFY + Send only. Starts a new _session. + PRESENCE + Send only. Updates your presence. + VOICE_STATE + Send only. Starts a new connection to a voice guild. + VOICE_PING + Send only. Checks ping time to a voice guild, do not use. + RESUME + Send only. Resumes an existing connection. + RECONNECT + Receive only. Tells the client to reconnect to a new gateway. + REQUEST_MEMBERS + Send only. Asks for the full member list of a guild. + INVALIDATE_SESSION + Receive only. Tells the client to optionally invalidate the _session + and IDENTIFY again. + HELLO + Receive only. Tells the client the heartbeat interval. + HEARTBEAT_ACK + Receive only. Confirms receiving of a heartbeat. Not having it implies + a connection issue. + GUILD_SYNC + Send only. Requests a guild sync. + REQUEST_SOUNDBOARD_SOUNDs + Send only. Used to request soundboard sounds for a list of guilds. + gateway + The gateway we are currently connected to. + token + The authentication token for discord. + """ + + DISPATCH = 0 + HEARTBEAT = 1 + IDENTIFY = 2 + PRESENCE = 3 + VOICE_STATE = 4 + VOICE_PING = 5 + RESUME = 6 + RECONNECT = 7 + REQUEST_MEMBERS = 8 + INVALIDATE_SESSION = 9 + HELLO = 10 + HEARTBEAT_ACK = 11 + GUILD_SYNC = 12 + REQUEST_SOUNDBOARD_SOUNDs = 31 + + def __init__(self, socket, *, loop): + self.socket = socket + self.loop = loop + + # an empty dispatcher to prevent crashes + self._dispatch = lambda *args: None + # generic event listeners + self._dispatch_listeners = [] + # the keep alive + self._keep_alive = None + self.thread_id = threading.get_ident() + + # ws related stuff + self.session_id = None + self.sequence = None + self.resume_gateway_url = None + self._zlib = zlib.decompressobj() + self._buffer = bytearray() + self._close_code = None + self._rate_limiter = GatewayRatelimiter() + + @property + def open(self): + return not self.socket.closed + + def is_ratelimited(self): + return self._rate_limiter.is_ratelimited() + + @classmethod + async def from_client(cls, client, *, initial=False, gateway=None, resume_gateway_url=None, shard_id=None, session=None, sequence=None, resume=False): + """Creates a main websocket for Discord from a :class:`Client`. + + This is for internal use only. + """ + version = client.gateway_version + if resume is True and resume_gateway_url is not None: + gateway = resume_gateway_url + else: + gateway = gateway or await client.http.get_gateway(v=version) + socket = await client.http.ws_connect(gateway) + + ws = cls(socket, loop=client.loop) + + # dynamically add attributes needed + ws.token = client.http.token + ws._connection = client._connection + ws._discord_parsers = client._connection.parsers + ws._dispatch = client.dispatch + ws.gateway = gateway + ws.resume_gateway_url = resume_gateway_url + ws.call_hooks = client._connection.call_hooks + ws._initial_identify = initial + ws.shard_id = shard_id + ws._rate_limiter.shard_id = shard_id + ws.shard_count = client._connection.shard_count + ws.session_id = session + ws.sequence = sequence + ws._max_heartbeat_timeout = client._connection.heartbeat_timeout + + client._connection._update_references(ws) + + log.debug('Created websocket connected to %s', gateway) + + # poll event for OP Hello + await ws.poll_event() + + if not resume: + await ws.identify() + return ws + + await ws.resume() + return ws + + def wait_for(self, event, predicate, result=None): + """Waits for a DISPATCH'd event that meets the predicate. + + Parameters + ----------- + event: :class:`str` + The event name in all upper case to wait for. + predicate + A function that takes a data parameter to check for event + properties. The data parameter is the 'd' key in the JSON message. + result + A function that takes the same data parameter and executes to send + the result to the future. If ``None``, returns the data. + + Returns + -------- + asyncio.Future + A future to wait for. + """ + + future = self.loop.create_future() + entry = EventListener(event=event, predicate=predicate, result=result, future=future) + self._dispatch_listeners.append(entry) + return future + + async def identify(self): + """Sends the IDENTIFY packet.""" + payload = { + 'op': self.IDENTIFY, + 'd': { + 'token': self.token, + 'properties': { + 'os': sys.platform, + 'browser': BROWSER, + 'device': DEVICE, + 'referrer': '', + 'referring_domain': '' + }, + 'compress': True, + 'large_threshold': 250, + 'guild_subscriptions': self._connection.guild_subscriptions, + 'v': 3 + } + } + + if self.shard_id is not None and self.shard_count is not None: + payload['d']['shard'] = [self.shard_id, self.shard_count] + + state = self._connection + if state._activity is not None or state._status is not None: + payload['d']['presence'] = { + 'status': state._status, + 'game': state._activity, + 'since': 0, + 'afk': False + } + + if state._intents is not None: + payload['d']['intents'] = state._intents.value + + await self.call_hooks('before_identify', self.shard_id, initial=self._initial_identify) + await self.send_as_json(payload) + log.info('Shard ID %s has sent the IDENTIFY payload.', self.shard_id) + + async def resume(self): + """Sends the RESUME packet.""" + payload = { + 'op': self.RESUME, + 'd': { + 'seq': self.sequence, + 'session_id': self.session_id, + 'token': self.token + } + } + + await self.send_as_json(payload) + log.info('Shard ID %s has sent the RESUME payload.', self.shard_id) + + async def received_message(self, msg: Union[bytes, str]): + self._dispatch('socket_raw_receive', msg) + + if type(msg) is bytes: + self._buffer.extend(msg) + + if len(msg) < 4 or msg[-4:] != b'\x00\x00\xff\xff': + return + msg = self._zlib.decompress(self._buffer) + msg = msg.decode('utf-8') + self._buffer = bytearray() + msg: GatewayPayload = json.loads(msg) + + log.debug('For Shard ID %s: WebSocket Event: %s', self.shard_id, msg) + self._dispatch('socket_response', msg) + + op = msg.get('op') + data = msg.get('d') + seq = msg.get('s') + if seq is not None: + self.sequence = seq + + if self._keep_alive: + self._keep_alive.tick() + + if op != self.DISPATCH: + if op == self.RECONNECT: + # "reconnect" can only be handled by the Client + # , so we terminate our connection and raise an + # internal exception signalling to reconnect. + log.debug('Received RECONNECT opcode.') + await self.close() + raise ReconnectWebSocket(self.shard_id) + + if op == self.HEARTBEAT_ACK: + if self._keep_alive: + self._keep_alive.ack() + return + + if op == self.HEARTBEAT: + if self._keep_alive: + beat = self._keep_alive.get_payload() + await self.send_as_json(beat) + return + + if op == self.HELLO: + interval = data['heartbeat_interval'] / 1000.0 + self._keep_alive = KeepAliveHandler(ws=self, interval=interval, shard_id=self.shard_id) + # send a heartbeat immediately + await self.send_as_json(self._keep_alive.get_payload()) + self._keep_alive.start() + return + + if op == self.INVALIDATE_SESSION: + if data is True: + await self.close() + raise ReconnectWebSocket(self.shard_id) + + self.sequence = None + self.session_id = None + self.resume_gateway_url = None + log.info('Shard ID %s _session has been invalidated.', self.shard_id) + await self.close(code=1000) + raise ReconnectWebSocket(self.shard_id, resume=False) + + log.warning('Unknown OP code %s.', op) + return + + event = msg.get('t') + + if event == 'READY': + self._trace = trace = data.get('_trace', []) + self.sequence = msg['s'] + self.session_id = data['session_id'] + self.resume_gateway_url = f'{data["resume_gateway_url"]}?{self.gateway.split("?")[-1]}' # Weird way doing this but should prevent any future issues with that + # pass back shard ID to ready handler + data['__shard_id__'] = self.shard_id + + handler = logging.getLogger('discord').handlers[-1] + handler_level = handler.level + is_debug_logging = handler_level == logging.DEBUG + + log.info( + f'Shard ID %s has connected to Gateway (Session ID: %s){" - Set loglevel to DEBUG to show trace" if not is_debug_logging else ""}', + self.shard_id, + self.session_id + ) + if is_debug_logging: + if hasattr(handler, 'stream') and utils.stream_supports_colour(handler.stream): + log.debug('Trace: %s', ', '.join([color_dumps(tp, indent=None) for tp in map(json.loads, trace)])) + else: + log.debug('Trace: %s', ', '.join(trace)) + + elif event == 'RESUMED': + self._trace = trace = data.get('_trace', []) + # pass back the shard ID to the resumed handler + data['__shard_id__'] = self.shard_id + + handler = logging.getLogger('discord').handlers[-1] + handler_level = handler.level + is_debug_logging = handler_level == logging.DEBUG + + log.info( + f'Shard ID %s has resumed session %s{" - Set loglevel to DEBUG to show trace" if not is_debug_logging else ""}', + self.shard_id, + self.session_id + ) + if is_debug_logging: + if hasattr(handler, 'stream') and utils.stream_supports_colour(handler.stream): + log.debug('Trace: %s', ', '.join([color_dumps(tp, indent=None) for tp in map(json.loads, trace)])) + else: + log.debug('Trace: %s', ', '.join(trace)) + + try: + func = self._discord_parsers[event] + except KeyError: + log.debug('Unknown event %s.', event) + log.info(f'Unknown event %s! Please report this to the library developers!', event) + else: + func(data) + + # remove the dispatched listeners + removed = [] + for index, entry in enumerate(self._dispatch_listeners): + if entry.event != event: + continue + + future = entry.future + if future.cancelled(): + removed.append(index) + continue + + try: + valid = entry.predicate(data) + except Exception as exc: + future.set_exception(exc) + removed.append(index) + else: + if valid: + ret = data if entry.result is None else entry.result(data) + future.set_result(ret) + removed.append(index) + + for index in reversed(removed): + del self._dispatch_listeners[index] + + @property + def latency(self): + """:class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds.""" + heartbeat = self._keep_alive + return float('inf') if heartbeat is None else heartbeat.latency + + def _can_handle_close(self): + code = self._close_code or self.socket.close_code + return code not in (1000, 4004, 4010, 4011, 4012, 4013, 4014) + + async def poll_event(self): + """Polls for a DISPATCH event and handles the general gateway loop. + + Raises + ------ + ConnectionClosed + The websocket connection was terminated for unhandled reasons. + """ + try: + msg = await self.socket.receive(timeout=self._max_heartbeat_timeout) + if msg.type is aiohttp.WSMsgType.TEXT: + await self.received_message(msg.data) + elif msg.type is aiohttp.WSMsgType.BINARY: + await self.received_message(msg.data) + elif msg.type is aiohttp.WSMsgType.ERROR: + log.debug('Received %s', msg) + raise msg.data + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSE): + log.debug('Received %s', msg) + raise WebSocketClosure + except (asyncio.TimeoutError, WebSocketClosure) as e: + # Ensure the keep alive handler is closed + if self._keep_alive: + self._keep_alive.stop() + self._keep_alive = None + + if isinstance(e, asyncio.TimeoutError): + log.info('Timed out receiving packet. Attempting a reconnect.') + raise ReconnectWebSocket(self.shard_id) from None + + code = self._close_code or self.socket.close_code + if self._can_handle_close(): + log.info('Websocket closed with %s, attempting a reconnect.', code) + raise ReconnectWebSocket(self.shard_id) from None + else: + log.info('Websocket closed with %s, cannot reconnect.', code) + raise ConnectionClosed(self.socket, shard_id=self.shard_id, code=code) from None + + async def send(self, data): + await self._rate_limiter.block() + self._dispatch('socket_raw_send', data) + await self.socket.send_str(data) + + async def send_as_json(self, data: GatewayPayload): + try: + await self.send(utils.to_json(data)) + except RuntimeError as exc: + if not self._can_handle_close(): + raise ConnectionClosed(self.socket, shard_id=self.shard_id) from exc + + async def send_heartbeat(self, data): + # This bypasses the rate limit handling code since it has a higher priority + try: + await self.socket.send_str(utils.to_json(data)) + except RuntimeError as exc: + if not self._can_handle_close(): + raise ConnectionClosed(self.socket, shard_id=self.shard_id) from exc + + async def change_presence(self, *, activity=None, status=None, afk=False, since=0.0): + if activity is not None: + if not isinstance(activity, BaseActivity): + raise InvalidArgument('activity must derive from BaseActivity.') + activity = activity.to_dict() + + if status == 'idle': + since = int(time.time() * 1000) + + payload = { + 'op': self.PRESENCE, + 'd': { + 'game': activity, + 'afk': afk, + 'since': since, + 'status': status + } + } + + sent = utils.to_json(payload) + log.debug('Sending "%s" to change status', sent) + await self.send(sent) + + async def request_sync(self, guild_ids): + payload = { + 'op': self.GUILD_SYNC, + 'd': list(guild_ids) + } + await self.send_as_json(payload) + + async def request_chunks(self, guild_id, query=None, *, limit, user_ids=None, presences=False, nonce=None): + payload = { + 'op': self.REQUEST_MEMBERS, + 'd': { + 'guild_id': guild_id, + 'presences': presences, + 'limit': limit + } + } + + if nonce: + payload['d']['nonce'] = nonce + + if user_ids: + payload['d']['user_ids'] = user_ids + + if query is not None: + payload['d']['query'] = query + + await self.send_as_json(payload) + + async def voice_state(self, guild_id, channel_id, self_mute=False, self_deaf=False): + payload = { + 'op': self.VOICE_STATE, + 'd': { + 'guild_id': guild_id, + 'channel_id': channel_id, + 'self_mute': self_mute, + 'self_deaf': self_deaf + } + } + + log.debug('Updating our voice state to %s.', payload) + await self.send_as_json(payload) + + async def request_soundboard_sounds(self, guild_ids): + if not isinstance(guild_ids, list): + raise TypeError("guild_ids has to be a list.") + + payload = { + 'op': self.REQUEST_SOUNDBOARD_SOUNDs, + 'd': { + 'guild_ids': guild_ids + } + } + await self.send_as_json(payload) + + async def close(self, code=4000): + if self._keep_alive: + self._keep_alive.stop() + self._keep_alive = None + + self._close_code = code + await self.socket.close(code=code) + + +class DiscordVoiceWebSocket: + """Implements the websocket protocol for handling voice connections. + + Attributes + ----------- + IDENTIFY + Send only. Starts a new voice _session. + SELECT_PROTOCOL + Send only. Tells discord what encryption mode and how to connect for voice. + READY + Receive only. Tells the websocket that the initial connection has completed. + HEARTBEAT + Send only. Keeps your websocket connection alive. + SESSION_DESCRIPTION + Receive only. Gives you the secret key required for voice. + SPEAKING + Send only. Notifies the client if you are currently speaking. + HEARTBEAT_ACK + Receive only. Tells you your heartbeat has been acknowledged. + RESUME + Sent only. Tells the client to resume its _session. + HELLO + Receive only. Tells you that your websocket connection was acknowledged. + RESUMED + Sent only. Tells you that your RESUME request has succeeded. + CLIENT_CONNECT + Indicates a user has connected to voice. + CLIENT_DISCONNECT + Receive only. Indicates a user has disconnected from voice. + """ + + IDENTIFY = 0 + SELECT_PROTOCOL = 1 + READY = 2 + HEARTBEAT = 3 + SESSION_DESCRIPTION = 4 + SPEAKING = 5 + HEARTBEAT_ACK = 6 + RESUME = 7 + HELLO = 8 + RESUMED = 9 + CLIENT_CONNECT = 12 + CLIENT_DISCONNECT = 13 + + def __init__(self, socket, loop, *, hook=None): + self.ws = socket + self.loop = loop + self._keep_alive = None + self._close_code = None + self.secret_key = None + self.ssrc_map = {} + if hook: + self._hook = hook + + async def _hook(self, *args): + pass + + async def send_as_json(self, data): + log.debug('Sending voice websocket frame: %s.', data) + await self.ws.send_str(utils.to_json(data)) + + send_heartbeat = send_as_json + + async def resume(self): + state = self._connection + payload = { + 'op': self.RESUME, + 'd': { + 'token': state.token, + 'server_id': str(state.server_id), + 'session_id': state.session_id + } + } + await self.send_as_json(payload) + + async def identify(self): + state = self._connection + payload = { + 'op': self.IDENTIFY, + 'd': { + 'server_id': str(state.server_id), + 'user_id': str(state.user.id), + 'session_id': state.session_id, + 'token': state.token + } + } + await self.send_as_json(payload) + + @classmethod + async def from_client(cls, client, *, resume=False): + """Creates a voice websocket for the :class:`VoiceClient`.""" + gateway = 'wss://' + client.endpoint + '/?v=4' + http = client._state.http + socket = await http.ws_connect(gateway, compress=15) + ws = cls(socket, loop=client.loop) + ws.gateway = gateway + ws._connection = client + ws._max_heartbeat_timeout = 60.0 + ws.thread_id = threading.get_ident() + + if resume: + await ws.resume() + else: + await ws.identify() + + return ws + + async def select_protocol(self, ip, port, mode): + payload = { + 'op': self.SELECT_PROTOCOL, + 'd': { + 'protocol': 'udp', + 'data': { + 'address': ip, + 'port': port, + 'mode': mode + } + } + } + + await self.send_as_json(payload) + + async def client_connect(self): + payload = { + 'op': self.CLIENT_CONNECT, + 'd': { + 'audio_ssrc': self._connection.ssrc + } + } + + await self.send_as_json(payload) + + async def speak(self, state=SpeakingState.voice): + payload = { + 'op': self.SPEAKING, + 'd': { + 'speaking': int(state), + 'delay': 0 + } + } + + await self.send_as_json(payload) + + async def received_message(self, msg: VoiceGatewayPayload): + log.debug('Voice websocket frame received: %s', msg) + op = msg['op'] + data = msg.get('d') + + if op == self.READY: + await self.initial_connection(data) + elif op == self.HEARTBEAT_ACK: + self._keep_alive.ack() + elif op == self.RESUMED: + log.info('Voice RESUME succeeded.') + elif op == self.SESSION_DESCRIPTION: + self._connection.mode = data['mode'] + await self.load_secret_key(data) + elif op == self.HELLO: + interval = data['heartbeat_interval'] / 1000.0 + self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=min(interval, 5.0)) + self._keep_alive.start() + elif op == self.SPEAKING: + ssrc = data['ssrc'] + user = int(data['user_id']) + speaking = data['speaking'] + if ssrc in self.ssrc_map: + self.ssrc_map[ssrc]['speaking'] = speaking + else: + self.ssrc_map.update({ssrc: {'user_id': user, 'speaking': speaking}}) + + await self._hook(self, msg) + + async def initial_connection(self, data): + state = self._connection + state.ssrc = data['ssrc'] + state.voice_port = data['port'] + state.endpoint_ip = data['ip'] + + packet = bytearray(74) + struct.pack_into('>H', packet, 0, 1) # 1 = Send + struct.pack_into('>H', packet, 2, 70) # 70 = Length + struct.pack_into('>I', packet, 4, state.ssrc) + state.socket.sendto(packet, (state.endpoint_ip, state.voice_port)) + recv = await self.loop.sock_recv(state.socket, 74) + log.debug('received packet in initial_connection: %s', recv) + + # the ip is ascii starting at the 8th byte and ending at the first null + ip_start = 8 + ip_end = recv.index(0, ip_start) + state.ip = recv[ip_start:ip_end].decode('ascii') + + state.port = struct.unpack_from('>H', recv, len(recv) - 2)[0] + log.debug('detected ip: %s port: %s', state.ip, state.port) + + # there *should* always be at least one supported mode (xsalsa20_poly1305) + modes = [mode for mode in data['modes'] if mode in self._connection.supported_modes] + log.debug('received supported encryption modes: %s', ", ".join(modes)) + + mode = modes[0] + await self.select_protocol(state.ip, state.port, mode) + log.info('selected the voice protocol for use (%s)', mode) + + @property + def latency(self): + """:class:`float`: Latency between a HEARTBEAT and its HEARTBEAT_ACK in seconds.""" + heartbeat = self._keep_alive + return float('inf') if heartbeat is None else heartbeat.latency + + @property + def average_latency(self): + """:class:`list`: Average of last 20 HEARTBEAT latencies.""" + heartbeat = self._keep_alive + if heartbeat is None or not heartbeat.recent_ack_latencies: + return float('inf') + + return sum(heartbeat.recent_ack_latencies) / len(heartbeat.recent_ack_latencies) + + async def load_secret_key(self, data): + log.info('received secret key for voice connection') + self.secret_key = self._connection.secret_key = data.get('secret_key') + await self.speak() + await self.speak(False) + + async def poll_event(self): + # This exception is handled up the chain + msg = await asyncio.wait_for(self.ws.receive(), timeout=30.0) + if msg.type is aiohttp.WSMsgType.TEXT: + await self.received_message(json.loads(msg.data)) + elif msg.type is aiohttp.WSMsgType.ERROR: + log.debug('Received %s', msg) + raise ConnectionClosed(self.ws, shard_id=None) from msg.data + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING): + log.debug('Received %s', msg) + raise ConnectionClosed(self.ws, shard_id=None, code=self._close_code) + + async def close(self, code=1000): + if self._keep_alive is not None: + self._keep_alive.stop() + + self._close_code = code + await self.ws.close(code=code) diff --git a/discord/guild.py b/discord/guild.py index 23271a79..5d87266c 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -1,3772 +1,4018 @@ -# -*- coding: utf-8 -*- - -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz & (c) 2021-present mccoderpy - -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. -""" -from __future__ import annotations - -import copy -from datetime import datetime -from typing import ( - Union, - Optional, - overload, - List, - Tuple, - Dict, - Any, - Awaitable, - Iterable, - Iterator, - NamedTuple, - TYPE_CHECKING -) -from typing_extensions import Literal - -if TYPE_CHECKING: - from os import PathLike - from .types import guild as g - from .state import ConnectionState - from .abc import Snowflake, GuildChannel - from .ext.commands import Cog - from .automod import AutoModRule - from .voice_client import VoiceProtocol - from .template import Template - from .webhook import Webhook - from .application_commands import ApplicationCommand, SlashCommandOption, SubCommandGroup, SubCommand - -from . import utils -from .file import UploadFile -from .role import Role -from .member import Member, VoiceState -from .emoji import Emoji -from .errors import InvalidData -from .permissions import PermissionOverwrite, Permissions -from .colour import Colour -from .errors import InvalidArgument, ClientException -from .channel import * -from .enums import ( - VoiceRegion, - ChannelType, - Locale, - VerificationLevel, - ContentFilter, - NotificationLevel, - EventEntityType, - AuditLogAction, - AutoModTriggerType, - AutoModEventType, - AutoArchiveDuration, - IntegrationType, - OnboardingMode, - try_enum -) -from .mixins import Hashable -from .scheduled_event import GuildScheduledEvent -from .user import User -from .invite import Invite -from .iterators import AuditLogIterator, MemberIterator, BanIterator, BanEntry -from .onboarding import * -from .welcome_screen import * -from .widget import Widget -from .asset import Asset -from .flags import SystemChannelFlags -from .integrations import _integration_factory, Integration -from .sticker import GuildSticker -from .automod import AutoModRule, AutoModTriggerMetadata, AutoModAction -from .application_commands import SlashCommand, MessageCommand, UserCommand, Localizations - -MISSING = utils.MISSING - -__all__ = ( - 'GuildFeatures', - 'Guild', -) - - -class _GuildLimit(NamedTuple): - emoji: int - sticker: int - bitrate: float - filesize: int - - -async def default_callback(interaction, *args, **kwargs): - await interaction.respond( - 'This command has no callback set.' - 'Probably something is being tested with him and he is not yet fully developed.', - hidden=True - ) - - -class GuildFeatures(Iterable[str], dict): - """ - Represents a guild's features. - - This class mainly exists to make it easier to edit a guild's features. - - .. versionadded:: 2.0 - - .. container:: operations - - .. describe:: 'FEATURE_NAME' in features - - Checks if the guild has the feature. - - .. describe:: features.FEATURE_NAME - - Checks if the guild has the feature. Returns ``False`` if it doesn't. - - .. describe:: features.FEATURE_NAME = True - - Enables the feature in the features object, but does not enable it in the guild itself except if you pass it to :meth:`Guild.edit`. - - .. describe:: features.FEATURE_NAME = False - - Disables the feature in the features object, but does not disable it in the guild itself except if you pass it to :meth:`Guild.edit`. - - .. describe:: del features.FEATURE_NAME - - The same as ``features.FEATURE_NAME = False`` - - .. describe:: features.parsed() - - Returns a list of all features that are/should be enabled. - - .. describe:: features.merge(other) - - Returns a new object with the features of both objects merged. - If a feature is missing in the other object, it will be ignored. - - .. describe:: features == other - - Checks if two feature objects are equal. - - .. describe:: features != other - - Checks if two feature objects are not equal. - - .. describe:: iter(features) - - Returns an iterator over the enabled features. - """ - def __init__(self, /, initial: List[str] = [], **features: bool): - """ - Parameters - ----------- - initial: :class:`list` - The initial features to set. - **features: :class:`bool` - The features to set. If the value is ``True`` then the feature is/will be enabled. - If the value is ``False`` then the feature will be disabled. - """ - for feature in initial: - features[feature] = True - self.__dict__.update(features) - - def __iter__(self) -> Iterator[str]: - return [feature for feature, value in self.__dict__.items() if value is True].__iter__() - - def __contains__(self, item: str) -> bool: - return item in self.__dict__ and self.__dict__[item] is True - - def __getattr__(self, item: str) -> bool: - return self.__dict__.get(item, False) - - def __setattr__(self, key: str, value: bool) -> None: - self.__dict__[key] = value - - def __delattr__(self, item: str) -> None: - self.__dict__[item] = False - - def __repr__(self) -> str: - return f'' - - def __str__(self) -> str: - return str(self.__dict__) - - def keys(self) -> Iterator[str]: - return self.__dict__.keys().__iter__() - - def values(self) -> Iterator[bool]: - return self.__dict__.values().__iter__() - - def items(self) -> Iterator[Tuple[str, bool]]: - return self.__dict__.items().__iter__() - - def merge(self, other: GuildFeatures) -> GuildFeatures: - base = copy.copy(self.__dict__) - - for key, value in other.items(): - base[key] = value - - return GuildFeatures(**base) - - def parsed(self) -> List[str]: - return [name for name, value in self.__dict__.items() if value is True] - - def __eq__(self, other: GuildFeatures) -> bool: - current = self.__dict__ - other = other.__dict__ - - all_keys = set(current.keys()) | set(other.keys()) - - for key in all_keys: - try: - current_value = current[key] - except KeyError: - if other[key] is True: - return False - else: - try: - other_value = other[key] - except KeyError: - pass - else: - if current_value != other_value: - return False - - return True - - def __ne__(self, other: GuildFeatures) -> bool: - return not self.__eq__(other) - - -class Guild(Hashable): - """Represents a Discord guild. - - This is referred to as a "server" in the official Discord UI. - - .. container:: operations - - .. describe:: x == y - - Checks if two guilds are equal. - - .. describe:: x != y - - Checks if two guilds are not equal. - - .. describe:: hash(x) - - Returns the guild's hash. - - .. describe:: str(x) - - Returns the guild's name. - - Attributes - ---------- - name: :class:`str` - The guild name. - emojis: Tuple[:class:`Emoji`, ...] - All emojis that the guild owns. - afk_timeout: :class:`int` - The timeout to get sent to the AFK channel. - afk_channel: Optional[:class:`VoiceChannel`] - The channel that denotes the AFK channel. ``None`` if it doesn't exist. - icon: Optional[:class:`str`] - The guild's icon. - id: :class:`int` - The guild's ID. - owner_id: :class:`int` - The guild owner's ID. Use :attr:`Guild.owner` instead. - unavailable: :class:`bool` - Indicates if the guild is unavailable. If this is ``True`` then the - reliability of other attributes outside of :attr:`Guild.id` is slim, and they might - all be ``None``. It is best to not do anything with the guild if it is unavailable. - - Check the :func:`on_guild_unavailable` and :func:`on_guild_available` events. - max_presences: Optional[:class:`int`] - The maximum amount of presences for the guild. - max_members: Optional[:class:`int`] - The maximum amount of members for the guild. - - .. note:: - - This attribute is only available via :meth:`.Client.fetch_guild`. - max_video_channel_users: Optional[:class:`int`] - The maximum amount of users in a video channel. - - .. versionadded:: 1.4 - banner: Optional[:class:`str`] - The guild's banner. - description: Optional[:class:`str`] - The guild's description. - mfa_level: :class:`int` - Indicates the guild's two-factor authorisation level. If this value is 0 then - the guild does not require 2FA for their administrative members. If the value is - 1 then they do. - verification_level: :class:`VerificationLevel` - The guild's verification level. - explicit_content_filter: :class:`ContentFilter` - The guild's explicit content filter. - default_notifications: :class:`NotificationLevel` - The guild's notification settings. - features: List[:class:`str`] - A list of features that the guild has. They are currently as follows: - - - ``VIP_REGIONS``: Guild has VIP voice regions - - ``VANITY_URL``: Guild can have a vanity invite URL (e.g. discord.gg/discord-api) - - ``INVITE_SPLASH``: Guild's invite page can have a special splash. - - ``VERIFIED``: Guild is a verified server. - - ``PARTNERED``: Guild is a partnered server. - - ``MORE_EMOJI``: Guild is allowed to have more than 50 custom emoji. - - ``MORE_STICKER``: Guild is allowed to have more than 60 custom sticker. - - ``DISCOVERABLE``: Guild shows up in Server Discovery. - - ``FEATURABLE``: Guild is able to be featured in Server Discovery. - - ``COMMUNITY``: Guild is a community server. - - ``PUBLIC``: Guild is a public guild. - - ``NEWS``: Guild can create news channels. - - ``BANNER``: Guild can upload and use a banner (i.e. :meth:`banner_url`). - - ``ANIMATED_ICON``: Guild can upload an animated icon. - - ``PUBLIC_DISABLED``: Guild cannot be public. - - ``WELCOME_SCREEN_ENABLED``: Guild has enabled the welcome screen - - ``MEMBER_VERIFICATION_GATE_ENABLED``: Guild has Membership Screening enabled. - - ``PREVIEW_ENABLED``: Guild can be viewed before being accepted via Membership Screening. - - splash: Optional[:class:`str`] - The guild's invite splash. - premium_tier: :class:`int` - The premium tier for this guild. Corresponds to "Nitro Server" in the official UI. - The number goes from 0 to 3 inclusive. - premium_subscription_count: :class:`int` - The number of "boosts" this guild currently has. - preferred_locale: Optional[:class:`Locale`] - The preferred locale for the guild. Used when filtering Server Discovery - results to a specific language. - discovery_splash: :class:`str` - The guild's discovery splash. - .. versionadded:: 1.3 - premium_progress_bar_enabled: :class:`bool` - Whether the guild has the boost progress bar enabled. - invites_disabled_until: Optional[:class:`~datetime.datetime`] - When this is set to a time in the future, then invites to the guild are disabled until that time. - .. versionadded:: 2.0 - dms_disabled_until: Optional[:class:`~datetime.datetime`] - When this is set to a time in the future, then direct messages to members of the guild are disabled - (unless users are friends to each other) until that time. - .. versionadded:: 2.0 - """ - - __slots__ = ('afk_timeout', 'afk_channel', '_members', '_channels', '_stage_instances', - 'icon', 'name', 'id', 'unavailable', 'banner', 'region', '_state', - '_application_commands', '_roles', '_events', '_member_count', '_large', - 'owner_id', 'mfa_level', 'emojis', 'features', - 'verification_level', 'explicit_content_filter', 'splash', - '_voice_states', '_system_channel_id', 'default_notifications', - 'description', 'max_presences', 'max_members', 'max_video_channel_users', - 'premium_tier', 'premium_subscription_count', '_system_channel_flags', - 'preferred_locale', 'discovery_splash', '_rules_channel_id', - '_public_updates_channel_id', '_safety_alerts_channel_id', - 'premium_progress_bar_enabled', '_welcome_screen', - 'stickers', '_automod_rules', 'invites_disabled_until', 'dms_disabled_until',) - - _PREMIUM_GUILD_LIMITS = { - None: _GuildLimit(emoji=50, sticker=5, bitrate=96e3, filesize=8388608), - 0: _GuildLimit(emoji=50, sticker=5, bitrate=96e3, filesize=8388608), - 1: _GuildLimit(emoji=100, sticker=15, bitrate=128e3, filesize=8388608), - 2: _GuildLimit(emoji=150, sticker=30, bitrate=256e3, filesize=52428800), - 3: _GuildLimit(emoji=250, sticker=60, bitrate=384e3, filesize=104857600), - } - - if TYPE_CHECKING: - name: str - verification_level: VerificationLevel - default_notifications: NotificationLevel - explicit_content_filter: ContentFilter - afk_timeout: int - region: VoiceRegion - mfa_level: int - emojis: Tuple[Emoji, ...] - features: List[str] - splash: Optional[str] - banner: Optional[str] - icon: Optional[str] - id: int - owner_id: int - description: Optional[str] - max_presences: Optional[int] - max_members: Optional[int] - max_video_channel_users: Optional[int] - premium_tier: int - premium_subscription_count: int - preferred_locale: Locale - discovery_splash: Optional[str] - premium_progress_bar_enabled: bool - invites_disabled_until: Optional[datetime] - dms_disabled_until: Optional[datetime] - unavailable: bool - _roles: Dict[int, Role] - _system_channel_id: Optional[int] - _system_channel_flags: int - _rules_channel_id: Optional[int] - _public_updates_channel_id: Optional[int] - _safety_alerts_channel_id: Optional[int] - _welcome_screen: Optional[WelcomeScreen] - _member_count: Optional[int] - _large: Optional[bool] - - def __init__(self, *, data, state: ConnectionState): - self._channels: Dict[int, Union[GuildChannel, ThreadChannel]] = {} - self._stage_instances: Dict[int, StageInstance] = {} - self._members: Dict[int, Member] = {} - self._events: Dict[int, GuildScheduledEvent] = {} - self._automod_rules: Dict[int, AutoModRule] = {} - self._voice_states: Dict[int, VoiceState] = {} - self._state: ConnectionState = state - self._application_commands: Dict[int, ApplicationCommand] = {} - self._from_data(data) - - def _add_channel(self, channel: Union[GuildChannel, ThreadChannel]): - self._channels[channel.id] = channel - if isinstance(channel, ThreadChannel): - parent = channel.parent_channel - addr = getattr(parent, '_add_thread', getattr(parent, '_add_post', None)) - if addr is not None: - addr(channel) - - def _remove_channel(self, channel: Union[GuildChannel, ThreadChannel]): - self._channels.pop(channel.id, None) - if isinstance(channel, ThreadChannel): - remvr = getattr( - channel.parent_channel, '_remove_thread', getattr(channel.parent_channel, '_remove_post', None) - ) - if remvr is not None: - remvr(channel) - - def _add_event(self, event: GuildScheduledEvent): - self._events[event.id] = event - - def _remove_event(self, event: GuildScheduledEvent): - self._events.pop(event.id, None) - - def _add_stage_instance(self, instance: StageInstance): - self._stage_instances[instance.channel.id] = instance - - def _remove_stage_instance(self, instance: StageInstance): - self._stage_instances.pop(instance.channel.id, None) - - def _add_automod_rule(self, rule: AutoModRule): - self._automod_rules[rule.id] = rule - - def _remove_automod_rule(self, rule: AutoModRule): - self._automod_rules.pop(rule.id, None) - - def _voice_state_for(self, user_id: int): - return self._voice_states.get(user_id) - - def _add_member(self, member: Member): - self._members[member.id] = member - - def _remove_member(self, member: Member): - self._members.pop(member.id, None) - - def __str__(self): - return self.name or '' - - def __repr__(self): - attrs = ( - 'id', 'name', 'shard_id', 'chunked' - ) - resolved = ['%s=%r' % (attr, getattr(self, attr)) for attr in attrs] - resolved.append('member_count=%r' % getattr(self, '_member_count', None)) - return '' % ' '.join(resolved) - - def _update_voice_state(self, data, channel_id): - user_id = int(data['user_id']) - channel = self.get_channel(channel_id) - try: - # check if we should remove the voice state from cache - if channel is None: - after = self._voice_states.pop(user_id) - else: - after = self._voice_states[user_id] - - before = copy.copy(after) - after._update(data, channel) - except KeyError: - # if we're here then we're getting added into the cache - after = VoiceState(data=data, channel=channel) - before = VoiceState(data=data, channel=None) - self._voice_states[user_id] = after - - member = self.get_member(user_id) - if member is None: - try: - member = Member(data=data['member'], state=self._state, guild=self) - except KeyError: - member = None - - return member, before, after - - def _add_role(self, role: Role): - # roles get added to the bottom (position 1, pos 0 is @everyone) - # so since self.roles has the @everyone role, we can't increment - # its position because it's stuck at position 0. Luckily x += False - # is equivalent to adding 0. So we cast the position to a bool and - # increment it. - for r in self._roles.values(): - r.position += (not r.is_default()) - - self._roles[role.id] = role - - def _remove_role(self, role_id: int) -> Role: - # this raises KeyError if it fails.. - role = self._roles.pop(role_id) - - # since it didn't, we can change the positions now - # basically the same as above except we only decrement - # the position if we're above the role we deleted. - for r in self._roles.values(): - r.position -= r.position > role.position - - return role - - def _from_data(self, guild: g.Guild) -> None: - # according to Stan, this is always available even if the guild is unavailable - # I don't have this guarantee when someone updates the guild. - member_count = guild.get('member_count', None) - if member_count is not None: - self._member_count = member_count - - self.name = guild.get('name') - self.region = try_enum(VoiceRegion, guild.get('region')) # TODO: remove in 2.0 release - self.verification_level = try_enum(VerificationLevel, guild.get('verification_level')) - self.default_notifications = try_enum(NotificationLevel, guild.get('default_message_notifications')) - self.explicit_content_filter = try_enum(ContentFilter, guild.get('explicit_content_filter', 0)) - self.afk_timeout = guild.get('afk_timeout') - self.icon = guild.get('icon') - self.banner = guild.get('banner') - self.unavailable = guild.get('unavailable', False) - self.id = int(guild['id']) - self._roles: Dict[int, Role] = {} - state = self._state # speed up attribute access - for r in guild.get('roles', []): - role = Role(guild=self, data=r, state=state) - self._roles[role.id] = role - for e in guild.get('guild_scheduled_events', []): - state.store_event(guild=self, data=e) - self.mfa_level = guild.get('mfa_level') - self.emojis = tuple(map(lambda d: state.store_emoji(self, d), guild.get('emojis', []))) - self.features = guild.get('features', []) # TODO: make this a private attribute and add a features property that returns GuildFeatures - self.splash = guild.get('splash') - self._system_channel_id = utils._get_as_snowflake(guild, 'system_channel_id') - self.description = guild.get('description') - self.max_presences = guild.get('max_presences') - self.max_members = guild.get('max_members') - self.max_video_channel_users = guild.get('max_video_channel_users') - self.premium_tier = guild.get('premium_tier', 0) - self.premium_subscription_count = guild.get('premium_subscription_count') or 0 - self._system_channel_flags = guild.get('system_channel_flags', 0) - self.preferred_locale = try_enum(Locale, guild.get('preferred_locale')) - self.discovery_splash = guild.get('discovery_splash') - self._rules_channel_id = utils._get_as_snowflake(guild, 'rules_channel_id') - self._public_updates_channel_id = utils._get_as_snowflake(guild, 'public_updates_channel_id') - self._safety_alerts_channel_id = utils._get_as_snowflake(guild, 'safety_alerts_channel_id') - self.premium_progress_bar_enabled: bool = guild.get('premium_progress_bar_enabled', False) - self._handle_incidents_data(guild.get('incidents_data')) - cache_online_members = self._state.member_cache_flags.online - cache_joined = self._state.member_cache_flags.joined - self_id = self._state.self_id - for mdata in guild.get('members', []): - member = Member(data=mdata, guild=self, state=state) - if cache_joined or (cache_online_members and member.raw_status != 'offline') or member.id == self_id: - self._add_member(member) - - self._sync(guild) - self._large = None if member_count is None else self._member_count >= 250 - - self.owner_id = utils._get_as_snowflake(guild, 'owner_id') - self.afk_channel = self.get_channel(utils._get_as_snowflake(guild, 'afk_channel_id')) - welcome_screen = guild.get('welcome_screen', None) - if welcome_screen: - self._welcome_screen = WelcomeScreen(guild=self, state=self._state, data=welcome_screen) - else: - self._welcome_screen = None - self.stickers: Tuple[GuildSticker] = tuple(map(lambda d: state.store_sticker(d), guild.get('stickers', []))) # type: ignore - for obj in guild.get('voice_states', []): - self._update_voice_state(obj, int(obj['channel_id'])) - - def _sync(self, data): - try: - self._large = data['large'] - except KeyError: - pass - - for presence in data.get('presences', []): - user_id = int(presence['user']['id']) - member = self.get_member(user_id) - if member is not None: - member._presence_update(presence) - - _state = self._state - if 'channels' in data: - channels = data['channels'] - for c in channels: - factory, ch_type = _channel_factory(c['type']) - if factory: - self._add_channel(factory(guild=self, data=c, state=_state)) - - if 'threads' in data: - threads = data['threads'] - for t in threads: - factory, ch_type = _channel_factory(t['type']) - if factory: - parent_channel = self.get_channel(int(t['parent_id'])) - if parent_channel is None: - continue # we don't know why this happens sometimes, we skipp this for now - thread = factory(guild=self, data=t, state=_state) - self._add_channel(thread) - if isinstance(parent_channel, ForumChannel): - parent_channel._add_post(thread) - else: - self._add_channel(thread) - parent_channel._add_thread(thread) - - if 'stage_instances' in data: - for i in data['stage_instances']: - stage = self.get_channel(int(i['channel_id'])) - if stage is not None: - self._add_stage_instance(StageInstance(state=_state, channel=stage, data=i)) - - def _handle_incidents_data(self, incidents_data: Optional[g.IncidentsData]): - if incidents_data: - if invites_disabled_until := incidents_data.get('invites_disabled_until'): - self.invites_disabled_until = datetime.fromisoformat(invites_disabled_until) - else: - self.invites_disabled_until = None - if dms_disabled_until := incidents_data.get('dms_disabled_until'): - self.dms_disabled_until = datetime.fromisoformat(dms_disabled_until) - else: - self.dms_disabled_until = None - else: - self.invites_disabled_until = None - self.dms_disabled_until = None - - @property - def application_commands(self) -> List[ApplicationCommand]: - """List[:class:`~discord.ApplicationCommand`]: A list of application-commands from this application that are registered only in this guild. - """ - return list(self._application_commands.values()) - - def get_application_command(self, id: int) -> Optional[ApplicationCommand]: - """Optional[:class:`~discord.ApplicationCommand`]: Returns an application-command from this application that are registered only in this guild with the given id""" - return self._application_commands.get(id, None) - - @property - def channels(self) -> List[Union[GuildChannel, ThreadChannel, ForumPost]]: - """List[:class:`abc.GuildChannel`, :class:`ThreadChannel`, :class:`ForumPost`]: A list of channels that belongs to this guild.""" - return list(self._channels.values()) - - def stage_instances(self) -> List[StageInstance]: - """List[:class:`~discord.StageInstance`]: A list of stage instances that belongs to this guild.""" - return list(self._stage_instances.values()) - - @property - def server_guide_channels(self) -> List[GuildChannel]: - """List[:class:`abc.GuildChannel`]: A list of channels that are part of the servers guide.""" - return [c for c in self._channels.values() if c.flags.is_resource_channel] - - @property - def events(self) -> List[GuildScheduledEvent]: - """List[:class:`~discord.GuildScheduledEvent`]: A list of scheduled events that belong to this guild.""" - return list(self._events.values()) - - scheduled_events = events - - @property - def cached_automod_rules(self) -> List[AutoModRule]: - """ - List[:class:`AutoModRules`]: A list of auto moderation rules that are cached. - - .. admonition:: Reliable Fetching - :class: helpful - - This property is only reliable if :meth:`~Guild.automod_rules` was used before. - To ensure that the rules are up-to-date, use :meth:`~Guild.automod_rules` instead. - """ - return list(self._automod_rules.values()) - - def get_event(self, id: int) -> Optional[GuildScheduledEvent]: - """ - Returns a scheduled event with the given ID. - - Parameters - ---------- - id: :class:`int` - The ID of the event to get. - - Returns - ------- - Optional[:class:`~discord.GuildScheduledEvent`] - The scheduled event or ``None`` if not found. - """ - return self._events.get(id) - - get_scheduled_event = get_event - - @property - def large(self) -> bool: - """:class:`bool`: Indicates if the guild is a 'large' guild. - - A large guild is defined as having more than ``large_threshold`` count - members, which for this library is set to the maximum of 250. - """ - if self._large is None: - try: - return self._member_count >= 250 - except AttributeError: - return len(self._members) >= 250 - return self._large - - @property - def voice_channels(self) -> List[VoiceChannel]: - """List[:class:`VoiceChannel`]: A list of voice channels that belongs to this guild. - - This is sorted by the position and are in UI order from top to bottom. - """ - r = [ch for ch in self._channels.values() if isinstance(ch, VoiceChannel)] - r.sort(key=lambda c: (c.position, c.id)) - return r - - @property - def stage_channels(self) -> List[StageChannel]: - """List[:class:`StageChannel`]: A list of voice channels that belongs to this guild. - - .. versionadded:: 1.7 - - This is sorted by the position and are in UI order from top to bottom. - """ - r = [ch for ch in self._channels.values() if isinstance(ch, StageChannel)] - r.sort(key=lambda c: (c.position, c.id)) - return r - - @property - def me(self) -> Member: - """:class:`Member`: Similar to :attr:`Client.user` except an instance of :class:`Member`. - This is essentially used to get the member version of yourself. - """ - self_id = self._state.user.id - return self.get_member(self_id) - - @property - def voice_client(self) -> Optional[VoiceProtocol]: - """Optional[:class:`VoiceProtocol`]: Returns the :class:`VoiceProtocol` associated with this guild, if any.""" - return self._state._get_voice_client(self.id) - - @property - def text_channels(self) -> List[TextChannel]: - """List[:class:`TextChannel`]: A list of text channels that belongs to this guild. - - This is sorted by the position and are in UI order from top to bottom. - """ - r = [ch for ch in self._channels.values() if isinstance(ch, TextChannel)] - r.sort(key=lambda c: (c.position, c.id)) - return r - - @property - def thread_channels(self) -> List[ThreadChannel]: - """List[:class:`ThreadChannel`]: A list of **cached** thread channels the guild has. - - This is sorted by the position of the threads :attr:`~discord.ThreadChannel.parent` - and are in UI order from top to bottom. - """ - r = list() - [r.extend(ch.threads) for ch in self._channels.values() if isinstance(ch, TextChannel)] - r.sort(key=lambda t: (t.parent_channel.position, t.id)) - return r - - @property - def forum_channels(self) -> List[ForumChannel]: - """List[:class:`ForumChannel`]: A list of forum channels the guild has. - - This is sorted by the position of the forums :attr:`~discord.ForumChannel.parent` - and are in UI order from top to bottom. - """ - r = [ch for ch in self._channels.values() if isinstance(ch, ForumChannel)] - r.sort(key=lambda f: (f.parent_channel.position, f.id)) - return r - - @property - def forum_posts(self) -> List[ForumPost]: - """List[:class:`ForumPost`]: A list of **cached** forum posts the guild has. - - This is sorted by the position of the forums :attr:`~discord.ForumPost.parent` - and are in UI order from top to bottom. - """ - r = [ch for ch in self._channels.values() if isinstance(ch, ForumPost)] - r.sort(key=lambda f: (f.parent_channel.position, f.id)) - return r - - @property - def guide_channels(self) -> GuildChannel: - """List[:class:`GuildChannel`]: A list of channels that are part of the server guide. - - There is an alias for this called :attr:`~Guild.resource_channels`. - """ - r: List[GuildChannel] = [ch for ch in self._channels.values() if ch.flags.is_resource_channel] - r.sort(key=lambda c: (c.position, c.id)) - return r - - resource_channels = guide_channels - - @property - def categories(self) -> List[CategoryChannel]: - """List[:class:`CategoryChannel`]: A list of categories that belongs to this guild. - - This is sorted by the position and are in UI order from top to bottom. - """ - r = [ch for ch in self._channels.values() if isinstance(ch, CategoryChannel)] - r.sort(key=lambda c: (c.position, c.id)) - return r - - def by_category(self) -> List[Tuple[Optional[CategoryChannel], List[GuildChannel]]]: - """Returns every :class:`CategoryChannel` and their associated channels. - - These channels and categories are sorted in the official Discord UI order. - - If the channels do not have a category, then the first element of the tuple is - ``None``. - - Returns - -------- - List[Tuple[Optional[:class:`CategoryChannel`], List[:class:`abc.GuildChannel`]]]: - The categories and their associated channels. - """ - grouped = {} - for channel in self._channels.values(): - if isinstance(channel, CategoryChannel): - grouped.setdefault(channel.id, []) - continue - if isinstance(channel, ThreadChannel): - continue - try: - grouped[channel.category_id].append(channel) - except KeyError: - grouped[channel.category_id] = [channel] - - def key(t): - k, v = t - return ((k.position, k.id) if k else (-1, -1), v) - - _get = self._channels.get - as_list = [(_get(k), v) for k, v in grouped.items()] - as_list.sort(key=key) - for _, channels in as_list: - channels.sort(key=lambda c: (c._sorting_bucket, c.position, c.id)) - return as_list - - def get_channel(self, channel_id: int) -> Optional[ - Union[CategoryChannel, TextChannel, StageChannel, VoiceChannel, ThreadChannel, ForumPost] - ]: - """Returns a channel with the given ID. - - Parameters - ----------- - channel_id: :class:`int` - The ID to search for. - - Returns - -------- - Optional[Union[:class:`.abc.GuildChannel`, :class:`ThreadChannel`, :class:`ForumPost`]] - The channel or ``None`` if not found. - """ - return self._channels.get(channel_id) - - @property - def system_channel(self) -> Optional[TextChannel]: - """Optional[:class:`TextChannel`]: Returns the guild's channel used for system messages. - - If no channel is set, then this returns ``None``. - """ - channel_id = self._system_channel_id - return channel_id and self._channels.get(channel_id) - - @property - def system_channel_flags(self) -> SystemChannelFlags: - """:class:`SystemChannelFlags`: Returns the guild's system channel settings.""" - return SystemChannelFlags._from_value(self._system_channel_flags) - - - @property - def rules_channel(self) -> Optional[TextChannel]: - """Optional[:class:`TextChannel`]: Return's the guild's channel used for the rules. - The guild must be a Community guild. - - If no channel is set, then this returns ``None``. - - .. versionadded:: 1.3 - """ - channel_id = self._rules_channel_id - return channel_id and self._channels.get(channel_id) - - @property - def public_updates_channel(self) -> Optional[TextChannel]: - """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 - Community guild. - - If no channel is set, then this returns ``None``. - - .. versionadded:: 2.0 - """ - channel_id = self._public_updates_channel_id - return channel_id and self._channels.get(channel_id) - - @property - def safety_alerts_channel(self) -> Optional[TextChannel]: - """Optional[:class:`TextChannel`]: Return's the guild's channel where Discord - sends safety alerts in. The guild must be a Community guild. - - If no channel is set, then this returns ``None``. - - .. versionadded:: 2.0 - """ - channel_id = self._safety_alerts_channel_id - return channel_id and self._channels.get(channel_id) - - @property - def emoji_limit(self) -> int: - """:class:`int`: The maximum number of emoji slots this guild has.""" - more_emoji = 200 if 'MORE_EMOJI' in self.features else 50 - return max(more_emoji, self._PREMIUM_GUILD_LIMITS[self.premium_tier].emoji) - - @property - def sticker_limit(self) -> int: - """:class:`int`: The maximum number of sticker slots this guild has.""" - more_sticker = 60 if 'MORE_STICKER' in self.features else 5 - return max(more_sticker, self._PREMIUM_GUILD_LIMITS[self.premium_tier].sticker) - - @property - def bitrate_limit(self) -> float: - """:class:`float`: The maximum bitrate for voice channels this guild can have.""" - vip_guild = self._PREMIUM_GUILD_LIMITS[1].bitrate if 'VIP_REGIONS' in self.features else 96e3 - return max(vip_guild, self._PREMIUM_GUILD_LIMITS[self.premium_tier].bitrate) - - @property - def filesize_limit(self) -> int: - """:class:`int`: The maximum number of bytes files can have when uploaded to this guild.""" - return self._PREMIUM_GUILD_LIMITS[self.premium_tier].filesize - - @property - def members(self) -> List[Member]: - """List[:class:`Member`]: A list of members that belong to this guild.""" - return list(self._members.values()) - - def get_member(self, user_id: int) -> Optional[Member]: - """Returns a member with the given ID. - - Parameters - ----------- - user_id: :class:`int` - The ID to search for. - - Returns - -------- - Optional[:class:`Member`] - The member or ``None`` if not found. - """ - return self._members.get(user_id) - - @property - def premium_subscribers(self) -> List[Member]: - """List[:class:`Member`]: A list of members who have "boosted" this guild.""" - return [member for member in self.members if member.premium_since is not None] - - @property - def roles(self) -> List[Role]: - """List[:class:`Role`]: Returns a :class:`list` of the guild's roles in hierarchy order. - - The first element of this list will be the lowest role in the - hierarchy. - """ - return sorted(self._roles.values()) - - def get_role(self, role_id) -> Optional[Role]: - """Returns a role with the given ID. - - Parameters - ----------- - role_id: :class:`int` - The ID to search for. - - Returns - -------- - Optional[:class:`Role`] - The role or ``None`` if not found. - """ - return self._roles.get(role_id) - - @property - def default_role(self) -> Role: - """: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[Role]: - """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[Role]: - """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[Member]: - """Optional[:class:`Member`]: The member that owns the guild.""" - return self.get_member(self.owner_id) - - @property - def icon_url(self) -> Asset: - """:class:`Asset`: Returns the guild's icon asset.""" - return self.icon_url_as() - - def is_icon_animated(self) -> bool: - """:class:`bool`: Returns True if the guild has an animated icon.""" - return bool(self.icon and self.icon.startswith('a_')) - - def icon_url_as( - self, - *, - format: Literal['webp', 'jpeg', 'jpg', 'png', 'gif'] = None, - static_format: Literal['webp', 'jpeg', 'jpg', 'png'] = 'webp', - size: int = 1024 - ) -> Asset: - """Returns an :class:`Asset` for the guild's icon. - - The format must be one of 'webp', 'jpeg', 'jpg', 'png' or 'gif', and - 'gif' is only valid for animated avatars. The size must be a power of 2 - between 16 and 4096. - - Parameters - ----------- - format: Optional[:class:`str`] - The format to attempt to convert the icon to. - If the format is ``None``, then it is automatically - detected into either 'gif' or static_format depending on the - icon being animated or not. - static_format: Optional[:class:`str`] - Format to attempt to convert only non-animated icons to. - size: :class:`int` - The size of the image to display. - - Raises - ------ - InvalidArgument - Bad image format passed to ``format`` or invalid ``size``. - - Returns - -------- - :class:`Asset` - The resulting CDN asset. - """ - return Asset._from_guild_icon(self._state, self, format=format, static_format=static_format, size=size) - - @property - def banner_url(self) -> Asset: - """:class:`Asset`: Returns the guild's banner asset.""" - return self.banner_url_as() - - def banner_url_as( - self, - *, - format: Literal['webp', 'jpeg', 'jpg', 'png'] = 'webp', - size: int = 2048 - ) -> Asset: - """Returns an :class:`Asset` for the guild's banner. - - The format must be one of 'webp', 'jpeg', or 'png'. The - size must be a power of 2 between 16 and 4096. - - Parameters - ----------- - format: :class:`str` - The format to attempt to convert the banner to. - size: :class:`int` - The size of the image to display. - - Raises - ------ - InvalidArgument - Bad image format passed to ``format`` or invalid ``size``. - - Returns - -------- - :class:`Asset` - The resulting CDN asset. - """ - return Asset._from_guild_image(self._state, self.id, self.banner, 'banners', format=format, size=size) - - @property - def splash_url(self) -> Asset: - """:class:`Asset`: Returns the guild's invite splash asset.""" - return self.splash_url_as() - - def splash_url_as( - self, - *, - format: Literal['webp', 'jpeg', 'jpg', 'png'] = 'webp', - size: int = 2048 - ) -> Asset: - """Returns an :class:`Asset` for the guild's invite splash. - - The format must be one of 'webp', 'jpeg', 'jpg', or 'png'. The - size must be a power of 2 between 16 and 4096. - - Parameters - ----------- - format: :class:`str` - The format to attempt to convert the splash to. - size: :class:`int` - The size of the image to display. - - Raises - ------ - InvalidArgument - Bad image format passed to ``format`` or invalid ``size``. - - Returns - -------- - :class:`Asset` - The resulting CDN asset. - """ - return Asset._from_guild_image(self._state, self.id, self.splash, 'splashes', format=format, size=size) - - @property - def discovery_splash_url(self) -> Asset: - """:class:`Asset`: Returns the guild's discovery splash asset. - - .. versionadded:: 1.3 - """ - return self.discovery_splash_url_as() - - def discovery_splash_url_as( - self, - *, - format: Literal['webp', 'jpeg', 'jpg', 'png'] = 'webp', - size: int = 2048 - ) -> Asset: - """Returns an :class:`Asset` for the guild's discovery splash. - - The format must be one of 'webp', 'jpeg', 'jpg', or 'png'. The - size must be a power of 2 between 16 and 4096. - - .. versionadded:: 1.3 - - Parameters - ----------- - format: :class:`str` - The format to attempt to convert the splash to. - size: :class:`int` - The size of the image to display. - - Raises - ------ - InvalidArgument - Bad image format passed to ``format`` or invalid ``size``. - - Returns - -------- - :class:`Asset` - The resulting CDN asset. - """ - return Asset._from_guild_image( - self._state, - self.id, - self.discovery_splash, - 'discovery-splashes', - format=format, - size=size - ) - - @property - def member_count(self) -> int: - """:class:`int`: Returns the true member count regardless of it being loaded fully or not. - - .. warning:: - - Due to a Discord limitation, in order for this attribute to remain up-to-date and - accurate, it requires :attr:`Intents.members` to be specified. - - """ - return self._member_count - - @property - def chunked(self) -> bool: - """:class:`bool`: Returns a boolean indicating if the guild is "chunked". - - A chunked guild means that :attr:`member_count` is equal to the - number of members stored in the internal :attr:`members` cache. - - If this value returns ``False``, then you should request for - offline members. - """ - count = getattr(self, '_member_count', None) - if count is None: - return False - return count == len(self._members) - - @property - def shard_id(self) -> Optional[int]: - """Optional[:class:`int`]: Returns the shard ID for this guild if applicable.""" - count = self._state.shard_count - if count is None: - return None - return (self.id >> 22) % count - - @property - def created_at(self) -> datetime: - """:class:`datetime.datetime`: Returns the guild's creation time in UTC.""" - return utils.snowflake_time(self.id) - - def get_member_named(self, name: str) -> Optional[Member]: - """Returns the first member found that matches the name provided. - - The name can have an optional discriminator argument, e.g. "Jake#0001" - or "Jake" will both do the lookup. However, the former will give a more - precise result. Note that the discriminator must have all 4 digits - for this to work. - - If a nickname is passed, then it is looked up via the nickname. Note - however, that a nickname + discriminator combo will not lookup the nickname - but rather the username + discriminator combo due to nickname + discriminator - not being unique. - - If no member is found, ``None`` is returned. - - Parameters - ----------- - name: :class:`str` - The name of the member to lookup with an optional discriminator. - - Returns - -------- - Optional[:class:`Member`] - The member in this guild with the associated name. If not found - then ``None`` is returned. - """ - - result = None - members = self.members - if len(name) > 5 and name[-5] == '#': - # The 5 length is checking to see if #0000 is in the string, - # as a#0000 has a length of 6, the minimum for a potential - # discriminator lookup. - potential_discriminator = name[-4:] - - # do the actual lookup and return if found - # if it isn't found then we'll do a full name lookup below. - result = utils.get(members, name=name[:-5], discriminator=potential_discriminator) - if result is not None: - return result - - def pred(m: Member): - return m.nick == name or m.global_name == name or m.name == name - - return utils.find(pred, members) - - def _create_channel( - self, - name: str, - overwrites: Dict[Union[Role, Member], PermissionOverwrite], - channel_type: ChannelType, - category: Optional[CategoryChannel] = None, - reason: Optional[str] = None, - **options: Any - ): - if overwrites is None: - overwrites = {} - elif not isinstance(overwrites, dict): - raise InvalidArgument('overwrites parameter expects a dict.') - - perms = [] - for target, perm in overwrites.items(): - if not isinstance(target, (Role, Member)): - raise InvalidArgument('Expected Member or Role received {0.__name__}'.format(type(target))) - if not isinstance(perm, PermissionOverwrite): - raise InvalidArgument('Expected PermissionOverwrite received {0.__name__}'.format(type(perm))) - - allow, deny = perm.pair() - payload = { - 'allow': allow.value, - 'deny': deny.value, - 'id': target.id - } - - if isinstance(target, Role): - payload['type'] = 'role' - else: - payload['type'] = 'member' - - perms.append(payload) - - try: - options['rate_limit_per_user'] = options.pop('slowmode_delay') - except KeyError: - pass - - try: - rtc_region = options.pop('rtc_region') - except KeyError: - pass - else: - options['rtc_region'] = None if rtc_region is None else str(rtc_region) - - if channel_type.text or channel_type.forum_channel: - try: - default_auto_archive_duration: AutoArchiveDuration = options.pop('default_auto_archive_duration') - except KeyError: - pass - else: - default_auto_archive_duration = try_enum(AutoArchiveDuration, default_auto_archive_duration) - if not isinstance(default_auto_archive_duration, AutoArchiveDuration): - raise InvalidArgument( - f'{default_auto_archive_duration} is not a valid default_auto_archive_duration' - ) - else: - options['default_auto_archive_duration'] = default_auto_archive_duration.value - - try: - options['default_thread_rate_limit_per_user']: int = options.pop('default_thread_slowmode_delay') - except KeyError: - pass - - parent_id = category.id if category else None - return self._state.http.create_channel( - self.id, channel_type.value, name=name, parent_id=parent_id, - permission_overwrites=perms, reason=reason, **options - ) - - async def create_text_channel( - self, - name: str, - *, - overwrites: Optional[Dict[Snowflake, PermissionOverwrite]] = None, - category: Optional[CategoryChannel] = None, - reason: Optional[str] = None, - **options - ) -> TextChannel: - """|coro| - - Creates a :class:`TextChannel` for the guild. - - Note that you need the :attr:`~Permissions.manage_channels` permission - to create the channel. - - The ``overwrites`` parameter can be used to create a 'secret' - channel upon creation. This parameter expects a :class:`dict` of - overwrites with the target (either a :class:`Member` or a :class:`Role`) - as the key and a :class:`PermissionOverwrite` as the value. - - .. note:: - - Creating a channel of a specified position will not update the position of - other channels to follow suit. A follow-up call to :meth:`~TextChannel.edit` - will be required to update the position of the channel in the channel list. - - Examples - ---------- - - Creating a basic channel: - - .. code-block:: python3 - - channel = await guild.create_text_channel('cool-channel') - - Creating a "secret" channel: - - .. code-block:: python3 - - overwrites = { - guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.me: discord.PermissionOverwrite(read_messages=True) - } - - channel = await guild.create_text_channel('secret', overwrites=overwrites) - - Parameters - ----------- - name: :class:`str` - The channel's name. - overwrites - A :class:`dict` of target (either a role or a member) to - :class:`PermissionOverwrite` to apply upon creation of a channel. - Useful for creating secret channels. - category: Optional[:class:`CategoryChannel`] - The category to place the newly created channel under. - The permissions will be automatically synced to category if no - overwrites are provided. - position: :class:`int` - The position in the channel list. This is a number that starts - at 0. e.g. the top channel is position 0. - topic: Optional[:class:`str`] - The new channel's topic. - slowmode_delay: :class:`int` - Specifies the slowmode rate limit for user in this channel, in seconds. - The maximum value possible is `21600`. - default_thread_slowmode_delay: :class:`int` - The initial ``slowmode_delay`` to set on newly created threads in the channel. - This field is copied to the thread at creation time and does not live update. - nsfw: :class:`bool` - To mark the channel as NSFW or not. - reason: Optional[:class:`str`] - The reason for creating this channel. Shows up on the audit log. - - Raises - ------- - Forbidden - You do not have the proper permissions to create this channel. - HTTPException - Creating the channel failed. - InvalidArgument - The permission overwrite information is not in proper form. - - Returns - ------- - :class:`TextChannel` - The channel that was just created. - """ - data = await self._create_channel(name, overwrites, ChannelType.text, category, reason=reason, **options) - channel = TextChannel(state=self._state, guild=self, data=data) - - # temporarily add to the cache - self._channels[channel.id] = channel - return channel - - async def create_voice_channel( - self, - name: str, - *, - overwrites: Optional[Dict[Snowflake, PermissionOverwrite]] = None, - category: Optional[CategoryChannel] = None, - reason: Optional[str] = None, - **options - ) -> VoiceChannel: - """|coro| - - This is similar to :meth:`create_text_channel` except makes a :class:`VoiceChannel` instead, in addition - to having the following new parameters. - - Parameters - ----------- - bitrate: :class:`int` - The channel's preferred audio bitrate in bits per second. - user_limit: :class:`int` - The channel's limit for number of members that can be in a voice channel. - rtc_region: Optional[:class:`VoiceRegion`] - The region for the voice channel's voice communication. - A value of ``None`` indicates automatic voice region detection. - - .. versionadded:: 1.7 - - Raises - ------ - Forbidden - You do not have the proper permissions to create this channel. - HTTPException - Creating the channel failed. - InvalidArgument - The permission overwrite information is not in proper form. - - Returns - ------- - :class:`VoiceChannel` - The channel that was just created. - """ - data = await self._create_channel(name, overwrites, ChannelType.voice, category, reason=reason, **options) - channel = VoiceChannel(state=self._state, guild=self, data=data) - - # temporarily add to the cache - self._channels[channel.id] = channel - return channel - - async def create_stage_channel( - self, - name: str, - *, - topic: Optional[str] = None, - category: Optional[CategoryChannel] = None, - overwrites: Optional[Dict[Snowflake, PermissionOverwrite]] = None, - reason: Optional[str] = None, - position: Optional[int] = None - ) -> StageChannel: - """|coro| - - This is similar to :meth:`create_text_channel` except makes a :class:`StageChannel` instead, in addition - to having the following new parameters. - - Parameters - ---------- - topic: Optional[:class:`str`] - The topic of the Stage instance (1-120 characters) - - .. note:: - - The ``slowmode_delay`` and ``nsfw`` parameters are not supported in this function. - - .. versionadded:: 1.7 - - Raises - ------ - Forbidden - You do not have the proper permissions to create this channel. - HTTPException - Creating the channel failed. - InvalidArgument - The permission overwrite information is not in proper form. - - Returns - ------- - :class:`StageChannel` - The channel that was just created. - """ - data = await self._create_channel( - name, overwrites, ChannelType.stage_voice, category, reason=reason, position=position, topic=topic - ) - channel = StageChannel(state=self._state, guild=self, data=data) - - # temporarily add to the cache - self._channels[channel.id] = channel - return channel - - async def create_forum_channel( - self, - name: str, - *, - topic: Optional[str] = None, - slowmode_delay: Optional[int] = None, - default_post_slowmode_delay: Optional[int] = None, - default_auto_archive_duration: Optional[AutoArchiveDuration] = None, - overwrites: Optional[Dict[Snowflake, PermissionOverwrite]] = None, - nsfw: Optional[bool] = None, - category: Optional[CategoryChannel] = None, - position: Optional[int] = None, - reason: Optional[str] = None - ) -> ForumChannel: - """|coro| - - Same as :meth:`create_text_channel` excepts that it creates a forum channel instead - - Parameters - ---------- - name: :class:`str` - The name of the channel - overwrites - A :class:`dict` of target (either a role or a member) to - :class:`PermissionOverwrite` to apply upon creation of a channel. - Useful for creating secret channels. - category: Optional[:class:`CategoryChannel`] - The category to place the newly created channel under. - The permissions will be automatically synced to category if no - overwrites are provided. - position: :class:`int` - The position in the channel list. This is a number that starts - at 0. e.g. the top channel is position 0. - topic: Optional[:class:`str`] - The new channel's topic. - slowmode_delay: :class:`int` - Specifies the slowmode rate limit for user in this channel, in seconds. - The maximum value possible is `21600`. - default_post_slowmode_delay: :class:`int` - The initial ``slowmode_delay`` to set on newly created threads in the channel. - This field is copied to the thread at creation time and does not live update. - default_auto_archive_duration: :class:`AutoArchiveDuration` - The default duration that the clients use (not the API) for newly created threads in the channel, - in minutes, to automatically archive the thread after recent activity - nsfw: :class:`bool` - To mark the channel as NSFW or not. - reason: Optional[:class:`str`] - The reason for creating this channel. Shows up on the audit log. - - Raises - ------- - Forbidden - You do not have the proper permissions to create this channel. - HTTPException - Creating the channel failed. - InvalidArgument - The permission overwrite information is not in proper form, - or the ``default_auto_archive_duration`` is not a valid member of :class:`AutoArchiveDuration` - - Returns - ------- - :class:`ForumChannel` - The channel that was just created - """ - data = await self._create_channel( - name, - overwrites, - ChannelType.forum_channel, - category, - topic=topic, - slowmode_delay=slowmode_delay, - default_thread_slowmode_delay=default_post_slowmode_delay, - default_auto_archive_duration=default_auto_archive_duration, - nsfw=nsfw, - position=position, - reason=reason - ) - channel = ForumChannel(state=self._state, guild=self, data=data) - - # temporarily add to the cache - self._channels[channel.id] = channel - return channel - - async def create_category( - self, - name: str, - *, - overwrites: Optional[Dict[Snowflake, PermissionOverwrite]] = None, - reason: Optional[str] = None, - position: Optional[int] = None - ) -> CategoryChannel: - """|coro| - - Same as :meth:`create_text_channel` except makes a :class:`CategoryChannel` instead. - - .. note:: - - The ``category`` parameter is not supported in this function since categories - cannot have categories. - - Raises - ------ - Forbidden - You do not have the proper permissions to create this channel. - HTTPException - Creating the channel failed. - InvalidArgument - The permission overwrite information is not in proper form. - - Returns - ------- - :class:`CategoryChannel` - The channel that was just created. - """ - data = await self._create_channel(name, overwrites, ChannelType.category, reason=reason, position=position) - channel = CategoryChannel(state=self._state, guild=self, data=data) - - # temporarily add to the cache - self._channels[channel.id] = channel - return channel - - create_category_channel = create_category - - async def leave(self): - """|coro| - - Leaves the guild. - - .. note:: - - You cannot leave the guild that you own, you must delete it instead - via :meth:`delete`. - - Raises - -------- - HTTPException - Leaving the guild failed. - """ - await self._state.http.leave_guild(self.id) - - async def delete(self): - """|coro| - - Deletes the guild. You must be the guild owner to delete the - guild. - - Raises - -------- - HTTPException - Deleting the guild failed. - Forbidden - You do not have permissions to delete the guild. - """ - - await self._state.http.delete_guild(self.id) - - async def edit( - self, - name: str = MISSING, - description: str = MISSING, - features: GuildFeatures = MISSING, - icon: Optional[bytes] = MISSING, - banner: Optional[bytes] = MISSING, - splash: Optional[bytes] = MISSING, - discovery_splash: Optional[bytes] = MISSING, - region: Optional[VoiceRegion] = MISSING, - afk_channel: Optional[VoiceChannel] = MISSING, - afk_timeout: Optional[int] = MISSING, - system_channel: Optional[TextChannel] = MISSING, - system_channel_flags: Optional[SystemChannelFlags] = MISSING, - rules_channel: Optional[TextChannel] = MISSING, - public_updates_channel: Optional[TextChannel] = MISSING, - preferred_locale: Optional[Union[str, Locale]] = MISSING, - verification_level: Optional[VerificationLevel] = MISSING, - default_notifications: Optional[NotificationLevel] = MISSING, - explicit_content_filter: Optional[ContentFilter] = MISSING, - vanity_code: Optional[str] = MISSING, - owner: Optional[Union[Member, User]] = MISSING, - *, - reason: Optional[str] = None, - ) -> None: - """|coro| - - Edits the guild. - - You must have the :attr:`~Permissions.manage_guild` permission - to edit the guild. - - .. versionchanged:: 1.4 - The `rules_channel` and `public_updates_channel` keyword-only parameters were added. - - Parameters - ---------- - name: :class:`str` - The new name of the guild. - description: :class:`str` - The new description of the guild. This is only available to guilds that - contain ``PUBLIC`` in :attr:`Guild.features`. - features: :class:`GuildFeatures` - Features to enable/disable will be merged in to the current features. - See the `discord api documentation `_ - for a list of currently mutable features and the required permissions. - icon: :class:`bytes` - A :term:`py:bytes-like object` representing the icon. Only PNG/JPEG is supported. - GIF is only available to guilds that contain ``ANIMATED_ICON`` in :attr:`Guild.features`. - Could be ``None`` to denote removal of the icon. - banner: :class:`bytes` - A :term:`py:bytes-like object` representing the banner. - Could be ``None`` to denote removal of the banner. - splash: :class:`bytes` - A :term:`py:bytes-like object` representing the invite splash. - Only PNG/JPEG supported. Could be ``None`` to denote removing the - splash. This is only available to guilds that contain ``INVITE_SPLASH`` - in :attr:`Guild.features`. - discovery_splash: :class:`bytes` - A :term:`py:bytes-like object` representing the discovery splash. - Only PNG/JPEG supported. Could be ``None`` to denote removing the splash. - This is only available to guilds that contain ``DISCOVERABLE`` in :attr:`Guild.features`. - region: :class:`VoiceRegion` - Deprecated: The new region for the guild's voice communication. - afk_channel: Optional[:class:`VoiceChannel`] - The new channel that is the AFK channel. Could be ``None`` for no AFK channel. - afk_timeout: :class:`int` - The number of seconds until someone is moved to the AFK channel. - owner: :class:`Member` - The new owner of the guild to transfer ownership to. Note that you must - be owner of the guild to do this. - verification_level: :class:`VerificationLevel` - The new verification level for the guild. - default_notifications: :class:`NotificationLevel` - The new default notification level for the guild. - explicit_content_filter: :class:`ContentFilter` - The new explicit content filter for the guild. - vanity_code: :class:`str` - The new vanity code for the guild. - system_channel: Optional[:class:`TextChannel`] - The new channel that is used for the system channel. Could be ``None`` for no system channel. - system_channel_flags: :class:`SystemChannelFlags` - The new system channel settings to use with the new system channel. - preferred_locale: :class:`str` - The new preferred locale for the guild. Used as the primary language in the guild. - If set, this must be an ISO 639 code, e.g. ``en-US`` or ``ja`` or ``zh-CN``. - rules_channel: Optional[:class:`TextChannel`] - The new channel that is used for rules. This is only available to - guilds that contain ``PUBLIC`` in :attr:`Guild.features`. Could be ``None`` for no rules - channel. - public_updates_channel: Optional[:class:`TextChannel`] - The new channel that is used for public updates from Discord. This is only available to - guilds that contain ``PUBLIC`` in :attr:`Guild.features`. Could be ``None`` for no - public updates channel. - reason: Optional[:class:`str`] - The reason for editing this guild. Shows up on the audit log. - - Raises - ------- - Forbidden - You do not have permissions to edit the guild. - HTTPException - Editing the guild failed. - InvalidArgument - The image format passed in to ``icon`` is invalid. It must be - PNG or JPG. This is also raised if you are not the owner of the - guild and request an ownership transfer. - """ - - http = self._state.http - - fields = {} - - if name is not MISSING: - fields['name'] = name - - if description is not MISSING: - fields['description'] = description - - if icon is not MISSING: - fields['icon'] = utils._bytes_to_base64_data(icon) - - if banner is not MISSING: - fields['banner'] = utils._bytes_to_base64_data(banner) - - if splash is not MISSING: - fields['splash'] = utils._bytes_to_base64_data(splash) - - if features is not MISSING: - current_features = GuildFeatures(self.features) - fields['features'] = current_features.merge(features).parsed() - - if discovery_splash is not MISSING: - fields['discovery_splash'] = utils._bytes_to_base64_data(discovery_splash) - - if region is not MISSING: - import warnings - warnings.warn('The region parameter is deprecated and will be removed in a future version.', DeprecationWarning) - if not isinstance(region, VoiceRegion): - raise InvalidArgument('region field must be of type VoiceRegion') - fields['region'] = region.value - - if afk_channel is not MISSING: - fields['afk_channel_id'] = afk_channel.id if afk_channel else None - - if afk_timeout is not MISSING: - fields['afk_timeout'] = afk_timeout - - if owner is not MISSING: - fields['owner_id'] = owner.id - - if verification_level is not MISSING: - if not isinstance(verification_level, VerificationLevel): - raise InvalidArgument('verification_level field must be of type VerificationLevel') - fields['verification_level'] = verification_level.value - - if default_notifications is not MISSING: - if not isinstance(default_notifications, NotificationLevel): - raise InvalidArgument('default_notifications field must be of type NotificationLevel') - fields['default_message_notifications'] = default_notifications.value - - if explicit_content_filter is not MISSING: - if not isinstance(explicit_content_filter, ContentFilter): - raise InvalidArgument('explicit_content_filter field must be of type ContentFilter') - fields['explicit_content_filter'] = explicit_content_filter.value - - if vanity_code is not MISSING: - fields['vanity_url_code'] = vanity_code - - if system_channel is not MISSING: - fields['system_channel_id'] = system_channel.id if system_channel else None - - if system_channel_flags is not MISSING: - if not isinstance(system_channel_flags, SystemChannelFlags): - raise InvalidArgument('system_channel_flags field must be of type SystemChannelFlags') - fields['system_channel_flags'] = system_channel_flags.value - - if preferred_locale is not MISSING: - fields['preferred_locale'] = str(preferred_locale) - - if rules_channel is not MISSING: - fields['rules_channel_id'] = rules_channel.id if rules_channel else None - - if public_updates_channel is not MISSING: - fields['public_updates_channel_id'] = public_updates_channel.id if public_updates_channel else None - - await http.edit_guild(self.id, reason=reason, **fields) - - async def fetch_channels(self) -> List[GuildChannel]: - """|coro| - - Retrieves all :class:`abc.GuildChannel` that the guild has. - - .. note:: - - This method is an API call. For general usage, consider :attr:`channels` instead. - - .. versionadded:: 1.2 - - Raises - ------- - InvalidData - An unknown channel type was received from Discord. - HTTPException - Retrieving the channels failed. - - Returns - ------- - List[:class:`abc.GuildChannel`] - All channels in the guild. - """ - data = await self._state.http.get_all_guild_channels(self.id) - - def convert(d): - factory, ch_type = _channel_factory(d['type']) - if factory is None: - raise InvalidData('Unknown channel type {type} for channel ID {id}.'.format_map(d)) - channel = factory(guild=self, state=self._state, data=d) - return channel - - return [convert(d) for d in data] - - async def fetch_active_threads(self) -> List[Union[ThreadChannel, ForumPost]]: - """|coro| - - Returns all active threads and forum posts in the guild. - This includes all public and private threads as long as the current user has permissions to access them. - Threads are ordered by their id, in descending order. - - .. note:: - - This method is an API call. - - .. versionadded:: 2.0 - - Returns - ------- - List[Union[:class:`ThreadChannel`, :class:`ForumPost`]] - The active threads and forum posts in the guild. - """ - state = self._state - data = await state.http.list_active_threads(self.id) - thread_members = data['thread_members'] - - def convert(d): - thread_member = thread_members.get(int(d['id'])) - if thread_member: - d['member'] = thread_member - return _thread_factory(guild=self, state=state, data=d) - - return [convert(d) for d in data['threads']] - - def fetch_members( - self, - *, - limit: int = 1000, - after: Optional[Union[Snowflake, datetime]] = None - ) -> MemberIterator: - """Retrieves an :class:`.AsyncIterator` that enables receiving the guild's members. In order to use this, - :meth:`Intents.members` must be enabled. - - .. note:: - - This method is an API call. For general usage, consider :attr:`members` instead. - - .. versionadded:: 1.3 - - All parameters are optional. - - Parameters - ---------- - limit: Optional[:class:`int`] - The number of members to retrieve. Defaults to 1000. - Pass ``None`` to fetch all members. Note that this is potentially slow. - after: Optional[Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`]] - Retrieve members after this date or object. - If a date is provided it must be a timezone-naive datetime representing UTC time. - - Raises - ------ - ClientException - The members intent is not enabled. - HTTPException - Getting the members failed. - - Yields - ------ - :class:`.Member` - The member with the member data parsed. - - Examples - -------- - - Usage :: - - async for member in guild.fetch_members(limit=150): - print(member.name) - - Flattening into a list :: - - members = await guild.fetch_members(limit=150).flatten() - # members is now a list of Member... - """ - - if not self._state._intents.members: - raise ClientException('Intents.members must be enabled to use this.') - - return MemberIterator(self, limit=limit, after=after) - - async def fetch_member(self, member_id: id) -> Member: - """|coro| - - Retrieves a :class:`Member` from a guild ID, and a member ID. - - .. note:: - - This method is an API call. If you have :attr:`Intents.members` and member cache enabled, consider :meth:`get_member` instead. - - Parameters - ----------- - member_id: :class:`int` - The member's ID to fetch from. - - Raises - ------- - Forbidden - You do not have access to the guild. - HTTPException - Fetching the member failed. - - Returns - -------- - :class:`Member` - The member from the member ID. - """ - data = await self._state.http.get_member(self.id, member_id) - return Member(data=data, state=self._state, guild=self) - - async def fetch_ban(self, user: Snowflake) -> BanEntry: - """|coro| - - Retrieves the :class:`BanEntry` for a user. - - You must have the :attr:`~Permissions.ban_members` permission - to get this information. - - Parameters - ----------- - user: :class:`abc.Snowflake` - The user to get ban information from. - - Raises - ------ - Forbidden - You do not have proper permissions to get the information. - NotFound - This user is not banned. - HTTPException - An error occurred while fetching the information. - - Returns - ------- - :class:`BanEntry` - The :class:`BanEntry` object for the specified user. - """ - data = await self._state.http.get_ban(user.id, self.id) # type: ignore - return BanEntry( - user=User(state=self._state, data=data['user']), - reason=data['reason'] - ) - - def bans( - self, - limit: Optional[int] = None, - before: Optional[Union['Snowflake', datetime]] = None, - after: Optional[Union['Snowflake', datetime]] = None - ) -> BanIterator: - """Retrieves an :class:`.AsyncIterator` that enables receiving the guild's bans. - - You must have the :attr:`~Permissions.ban_members` permission - to get this information. - - .. note:: - - This method is an API call. Use it careful. - - All parameters are optional. - - Parameters - ---------- - limit: Optional[:class:`int`] - The number of bans to retrieve. Defaults to all. - Note that this is potentially slow. - before: Optional[Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`]] - Retrieve members before this date or object. - If a date is provided it must be a timezone-naive datetime representing UTC time. - after: Optional[Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`]] - Retrieve members after this date or object. - If a date is provided it must be a timezone-naive datetime representing UTC time. - - Raises - ------ - Forbidden - You do not have proper permissions to get the information. - HTTPException - Getting the bans failed. - - Yields - ------ - :class:`.BanEntry` - The ban entry containing the user and an optional reason. - - Examples - -------- - - Usage :: - - async for ban_entry in guild.bans(limit=150): - print(ban_entry.user) - - Flattening into a list :: - - ban_entries = await guild.bans(limit=150).flatten() - # ban_entries is now a list of BanEntry... - """ - return BanIterator(self, limit=limit, before=before, after=after) - - @overload - async def prune_members( - self, - *, - days: int, - roles: Optional[List[Snowflake]], - reason: Optional[str] - ) -> int: - ... - - @overload - async def prune_members( - self, - *, - days: int, - compute_prune_count: Literal[True], - roles: Optional[List[Snowflake]], - reason: Optional[str] - ) -> int: - ... - - @overload - async def prune_members( - self, - *, - days: int, - compute_prune_count: Literal[False], - roles: Optional[List[Snowflake]], - reason: Optional[str] - ) -> None: - ... - - async def prune_members( - self, - *, - days: int, - compute_prune_count: bool = True, - roles: List[Snowflake] = None, - reason: Optional[str] = None - ) -> Optional[int]: - r"""|coro| - - Prunes the guild from its inactive members. - - The inactive members are denoted if they have not logged on in - ``days`` number of days, and they have no roles. - - You must have the :attr:`~Permissions.kick_members` permission - to use this. - - To check how many members you would prune without actually pruning, - see the :meth:`estimate_pruned_members` function. - - To prune members that have specific roles see the ``roles`` parameter. - - .. versionchanged:: 1.4 - The ``roles`` keyword-only parameter was added. - - Parameters - ----------- - days: :class:`int` - The number of days before counting as inactive. - reason: Optional[:class:`str`] - The reason for doing this action. Shows up on the audit log. - compute_prune_count: :class:`bool` - Whether to compute the prune count. This defaults to ``True`` - which makes it prone to timeouts in very large guilds. In order - to prevent timeouts, you must set this to ``False``. If this is - set to ``False``\, then this function will always return ``None``. - roles: Optional[List[:class:`abc.Snowflake`]] - A list of :class:`abc.Snowflake` that represent roles to include in the pruning process. If a member - has a role that is not specified, they'll be excluded. - - Raises - ------- - Forbidden - You do not have permissions to prune members. - HTTPException - An error occurred while pruning members. - InvalidArgument - An integer was not passed for ``days``. - - Returns - --------- - Optional[:class:`int`] - The number of members pruned. If ``compute_prune_count`` is ``False`` - then this returns ``None``. - """ - - if not isinstance(days, int): - raise InvalidArgument('Expected int for ``days``, received {0.__class__.__name__} instead.'.format(days)) - - if roles: - roles = [str(role.id) for role in roles] # type: ignore - - data = await self._state.http.prune_members( - self.id, - days, - compute_prune_count=compute_prune_count, - roles=roles, - reason=reason - ) - return data['pruned'] - - async def templates(self) -> List[Template]: - """|coro| - - Gets the list of templates from this guild. - - Requires :attr:`~.Permissions.manage_guild` permissions. - - .. versionadded:: 1.7 - - Raises - ------- - Forbidden - You don't have permissions to get the templates. - - Returns - -------- - List[:class:`Template`] - The templates for this guild. - """ - from .template import Template - data = await self._state.http.guild_templates(self.id) - return [Template(data=d, state=self._state) for d in data] - - async def webhooks(self) -> List[Webhook]: - """|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. - """ - - 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] - - async def estimate_pruned_members(self, *, days: int, roles: Optional[List[Snowflake]] = None) -> int: - """|coro| - - Similar to :meth:`prune_members` except instead of actually - pruning members, it returns how many members it would prune - from the guild had it been called. - - Parameters - ----------- - days: :class:`int` - The number of days before counting as inactive. - roles: Optional[List[:class:`abc.Snowflake`]] - A list of :class:`abc.Snowflake` that represent roles to include in the estimate. If a member - has a role that is not specified, they'll be excluded. - - .. versionadded:: 1.7 - - Raises - ------- - Forbidden - You do not have permissions to prune members. - HTTPException - An error occurred while fetching the prune members estimate. - InvalidArgument - An integer was not passed for ``days``. - - Returns - --------- - :class:`int` - The number of members estimated to be pruned. - """ - - if not isinstance(days, int): - raise InvalidArgument('Expected int for ``days``, received {0.__class__.__name__} instead.'.format(days)) - - if roles: - roles = [str(role.id) for role in roles] # type: ignore - - data = await self._state.http.estimate_pruned_members(self.id, days, roles) - return data['pruned'] - - async def invites(self) -> List[Invite]: - """|coro| - - Returns a list of all active instant invites from the guild. - - You must have the :attr:`~Permissions.manage_guild` permission to get - this information. - - Raises - ------- - Forbidden - You do not have proper permissions to get the information. - HTTPException - An error occurred while fetching the information. - - Returns - ------- - List[:class:`Invite`] - The list of invites that are currently active. - """ - - data = await self._state.http.invites_from(self.id) - result = [] - for invite in data: - channel = self.get_channel(int(invite['channel']['id'])) - invite['channel'] = channel - invite['guild'] = self - result.append(Invite(state=self._state, data=invite)) - - return result - - async def create_template(self, *, name: str, description: Optional[str] = None) -> Template: - """|coro| - - Creates a template for the guild. - - You must have the :attr:`~Permissions.manage_guild` permission to - do this. - - .. versionadded:: 1.7 - - Parameters - ----------- - name: :class:`str` - The name of the template. - description: Optional[:class:`str`] - The description of the template. - """ - from .template import Template - - payload = { - 'name': name - } - - if description: - payload['description'] = description - - data = await self._state.http.create_template(self.id, payload) - - return Template(state=self._state, data=data) - - async def create_integration(self, *, type: IntegrationType, id: int): - """|coro| - - Attaches an integration to the guild. - - You must have the :attr:`~Permissions.manage_guild` permission to - do this. - - .. versionadded:: 1.4 - - Parameters - ----------- - type: :class:`str` - The integration type (e.g. Twitch). - id: :class:`int` - The integration ID. - - Raises - ------- - Forbidden - You do not have permission to create the integration. - HTTPException - The account could not be found. - """ - await self._state.http.create_integration(self.id, type, id) - - async def integrations(self) -> List[Integration]: - """|coro| - - Returns a list of all integrations attached to the guild. - You must have the :attr:`~Permissions.manage_guild` permission to - do this. - .. versionadded:: 1.4 - Raises - ------- - Forbidden - You do not have permission to create the integration. - HTTPException - Fetching the integrations failed. - Returns - -------- - List[:class:`Integration`] - The list of integrations that are attached to the guild. - """ - data = await self._state.http.get_all_integrations(self.id) - - def convert(d): - factory, itype = _integration_factory(d['type']) - if factory is None: - raise InvalidData('Unknown integration type {type!r} for integration ID {id}'.format_map(d)) - return factory(guild=self, data=d) - - return [convert(d) for d in data] - - async def fetch_emojis(self) -> List[Emoji]: - r"""|coro| - - Retrieves all custom :class:`Emoji`\s from the guild. - - .. note:: - - This method is an API call. For general usage, consider :attr:`emojis` instead. - - Raises - --------- - HTTPException - An error occurred fetching the emojis. - - Returns - -------- - List[:class:`Emoji`] - The retrieved emojis. - """ - data = await self._state.http.get_all_custom_emojis(self.id) - return [Emoji(guild=self, state=self._state, data=d) for d in data] - - async def fetch_emoji(self, emoji_id: int) -> Emoji: - """|coro| - - Retrieves a custom :class:`Emoji` from the guild. - - .. note:: - - This method is an API call. - For general usage, consider iterating over :attr:`emojis` instead. - - Parameters - ------------- - emoji_id: :class:`int` - The emoji's ID. - - Raises - --------- - NotFound - The emoji requested could not be found. - HTTPException - An error occurred fetching the emoji. - - Returns - -------- - :class:`Emoji` - The retrieved emoji. - """ - data = await self._state.http.get_custom_emoji(self.id, emoji_id) - return Emoji(guild=self, state=self._state, data=data) - - async def create_custom_emoji( - self, - *, - name: str, - image: bytes, - roles: Optional[List[Snowflake]] = None, - reason: Optional[str] = None - ) -> Emoji: - r"""|coro| - - Creates a custom :class:`Emoji` for the guild. - - There is currently a limit of 50 static and animated emojis respectively per guild, - unless the guild has the ``MORE_EMOJI`` feature which extends the limit to 200. - - You must have the :attr:`~Permissions.manage_emojis` permission to - do this. - - Parameters - ----------- - name: :class:`str` - The emoji name. Must be at least 2 characters. - image: :class:`bytes` - The :term:`py:bytes-like object` representing the image data to use. - Only JPG, PNG and GIF images are supported. - roles: Optional[List[:class:`Role`]] - A :class:`list` of :class:`Role`\s that can use this emoji. Leave empty to make it available to everyone. - reason: Optional[:class:`str`] - The reason for creating this emoji. Shows up on the audit log. - - Raises - ------- - Forbidden - You are not allowed to create emojis. - HTTPException - An error occurred creating an emoji. - - Returns - -------- - :class:`Emoji` - The created emoji. - """ - - img = utils._bytes_to_base64_data(image) - if roles: - roles = [role.id for role in roles] # type: ignore - data = await self._state.http.create_custom_emoji(self.id, name, img, roles=roles, reason=reason) - return self._state.store_emoji(self, data) - - async def fetch_roles(self) -> List[Role]: - """|coro| - - Retrieves all :class:`Role` that the guild has. - - .. note:: - - This method is an API call. For general usage, consider :attr:`roles` instead. - - .. versionadded:: 1.3 - - Raises - ------- - HTTPException - Retrieving the roles failed. - - Returns - ------- - List[:class:`Role`] - All roles in the guild. - """ - data = await self._state.http.get_roles(self.id) - return [Role(guild=self, state=self._state, data=d) for d in data] - - @overload - async def create_role( - self, - *, - name: str = MISSING, - permissions: Permissions = MISSING, - color: Union[Colour, int] = MISSING, - colour: Union[Colour, int] = MISSING, - hoist: bool = False, - mentionable: bool = False, - reason: Optional[str] = None - ) -> Role: - ... - - @overload - async def create_role( - self, - *, - icon: bytes, - name: str = MISSING, - permissions: Permissions = MISSING, - color: Union[Colour, int] = MISSING, - colour: Union[Colour, int] = MISSING, - hoist: bool = False, - mentionable: bool = False, - reason: Optional[str] = None - ) -> Role: - ... - - @overload - async def create_role( - self, - *, - unicode_emoji: str, - name: str = MISSING, - permissions: Permissions = MISSING, - color: Union[Colour, int] = MISSING, - colour: Union[Colour, int] = MISSING, - hoist: bool = False, - mentionable: bool = False, - reason: Optional[str] = None - ) -> Role: - ... - - async def create_role( - self, - *, - name: str = MISSING, - permissions: Permissions = MISSING, - color: Union[Colour, int] = MISSING, - colour: Union[Colour, int] = MISSING, - hoist: bool = False, - mentionable: bool = False, - icon: Optional[bytes] = None, - unicode_emoji: Optional[str] = None, - reason: Optional[str] = None - ) -> Role: - """|coro| - - Creates a :class:`Role` for the guild. - - All fields are optional. - - You must have the :attr:`~Permissions.manage_roles` permission to - do this. - - .. versionchanged:: 1.6 - Can now pass ``int`` to ``colour`` keyword-only parameter. - - .. versionadded:: 2.0 - Added the ``icon`` and ``unicode_emoji`` keyword-only parameters. - - .. note:: - The ``icon`` and ``unicode_emoji`` can't be used together. - Both of them can only be used when ``ROLE_ICONS`` is in the guild :meth:`~Guild.features`. - - Parameters - ----------- - name: :class:`str` - The role name. Defaults to 'new role'. - permissions: :class:`Permissions` - The permissions to have. Defaults to no permissions. - color: Union[:class:`Colour`, :class:`int`] - The colour for the role. Defaults to :meth:`Colour.default`. - colour: Union[:class:`Colour`, :class:`int`] - The colour for the role. Defaults to :meth:`Colour.default`. - This is aliased to ``color`` as well. - hoist: :class:`bool` - Indicates if the role should be shown separately in the member list. - Defaults to ``False``. - mentionable: :class:`bool` - Indicates if the role should be mentionable by others. - Defaults to ``False``. - icon: Optional[:class:`bytes`] - The :term:`py:bytes-like object` representing the image data to use as the role :attr:`~Role.icon`. - unicode_emoji: Optional[:class:`str`] - The unicode emoji to use as the role :attr:`~Role.unicode_emoji`. - reason: Optional[:class:`str`] - The reason for creating this role. Shows up on the audit log. - - Raises - ------- - Forbidden - You do not have permissions to create the role. - HTTPException - Creating the role failed. - InvalidArgument - Both ``icon`` and ``unicode_emoji`` were passed. - - Returns - -------- - :class:`Role` - The newly created role. - """ - - fields = {} - - if name is not MISSING: - fields['name'] = name - - if permissions is not MISSING: - fields['permissions'] = permissions.value - - color = color if color is not MISSING else colour - - if color is not MISSING: - if isinstance(color, Colour): - color = color.value - fields['color'] = color - - if hoist is not MISSING: - fields['hoist'] = hoist - - if mentionable is not MISSING: - fields['mentionable'] = mentionable - - if icon is not None: - if unicode_emoji is not None: - raise InvalidArgument('icon and unicode_emoji cannot be used together.') - fields['icon'] = utils._bytes_to_base64_data(icon) - - elif unicode_emoji is not None: - fields['unicode_emoji'] = str(unicode_emoji) - - data = await self._state.http.create_role(self.id, reason=reason, fields=fields) - role = Role(guild=self, data=data, state=self._state) - self._add_role(role) - # TODO: add to cache - return role - - async def edit_role_positions(self, positions: Dict[Role, int], *, reason: Optional[str] = None) -> List[Role]: - """|coro| - - Bulk edits a list of :class:`Role` in the guild. - - You must have the :attr:`~Permissions.manage_roles` permission to - do this. - - .. versionadded:: 1.4 - - Example: - - .. code-block:: python3 - - positions = { - bots_role: 1, # penultimate role - tester_role: 2, - admin_role: 6 - } - - await guild.edit_role_positions(positions=positions) - - Parameters - ----------- - positions - A :class:`dict` of :class:`Role` to :class:`int` to change the positions - of each given role. - reason: Optional[:class:`str`] - The reason for editing the role positions. Shows up on the audit log. - - Raises - ------- - Forbidden - You do not have permissions to move the roles. - HTTPException - Moving the roles failed. - InvalidArgument - An invalid keyword argument was given. - - Returns - -------- - List[:class:`Role`] - A list of all the roles in the guild. - """ - if not isinstance(positions, dict): - raise InvalidArgument('positions parameter expects a dict.') - - role_positions = [] - for role, position in positions.items(): - - payload = { - 'id': role.id, - 'position': position - } - - role_positions.append(payload) - - data = await self._state.http.move_role_position(self.id, role_positions, reason=reason) - roles = [] - for d in data: - role = Role(guild=self, data=d, state=self._state) - roles.append(role) - self._roles[role.id] = role - - return roles - - async def kick(self, user: Snowflake, *, reason: Optional[str] = None): - """|coro| - - Kicks a user from the guild. - - The user must meet the :class:`abc.Snowflake` abc. - - You must have the :attr:`~Permissions.kick_members` permission to - do this. - - Parameters - ----------- - user: :class:`abc.Snowflake` - The user to kick from their guild. - reason: Optional[:class:`str`] - The reason the user got kicked. - - Raises - ------- - Forbidden - You do not have the proper permissions to kick. - HTTPException - Kicking failed. - """ - await self._state.http.kick(user.id, self.id, reason=reason) # type: ignore - - async def ban( - self, - user: Snowflake, - *, - delete_message_days: Optional[int] = None, - delete_message_seconds: Optional[int] = 0, - reason: Optional[str] = None - ) -> None: - """|coro| - - Bans a user from the guild. - - The user must meet the :class:`abc.Snowflake` abc. - - You must have the :attr:`~Permissions.ban_members` permission to - do this. - - Parameters - ----------- - user: :class:`abc.Snowflake` - The user to ban from their guild. - delete_message_days: :class:`int` - The number of days worth of messages to delete from the user - in the guild. The minimum is 0 and the maximum is 7. - - .. deprecated:: 2.0 - delete_message_seconds: :class:`int` - The number of days worth of messages to delete from the user - in the guild. The minimum is 0 and the maximum is 604800 (7 days). - - .. versionadded:: 2.0 - reason: Optional[:class:`str`] - The reason the user got banned. - - Raises - ------- - Forbidden - You do not have the proper permissions to ban. - HTTPException - Banning failed. - """ - if delete_message_days is not None: - import warnings - warnings.warn( - 'delete_message_days is deprecated, use delete_message_seconds instead.', - DeprecationWarning, - stacklevel=2 - ) - delete_message_seconds = delete_message_days * 86400 - await self._state.http.ban(user.id, self.id, delete_message_seconds, reason=reason) # type: ignore - - async def unban(self, user: Snowflake, *, reason: Optional[str] = None): - """|coro| - - Unbans a user from the guild. - - The user must meet the :class:`abc.Snowflake` abc. - - You must have the :attr:`~Permissions.ban_members` permission to - do this. - - Parameters - ----------- - user: :class:`abc.Snowflake` - The user to unban. - reason: Optional[:class:`str`] - The reason for doing this action. Shows up on the audit log. - - Raises - ------- - Forbidden - You do not have the proper permissions to unban. - HTTPException - Unbanning failed. - """ - await self._state.http.unban(user.id, self.id, reason=reason) # type: ignore - - async def vanity_invite(self) -> Invite: - """|coro| - - Returns the guild's special vanity invite. - - The guild must have ``VANITY_URL`` in :attr:`~Guild.features`. - - You must have the :attr:`~Permissions.manage_guild` permission to use - this as well. - - Raises - ------- - Forbidden - You do not have the proper permissions to get this. - HTTPException - Retrieving the vanity invite failed. - - Returns - -------- - :class:`Invite` - The special vanity invite. - """ - - # we start with { code: abc } - payload = await self._state.http.get_vanity_code(self.id) - - # get the vanity URL channel since default channels aren't - # reliable or a thing anymore - data = await self._state.http.get_invite(payload['code']) - - payload['guild'] = self - payload['channel'] = self.get_channel(int(data['channel']['id'])) - payload['revoked'] = False - payload['temporary'] = False - payload['max_uses'] = 0 - payload['max_age'] = 0 - return Invite(state=self._state, data=payload) - - def audit_logs( - self, - *, - limit: int = 100, - before: Optional[Union[Snowflake, datetime]] = None, - after: Optional[Union[Snowflake, datetime]] = None, - oldest_first: Optional[bool] = None, - user: Optional[Snowflake] = None, - action: Optional[AuditLogAction] = None - ): - """Returns an :class:`AsyncIterator` that enables receiving the guild's audit logs. - - You must have the :attr:`~Permissions.view_audit_log` permission to use this. - - Examples - ---------- - - Getting the first 100 entries: :: - - async for entry in guild.audit_logs(limit=100): - print('{0.user} did {0.action} to {0.target}'.format(entry)) - - Getting entries for a specific action: :: - - async for entry in guild.audit_logs(action=discord.AuditLogAction.ban): - print('{0.user} banned {0.target}'.format(entry)) - - Getting entries made by a specific user: :: - - entries = await guild.audit_logs(limit=None, user=guild.me).flatten() - await channel.send('I made {} moderation actions.'.format(len(entries))) - - Parameters - ----------- - limit: Optional[:class:`int`] - The number of entries to retrieve. If ``None`` retrieve all entries. - before: Union[:class:`abc.Snowflake`, :class:`datetime.datetime`] - Retrieve entries before this date or entry. - If a date is provided it must be a timezone-naive datetime representing UTC time. - after: Union[:class:`abc.Snowflake`, :class:`datetime.datetime`] - Retrieve entries after this date or entry. - If a date is provided it must be a timezone-naive datetime representing UTC time. - oldest_first: :class:`bool` - If set to ``True``, return entries in oldest->newest order. Defaults to ``True`` if - ``after`` is specified, otherwise ``False``. - user: :class:`abc.Snowflake` - The moderator to filter entries from. - action: :class:`AuditLogAction` - The action to filter with. - - Raises - ------- - Forbidden - You are not allowed to fetch audit logs - HTTPException - An error occurred while fetching the audit logs. - - Yields - -------- - :class:`AuditLogEntry` - The audit log entry. - """ - if user: - user = user.id # type: ignore - - if action: - action = action.value - - return AuditLogIterator( - self, - before=before, - after=after, - limit=limit, - oldest_first=oldest_first, - user_id=user, - action_type=action - ) - - async def widget(self) -> Widget: - """|coro| - - Returns the widget of the guild. - - .. note:: - - The guild must have the widget enabled to get this information. - - Raises - ------- - Forbidden - The widget for this guild is disabled. - HTTPException - Retrieving the widget failed. - - Returns - -------- - :class:`Widget` - The guild's widget. - """ - data = await self._state.http.get_widget(self.id) - - return Widget(state=self._state, data=data) - - async def chunk(self, *, cache: bool = True): - """|coro| - - Requests all members that belong to this guild. In order to use this, - :meth:`Intents.members` must be enabled. - - This is a websocket operation and can be slow. - - .. versionadded:: 1.5 - - Parameters - ----------- - cache: :class:`bool` - Whether to cache the members as well. - - Raises - ------- - ClientException - The members intent is not enabled. - """ - - if not self._state._intents.members: - raise ClientException('Intents.members must be enabled to use this.') - - if not self._state.is_guild_evicted(self): - return await self._state.chunk_guild(self, cache=cache) - - async def query_members( - self, - query: Optional[str] = None, - *, - limit: int = 5, - user_ids: Optional[List[int]] = None, - presences: bool = False, - cache: bool = True - ) -> List[Member]: - """|coro| - - Request members that belong to this guild whose username starts with - the query given. - - This is a websocket operation and can be slow. - - .. versionadded:: 1.3 - - Parameters - ----------- - query: Optional[:class:`str`] - The string that the username's start with. - limit: :class:`int` - The maximum number of members to send back. This must be - a number between 5 and 100. - presences: :class:`bool` - Whether to request for presences to be provided. This defaults - to ``False``. - - .. versionadded:: 1.6 - - cache: :class:`bool` - Whether to cache the members internally. This makes operations - such as :meth:`get_member` work for those that matched. - user_ids: Optional[List[:class:`int`]] - List of user IDs to search for. If the user ID is not in the guild then it won't be returned. - - .. versionadded:: 1.4 - - - Raises - ------- - asyncio.TimeoutError - The query timed out waiting for the members. - ValueError - Invalid parameters were passed to the function - ClientException - The presences intent is not enabled. - - Returns - -------- - List[:class:`Member`] - The list of members that have matched the query. - """ - - if presences and not self._state._intents.presences: - raise ClientException('Intents.presences must be enabled to use this.') - - if query is None: - if query == '': - raise ValueError('Cannot pass empty query string.') - - if user_ids is None: - raise ValueError('Must pass either query or user_ids') - - if user_ids is not None and query is not None: - raise ValueError('Cannot pass both query and user_ids') - - if user_ids is not None and not user_ids: - raise ValueError('user_ids must contain at least 1 value') - - limit = min(100, limit or 5) - return await self._state.query_members( - self, - query=query, - limit=limit, - user_ids=user_ids, - presences=presences, - cache=cache - ) - - async def change_voice_state( - self, - *, - channel: Optional[Union[VoiceChannel, StageChannel]], - self_mute: bool = False, - self_deaf: bool = False - ): - """|coro| - - Changes client's voice state in the guild. - - .. versionadded:: 1.4 - - Parameters - ----------- - channel: Optional[:class:`VoiceChannel`] - Channel the client wants to join. Use ``None`` to disconnect. - self_mute: :class:`bool` - Indicates if the client should be self-muted. - self_deaf: :class:`bool` - Indicates if the client should be self-deafened. - """ - ws = self._state._get_websocket(self.id) - channel_id = channel.id if channel else None - await ws.voice_state(self.id, channel_id, self_mute, self_deaf) - - async def create_sticker( - self, - name: str, - file: Union[UploadFile, PathLike[str], PathLike[bytes]], - tags: Union[str, List[str]], - description: Optional[str] = None, - *, - reason: Optional[str] = None - ) -> GuildSticker: - """|coro| - - Create a new sticker for the guild. - - Requires the ``MANAGE_EMOJIS_AND_STICKERS`` permission. - - - Parameters - ---------- - name: :class:`str` - The name of the sticker (2-30 characters). - tags: Union[:class:`str`, List[:class:`str`]] - Autocomplete/suggestion tags for the sticker separated by ``,`` or in a list. (max 200 characters). - description: Optional[:class:`str`] - The description of the sticker (None or 2-100 characters). - file: Union[:class:`UploadFile`, :class:`str`] - The sticker file to upload or the path to it, must be a PNG, APNG, GIF or Lottie JSON file, max 500 KB - reason: Optional[:class:`str`] - The reason for creating the sticker., shows up in the audit-log. - - Raises - ------ - discord.Forbidden: - You don't have the permissions to upload stickers in this guild. - discord.HTTPException: - Creating the sticker failed. - ValueError - Any of name, description or tags is too short/long. - - Return - ------ - :class:`GuildSticker` - The new GuildSticker created on success. - """ - if 2 > len(name) > 30: - raise ValueError(f'The name must be between 2 and 30 characters in length; got {len(name)}.') - if not isinstance(file, UploadFile): - file = UploadFile(file) - if description is not None and 2 > len(description) > 100: - raise ValueError(f'The description must be between 2 and 100 characters in length; got {len(description)}.') - if isinstance(tags, list): - tags = ','.join(tags) - if len(tags) > 200: - raise ValueError(f'The tags could be max. 200 characters in length; {len(tags)}.') - try: - data = await self._state.http.create_guild_sticker( - guild_id=self.id, - name=name, - description=description, - tags=tags, - file=file, - reason=reason - ) - finally: - file.close() - return self._state.store_sticker(data) - - async def fetch_events( - self, - with_user_count: bool = True - ) -> Optional[List[GuildScheduledEvent]]: - """|coro| - - Retrieves a :class:`list` of scheduled events the guild has. - - .. note:: - - This method is an API call. - For general usage, consider iterating over :attr:`events` instead. - - Parameters - ---------- - with_user_count: :class:`bool` - Whether to include the number of interested users the event has, default ``True``. - - Returns - ------- - Optional[List[:class:`GuildScheduledEvent`]] - A list of scheduled events the guild has. - """ - data = self._state.http.get_guild_events(guild_id=self.id, with_user_count=with_user_count) - [self._add_event(GuildScheduledEvent(state=self._state, guild=self, data=d)) for d in data] - return self.events - - async def fetch_event( - self, - id: int, - with_user_count: bool = True - ) -> Optional[GuildScheduledEvent]: - """|coro| - - Fetches the :class:`GuildScheduledEvent` with the given id. - - Parameters - ---------- - id: :class:`int` - The id of the event to fetch. - with_user_count: :class:`bool` - Whether to include the number of interested users the event has, default ``True``. - - Returns - ------- - Optional[:class:`GuildScheduledEvent`] - The event on success. - """ - data = await self._state.http.get_guild_event(guild_id=self.id, event_id=id, with_user_count=with_user_count) - if data: - event = GuildScheduledEvent(state=self._state, guild=self, data=data) - self._add_event(event) - return event - - async def create_scheduled_event( - self, - name: str, - entity_type: EventEntityType, - start_time: datetime, - end_time: Optional[datetime] = None, - channel: Optional[Union[StageChannel, VoiceChannel]] = None, - description: Optional[str] = None, - location: Optional[str] = None, - cover_image: Optional[bytes] = None, - *, - reason: Optional[str] = None - ) -> GuildScheduledEvent: - """|coro| - - Schedules a new Event in this guild. Requires ``MANAGE_EVENTS`` at least in the :attr:`channel` - or in the entire guild if :attr:`~GuildScheduledEvent.type` is :attr:`~EventType.external`. - - Parameters - ---------- - name: :class:`str` - The name of the scheduled event. 1-100 characters long. - entity_type: :class:`EventEntityType` - The entity_type of the scheduled event. - - .. important:: - :attr:`end_time` and :attr:`location` must be provided if entity_type is :class:`~EventEntityType.external`, otherwise :attr:`channel` - - start_time: :class:`datetime.datetime` - The time when the event will start. Must be a valid date in the future. - end_time: Optional[:class:`datetime.datetime`] - The time when the event will end. Must be a valid date in the future. - - .. important:: - If :attr:`entity_type` is :class:`~EventEntityType.external` this must be provided. - channel: Optional[Union[:class:`StageChannel`, :class:`VoiceChannel`]] - The channel in which the event takes place. - Must be provided if :attr:`entity_type` is :class:`~EventEntityType.stage` or :class:`~EventEntityType.voice`. - description: Optional[:class:`str`] - The description of the scheduled event. 1-1000 characters long. - location: Optional[:class:`str`] - The location where the event will take place. 1-100 characters long. - - .. important:: - This must be provided if :attr:`~GuildScheduledEvent.entity_type` is :attr:`~EventEntityType.external` - - cover_image: Optional[:class:`bytes`] - The cover image of the scheduled event. - reason: Optional[:class:`str`] - The reason for scheduling the event, shows up in the audit-log. - - Returns - ------- - :class:`~discord.GuildScheduledEvent` - The scheduled event on success. - - Raises - ------ - TypeError: - Any parameter is of wrong type. - errors.InvalidArgument: - entity_type is :attr:`~EventEntityType.stage` or :attr:`~EventEntityType.voice` but ``channel`` is not provided - or :attr:`~EventEntityType.external` but no ``location`` and/or ``end_time`` provided. - ValueError: - The value of any parameter is invalid. (e.g. to long/short) - errors.Forbidden: - You don't have permissions to schedule the event. - discord.HTTPException: - Scheduling the event failed. - """ - - fields: Dict[str, Any] = {} - - if not isinstance(entity_type, EventEntityType): - entity_type = try_enum(EventEntityType, entity_type) - if not isinstance(entity_type, EventEntityType): - raise ValueError('entity_type must be a valid EventEntityType.') - - if 1 > len(name) > 100: - raise ValueError(f'The length of the name must be between 1 and 100 characters long; got {len(name)}.') - fields['name'] = str(name) - - if int(entity_type) == 3 and not location: - raise InvalidArgument('location must be provided if type is EventEntityType.external') - elif int(entity_type) != 3 and not channel: - raise InvalidArgument('channel must be provided if type is EventEntityType.stage or EventEntityType.voice.') - - fields['entity_type'] = int(entity_type) - - if channel is not None and not entity_type.external: - if not isinstance(channel, (VoiceChannel, StageChannel)): - raise TypeError( - f'The channel must be a StageChannel or VoiceChannel object, not {channel.__class__.__name__}.' - ) - if int(entity_type) not in (1, 2): - entity_type = {StageChannel: EventEntityType.stage, VoiceChannel: EventEntityType.voice}.get(type(channel)) - fields['entity_type'] = entity_type.value - fields['channel_id'] = str(channel.id) - fields['entity_metadata'] = None - - if description is not None: - if 1 > len(description) > 1000: - raise ValueError( - f'The length of the description must be between 1 and 1000 characters long; got {len(description)}.' - ) - fields['description'] = description - - if location is not None: - if 1 > len(location) > 100: - raise ValueError( - f'The length of the location must be between 1 and 100 characters long; got {len(location)}.' - ) - if not entity_type.external: - entity_type = EventEntityType.external - fields['entity_type'] = entity_type.value - fields['channel_id'] = None - fields['entity_metadata'] = {'location': location} - - if entity_type.external and not end_time: - raise ValueError('end_time is required for external events.') - - if not isinstance(start_time, datetime): - raise TypeError(f'The start_time must be a datetime.datetime object, not {start_time.__class__.__name__}.') - elif start_time < datetime.utcnow(): - raise ValueError(f'The start_time could not be in the past.') - - fields['scheduled_start_time'] = start_time.isoformat() - - if end_time: - if not isinstance(end_time, datetime): - raise TypeError(f'The end_time must be a datetime.datetime object, not {end_time.__class__.__name__}.') - elif end_time < datetime.utcnow(): - raise ValueError(f'The end_time could not be in the past.') - - fields['scheduled_end_time'] = end_time.isoformat() - - fields['privacy_level'] = 2 - - if cover_image: - if not isinstance(cover_image, bytes): - raise ValueError(f'cover_image must be of type bytes, not {cover_image.__class__.__name__}') - as_base64 = utils._bytes_to_base64_data(cover_image) - fields['image'] = as_base64 - - data = await self._state.http.create_guild_event(guild_id=self.id, fields=fields, reason=reason) - event = GuildScheduledEvent(state=self._state, guild=self, data=data) - self._add_event(event) - return event - - async def _register_application_command(self, command): - client = self._state._get_client() - try: - client._guild_specific_application_commands[self.id] - except KeyError: - client._guild_specific_application_commands[self.id] = { - 'chat_input': {}, - 'message': {}, - 'user': {} - } - client._guild_specific_application_commands[self.id][command.type.name][command.name] = command - data = command.to_dict() - command_data = await self._state.http.create_application_command(self._state._get_client().app.id, data=data, - guild_id=self.id - ) - command._fill_data(command_data) - client._application_commands[command.id] = self._application_commands[command.id] = command - return command - - async def add_slash_command( - self, - name: str, - name_localizations: Optional[Localizations] = Localizations(), - description: str = 'No description', - description_localizations: Optional[Localizations] = Localizations(), - is_nsfw: bool = False, - default_required_permissions: Optional['Permissions'] = None, - options: Optional[List[Union['SubCommandGroup', 'SubCommand', 'SlashCommandOption']]] = [], - connector: Optional[Dict[str, str]] = {}, - func: Awaitable = default_callback, - cog: Optional[Cog] = None - ) -> SlashCommand: - command = SlashCommand( - name=name, - name_localizations=name_localizations, - description=description, - description_localizations=description_localizations, - is_nsfw=is_nsfw, - default_member_permissions=default_required_permissions, - options=options, - connector=connector, - func=func, - guild_id=self.id, - state=self._state, - cog=cog - ) - return await self._register_application_command(command) - - async def add_message_command( - self, - name: str, - name_localizations: Optional[Localizations] = Localizations(), - description: str = 'No description', - description_localizations: Optional[Localizations] = Localizations(), - is_nsfw: bool = False, - default_required_permissions: Optional[Permissions] = None, - func: Awaitable = default_callback, - cog: Optional[Cog] = None - ) -> MessageCommand: - command = MessageCommand( - name=name, - name_localizations=name_localizations, - description=description, - description_localizations=description_localizations, - is_nsfw=is_nsfw, - default_member_permissions=default_required_permissions, - func=func, - guild_id=self.id, - state=self._state, - cog=cog - ) - return await self._register_application_command(command) - - async def add_user_command( - self, - name: str, - name_localizations: Optional[Localizations] = Localizations(), - description: str = 'No description', - description_localizations: Optional[Localizations] = Localizations(), - is_nsfw: bool = False, - default_required_permissions: Optional[Permissions] = None, - func: Awaitable = default_callback, - cog: Optional[Cog] = None - ) -> UserCommand: - command = UserCommand( - name=name, - name_localizations=name_localizations, - description=description, - description_localizations=description_localizations, - is_nsfw=is_nsfw, - default_member_permissions=default_required_permissions, - func=func, - guild_id=self.id, - state=self._state, - cog=cog - ) - return await self._register_application_command(command) - - async def welcome_screen(self) -> Optional[WelcomeScreen]: - """|coro| - - Fetches the welcome screen from the guild if any. - - .. versionadded:: 2.0 - - Returns - -------- - Optional[:class:`~discord.WelcomeScreen`] - The welcome screen of the guild if any. - """ - data = await self._state.http.get_welcome_screen(self.id) - if data: - return WelcomeScreen(state=self._state, guild=self, data=data) - - async def onboarding(self) -> Optional[Onboarding]: - """|coro| - - Fetches the - `onboarding `_ - configuration for the guild. - - .. versionadded:: 2.0 - - Returns - -------- - :class:`Onboarding` - The onboarding configuration fetched. - """ - data = await self._state.http.get_guild_onboarding(self.id) - return Onboarding(data=data, state=self._state, guild=self) - - async def edit_onboarding( - self, - *, - prompts: List[PartialOnboardingPrompt] = MISSING, - default_channels: List[Snowflake] = MISSING, - enabled: bool = MISSING, - mode: OnboardingMode = MISSING, - reason: Optional[str] = None - ) -> Onboarding: - """|coro| - - Edits the onboarding configuration for the guild. - - .. versionadded:: 2.0 - - Parameters - ---------- - prompts: List[:class:`PartialOnboardingPrompt`] - The prompts that will be shown to new members - (if :attr:`~PartialOnboardingPrompt.is_onboarding` is ``True``, otherwise only in customise community). - default_channels: List[:class:`.Snowflake`] - The channels that will be added to new members' channel list by default. - enabled: :class:`bool` - Whether the onboarding configuration is enabled. - mode: :class:`OnboardingMode` - The mode that will be used for the onboarding configuration. - reason: Optional[:class:`str`] - The reason for editing the onboarding configuration. Shows up in the audit log. - - Raises - ------- - Forbidden - You do not have permissions to edit the onboarding configuration. - HTTPException - Editing the onboarding configuration failed. - - Returns - -------- - :class:`Onboarding` - The new onboarding configuration. - """ - data = await self._state.http.edit_guild_onboarding( - self.id, - prompts=[p._to_dict() for p in prompts] if prompts is not MISSING else None, - default_channel_ids=[c.id for c in default_channels] if default_channels is not MISSING else None, - enabled=enabled if enabled is not MISSING else None, - mode=mode.value if mode is not MISSING else None, - reason=reason - ) - - async def automod_rules(self) -> List[AutoModRule]: - """|coro| - - Fetches the Auto Moderation rules for this guild - - .. warning:: - This is an API-call, use it carefully. - - Returns - -------- - List[:class:`~discord.AutoModRule`] - A list of AutoMod rules the guild has - """ - data = await self._state.http.get_automod_rules(guild_id=self.id) - for rule in data: - self._add_automod_rule(AutoModRule(state=self._state, guild=self, **rule)) # TODO: Advanced caching - return self.cached_automod_rules - - async def fetch_automod_rule(self, rule_id: int) -> AutoModRule: - """|coro| - - Fetches a Auto Moderation rule for this guild by their ID - - Raises - ------- - Forbidden - You do not have permissions to fetch the rule. - HTTPException - Fetching the rule failed. - - Returns - -------- - :class:`~discord.AutoModRule` - The AutoMod rule - """ - data = await self._state.http.get_automod_rule(self.id, rule_id) - rule = AutoModRule(state=self._state, guild=self, **data) - self._add_automod_rule(rule) - return rule - - async def create_automod_rule( - self, - name: str, - event_type: AutoModEventType, - trigger_type: AutoModTriggerType, - trigger_metadata: AutoModTriggerMetadata, - actions: List[AutoModAction], - enabled: bool = True, - exempt_roles: List[Snowflake] = [], - exempt_channels: List[Snowflake] = [], - *, - reason: Optional[str] = None - ) -> AutoModRule: - """|coro| - - Creates a new AutoMod rule for this guild - - Parameters - ----------- - name: :class:`str` - The name, the rule should have. Only valid if it's not a preset rule. - event_type: :class:`~discord.AutoModEventType` - Indicates in what event context a rule should be checked. - trigger_type: :class:`~discord.AutoModTriggerType` - Characterizes the type of content which can trigger the rule. - trigger_metadata: :class:`~discord.AutoModTriggerMetadata` - Additional data used to determine whether a rule should be triggered. - Different fields are relevant based on the value of :attr:`~AutoModRule.trigger_type`. - actions: List[:class:`~discord.AutoModAction`] - The actions which will execute when the rule is triggered. - enabled: :class:`bool` - Whether the rule is enabled, default :obj:`True`. - exempt_roles: List[:class:`.Snowflake`] - Up to 20 :class:`~discord.Role`'s, that should not be affected by the rule. - exempt_channels: List[:class:`.Snowflake`] - Up to 50 :class:`~discord.TextChannel`/:class:`~discord.VoiceChannel`'s, that should not be affected by the rule. - reason: Optional[:class:`str`] - The reason for creating the rule. Shows up in the audit log. - - Raises - ------ - :exc:`discord.Forbidden` - The bot is missing permissions to create AutoMod rules - :exc:`~discord.HTTPException` - Creating the rule failed - - Returns - -------- - :class:`~discord.AutoModRule` - The AutoMod rule created - """ - data = { - 'name': name, - 'event_type': event_type if isinstance(event_type, int) else event_type.value, - 'trigger_type': trigger_type if isinstance(trigger_type, int) else trigger_type.value, - 'trigger_metadata': trigger_metadata.to_dict(), - 'actions': [a.to_dict() for a in actions], - 'enabled': enabled, - 'exempt_roles': [str(r.id) for r in exempt_roles], # type: ignore - } - for action in actions: # Add the channels where messages should be logged to, to the exempted channels - if action.type.send_alert_message and action.channel_id not in exempt_channels: - exempt_channels.append(action.channel_id) - data['exempt_channels'] = [str(r.id) for r in exempt_channels] - rule_data = await self._state.http.create_automod_rule(guild_id=self.id, data=data, reason=reason) - rule = AutoModRule(state=self._state, guild=self, **rule_data) - self._add_automod_rule(rule) - return rule - - async def edit_incidents_actions( - self, - *, - invites_disabled_until: Optional[datetime], - dms_disabled_until: Optional[datetime], - reason: Optional[str] = None - ) -> None: - """|coro| - - Edits the incident actions of the guild. - This requires the :attr:`~Permissions.manage_guild` permission. - - Parameters - ---------- - invites_disabled_until: Optional[:class:`datetime.datetime`] - When invites should be possible again (up to 24h in the future). - Set to ``None`` to enable invites again. - dms_disabled_until: Optional[:class:`datetime.datetime`] - When direct messages should be enabled again (up to 24h in the future). - Set to ``None`` to enable direct messages again. - reason: Optional[:class:`str`] - The reason for editing the incident actions. Shows up in the audit log. - - Raises - ------ - Forbidden - You don't have permissions to edit the incident actions. - HTTPException - Editing the incident actions failed. - """ - - data = await self._state.http.edit_guild_incident_actions( - guild_id=self.id, - invites_disabled_until=invites_disabled_until.isoformat() if invites_disabled_until else None, - dms_disabled_until=dms_disabled_until.isoformat() if dms_disabled_until else None, - reason=reason - ) - - +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz & (c) 2021-present mccoderpy + +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. +""" +from __future__ import annotations + +import copy +from datetime import datetime +from typing import ( + Union, + Optional, + overload, + List, + Tuple, + Dict, + Any, + Awaitable, + Iterable, + Iterator, + NamedTuple, + TYPE_CHECKING +) +from typing_extensions import Literal +import base64 + +if TYPE_CHECKING: + from os import PathLike + from .types import guild as g + from .state import ConnectionState + from .abc import Snowflake, GuildChannel + from .ext.commands import Cog + from .automod import AutoModRule + from .voice_client import VoiceProtocol + from .template import Template + from .webhook import Webhook + from .application_commands import ApplicationCommand, SlashCommandOption, SubCommandGroup, SubCommand + +from . import utils +from .file import UploadFile +from .role import Role +from .member import Member, VoiceState +from .emoji import Emoji +from .errors import InvalidData +from .permissions import PermissionOverwrite, Permissions +from .colour import Colour +from .errors import InvalidArgument, ClientException +from .channel import * +from .enums import ( + VoiceRegion, + ChannelType, + Locale, + VerificationLevel, + ContentFilter, + NotificationLevel, + EventEntityType, + AuditLogAction, + AutoModTriggerType, + AutoModEventType, + AutoArchiveDuration, + IntegrationType, + OnboardingMode, + try_enum +) +from .mixins import Hashable +from .scheduled_event import GuildScheduledEvent +from .user import User +from .invite import Invite +from .iterators import AuditLogIterator, MemberIterator, BanIterator, BanEntry +from .onboarding import * +from .welcome_screen import * +from .widget import Widget +from .asset import Asset +from .flags import SystemChannelFlags +from .integrations import _integration_factory, Integration +from .sticker import GuildSticker +from .soundboard import SoundboardSound +from .automod import AutoModRule, AutoModTriggerMetadata, AutoModAction +from .application_commands import SlashCommand, MessageCommand, UserCommand, Localizations + +MISSING = utils.MISSING + +__all__ = ( + 'GuildFeatures', + 'Guild', +) + + +class _GuildLimit(NamedTuple): + emoji: int + sticker: int + bitrate: float + filesize: int + + +async def default_callback(interaction, *args, **kwargs): + await interaction.respond( + 'This command has no callback set.' + 'Probably something is being tested with him and he is not yet fully developed.', + hidden=True + ) + + +class GuildFeatures(Iterable[str], dict): + """ + Represents a guild's features. + + This class mainly exists to make it easier to edit a guild's features. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: 'FEATURE_NAME' in features + + Checks if the guild has the feature. + + .. describe:: features.FEATURE_NAME + + Checks if the guild has the feature. Returns ``False`` if it doesn't. + + .. describe:: features.FEATURE_NAME = True + + Enables the feature in the features object, but does not enable it in the guild itself except if you pass it to :meth:`Guild.edit`. + + .. describe:: features.FEATURE_NAME = False + + Disables the feature in the features object, but does not disable it in the guild itself except if you pass it to :meth:`Guild.edit`. + + .. describe:: del features.FEATURE_NAME + + The same as ``features.FEATURE_NAME = False`` + + .. describe:: features.parsed() + + Returns a list of all features that are/should be enabled. + + .. describe:: features.merge(other) + + Returns a new object with the features of both objects merged. + If a feature is missing in the other object, it will be ignored. + + .. describe:: features == other + + Checks if two feature objects are equal. + + .. describe:: features != other + + Checks if two feature objects are not equal. + + .. describe:: iter(features) + + Returns an iterator over the enabled features. + """ + def __init__(self, /, initial: List[str] = [], **features: bool): + """ + Parameters + ----------- + initial: :class:`list` + The initial features to set. + **features: :class:`bool` + The features to set. If the value is ``True`` then the feature is/will be enabled. + If the value is ``False`` then the feature will be disabled. + """ + for feature in initial: + features[feature] = True + self.__dict__.update(features) + + def __iter__(self) -> Iterator[str]: + return [feature for feature, value in self.__dict__.items() if value is True].__iter__() + + def __contains__(self, item: str) -> bool: + return item in self.__dict__ and self.__dict__[item] is True + + def __getattr__(self, item: str) -> bool: + return self.__dict__.get(item, False) + + def __setattr__(self, key: str, value: bool) -> None: + self.__dict__[key] = value + + def __delattr__(self, item: str) -> None: + self.__dict__[item] = False + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return str(self.__dict__) + + def keys(self) -> Iterator[str]: + return self.__dict__.keys().__iter__() + + def values(self) -> Iterator[bool]: + return self.__dict__.values().__iter__() + + def items(self) -> Iterator[Tuple[str, bool]]: + return self.__dict__.items().__iter__() + + def merge(self, other: GuildFeatures) -> GuildFeatures: + base = copy.copy(self.__dict__) + + for key, value in other.items(): + base[key] = value + + return GuildFeatures(**base) + + def parsed(self) -> List[str]: + return [name for name, value in self.__dict__.items() if value is True] + + def __eq__(self, other: GuildFeatures) -> bool: + current = self.__dict__ + other = other.__dict__ + + all_keys = set(current.keys()) | set(other.keys()) + + for key in all_keys: + try: + current_value = current[key] + except KeyError: + if other[key] is True: + return False + else: + try: + other_value = other[key] + except KeyError: + pass + else: + if current_value != other_value: + return False + + return True + + def __ne__(self, other: GuildFeatures) -> bool: + return not self.__eq__(other) + + +class Guild(Hashable): + """Represents a Discord guild. + + This is referred to as a "server" in the official Discord UI. + + .. container:: operations + + .. describe:: x == y + + Checks if two guilds are equal. + + .. describe:: x != y + + Checks if two guilds are not equal. + + .. describe:: hash(x) + + Returns the guild's hash. + + .. describe:: str(x) + + Returns the guild's name. + + Attributes + ---------- + name: :class:`str` + The guild name. + emojis: Tuple[:class:`Emoji`, ...] + All emojis that the guild owns. + afk_timeout: :class:`int` + The timeout to get sent to the AFK channel. + afk_channel: Optional[:class:`VoiceChannel`] + The channel that denotes the AFK channel. ``None`` if it doesn't exist. + icon: Optional[:class:`str`] + The guild's icon. + id: :class:`int` + The guild's ID. + owner_id: :class:`int` + The guild owner's ID. Use :attr:`Guild.owner` instead. + unavailable: :class:`bool` + Indicates if the guild is unavailable. If this is ``True`` then the + reliability of other attributes outside of :attr:`Guild.id` is slim, and they might + all be ``None``. It is best to not do anything with the guild if it is unavailable. + + Check the :func:`on_guild_unavailable` and :func:`on_guild_available` events. + max_presences: Optional[:class:`int`] + The maximum amount of presences for the guild. + max_members: Optional[:class:`int`] + The maximum amount of members for the guild. + + .. note:: + + This attribute is only available via :meth:`.Client.fetch_guild`. + max_video_channel_users: Optional[:class:`int`] + The maximum amount of users in a video channel. + + .. versionadded:: 1.4 + banner: Optional[:class:`str`] + The guild's banner. + description: Optional[:class:`str`] + The guild's description. + mfa_level: :class:`int` + Indicates the guild's two-factor authorisation level. If this value is 0 then + the guild does not require 2FA for their administrative members. If the value is + 1 then they do. + verification_level: :class:`VerificationLevel` + The guild's verification level. + explicit_content_filter: :class:`ContentFilter` + The guild's explicit content filter. + default_notifications: :class:`NotificationLevel` + The guild's notification settings. + features: List[:class:`str`] + A list of features that the guild has. They are currently as follows: + + - ``VIP_REGIONS``: Guild has VIP voice regions + - ``VANITY_URL``: Guild can have a vanity invite URL (e.g. discord.gg/discord-api) + - ``INVITE_SPLASH``: Guild's invite page can have a special splash. + - ``VERIFIED``: Guild is a verified server. + - ``PARTNERED``: Guild is a partnered server. + - ``MORE_EMOJI``: Guild is allowed to have more than 50 custom emoji. + - ``MORE_STICKER``: Guild is allowed to have more than 60 custom sticker. + - ``DISCOVERABLE``: Guild shows up in Server Discovery. + - ``FEATURABLE``: Guild is able to be featured in Server Discovery. + - ``COMMUNITY``: Guild is a community server. + - ``PUBLIC``: Guild is a public guild. + - ``NEWS``: Guild can create news channels. + - ``BANNER``: Guild can upload and use a banner (i.e. :meth:`banner_url`). + - ``ANIMATED_ICON``: Guild can upload an animated icon. + - ``PUBLIC_DISABLED``: Guild cannot be public. + - ``WELCOME_SCREEN_ENABLED``: Guild has enabled the welcome screen + - ``MEMBER_VERIFICATION_GATE_ENABLED``: Guild has Membership Screening enabled. + - ``PREVIEW_ENABLED``: Guild can be viewed before being accepted via Membership Screening. + + splash: Optional[:class:`str`] + The guild's invite splash. + premium_tier: :class:`int` + The premium tier for this guild. Corresponds to "Nitro Server" in the official UI. + The number goes from 0 to 3 inclusive. + premium_subscription_count: :class:`int` + The number of "boosts" this guild currently has. + preferred_locale: Optional[:class:`Locale`] + The preferred locale for the guild. Used when filtering Server Discovery + results to a specific language. + discovery_splash: :class:`str` + The guild's discovery splash. + .. versionadded:: 1.3 + premium_progress_bar_enabled: :class:`bool` + Whether the guild has the boost progress bar enabled. + invites_disabled_until: Optional[:class:`~datetime.datetime`] + When this is set to a time in the future, then invites to the guild are disabled until that time. + .. versionadded:: 2.0 + dms_disabled_until: Optional[:class:`~datetime.datetime`] + When this is set to a time in the future, then direct messages to members of the guild are disabled + (unless users are friends to each other) until that time. + .. versionadded:: 2.0 + """ + + __slots__ = ('afk_timeout', 'afk_channel', '_members', '_channels', '_stage_instances', + 'icon', 'name', 'id', 'unavailable', 'banner', 'region', '_state', + '_application_commands', '_roles', '_events', '_member_count', '_large', + 'owner_id', 'mfa_level', 'emojis', 'features', + 'verification_level', 'explicit_content_filter', 'splash', + '_voice_states', '_system_channel_id', 'default_notifications', + 'description', 'max_presences', 'max_members', 'max_video_channel_users', + 'premium_tier', 'premium_subscription_count', '_system_channel_flags', + 'preferred_locale', 'discovery_splash', '_rules_channel_id', + '_public_updates_channel_id', '_safety_alerts_channel_id', + 'premium_progress_bar_enabled', '_welcome_screen', + 'stickers', '_automod_rules', 'invites_disabled_until', 'dms_disabled_until',) + + _PREMIUM_GUILD_LIMITS = { + None: _GuildLimit(emoji=50, sticker=5, bitrate=96e3, filesize=8388608), + 0: _GuildLimit(emoji=50, sticker=5, bitrate=96e3, filesize=8388608), + 1: _GuildLimit(emoji=100, sticker=15, bitrate=128e3, filesize=8388608), + 2: _GuildLimit(emoji=150, sticker=30, bitrate=256e3, filesize=52428800), + 3: _GuildLimit(emoji=250, sticker=60, bitrate=384e3, filesize=104857600), + } + + if TYPE_CHECKING: + name: str + verification_level: VerificationLevel + default_notifications: NotificationLevel + explicit_content_filter: ContentFilter + afk_timeout: int + region: VoiceRegion + mfa_level: int + emojis: Tuple[Emoji, ...] + features: List[str] + splash: Optional[str] + banner: Optional[str] + icon: Optional[str] + id: int + owner_id: int + description: Optional[str] + max_presences: Optional[int] + max_members: Optional[int] + max_video_channel_users: Optional[int] + premium_tier: int + premium_subscription_count: int + preferred_locale: Locale + discovery_splash: Optional[str] + premium_progress_bar_enabled: bool + invites_disabled_until: Optional[datetime] + dms_disabled_until: Optional[datetime] + unavailable: bool + _roles: Dict[int, Role] + _system_channel_id: Optional[int] + _system_channel_flags: int + _rules_channel_id: Optional[int] + _public_updates_channel_id: Optional[int] + _safety_alerts_channel_id: Optional[int] + _welcome_screen: Optional[WelcomeScreen] + _member_count: Optional[int] + _large: Optional[bool] + + def __init__(self, *, data, state: ConnectionState): + self._channels: Dict[int, Union[GuildChannel, ThreadChannel]] = {} + self._stage_instances: Dict[int, StageInstance] = {} + self._members: Dict[int, Member] = {} + self._events: Dict[int, GuildScheduledEvent] = {} + self._automod_rules: Dict[int, AutoModRule] = {} + self._voice_states: Dict[int, VoiceState] = {} + self._state: ConnectionState = state + self._application_commands: Dict[int, ApplicationCommand] = {} + self._from_data(data) + + def _add_channel(self, channel: Union[GuildChannel, ThreadChannel]): + self._channels[channel.id] = channel + if isinstance(channel, ThreadChannel): + parent = channel.parent_channel + addr = getattr(parent, '_add_thread', getattr(parent, '_add_post', None)) + if addr is not None: + addr(channel) + + def _remove_channel(self, channel: Union[GuildChannel, ThreadChannel]): + self._channels.pop(channel.id, None) + if isinstance(channel, ThreadChannel): + remvr = getattr( + channel.parent_channel, '_remove_thread', getattr(channel.parent_channel, '_remove_post', None) + ) + if remvr is not None: + remvr(channel) + + def _add_event(self, event: GuildScheduledEvent): + self._events[event.id] = event + + def _remove_event(self, event: GuildScheduledEvent): + self._events.pop(event.id, None) + + def _add_stage_instance(self, instance: StageInstance): + self._stage_instances[instance.channel.id] = instance + + def _remove_stage_instance(self, instance: StageInstance): + self._stage_instances.pop(instance.channel.id, None) + + def _add_automod_rule(self, rule: AutoModRule): + self._automod_rules[rule.id] = rule + + def _remove_automod_rule(self, rule: AutoModRule): + self._automod_rules.pop(rule.id, None) + + def _voice_state_for(self, user_id: int): + return self._voice_states.get(user_id) + + def _add_member(self, member: Member): + self._members[member.id] = member + + def _remove_member(self, member: Member): + self._members.pop(member.id, None) + + def __str__(self): + return self.name or '' + + def __repr__(self): + attrs = ( + 'id', 'name', 'shard_id', 'chunked' + ) + resolved = ['%s=%r' % (attr, getattr(self, attr)) for attr in attrs] + resolved.append('member_count=%r' % getattr(self, '_member_count', None)) + return '' % ' '.join(resolved) + + def _update_voice_state(self, data, channel_id): + user_id = int(data['user_id']) + channel = self.get_channel(channel_id) + try: + # check if we should remove the voice state from cache + if channel is None: + after = self._voice_states.pop(user_id) + else: + after = self._voice_states[user_id] + + before = copy.copy(after) + after._update(data, channel) + except KeyError: + # if we're here then we're getting added into the cache + after = VoiceState(data=data, channel=channel) + before = VoiceState(data=data, channel=None) + self._voice_states[user_id] = after + + member = self.get_member(user_id) + if member is None: + try: + member = Member(data=data['member'], state=self._state, guild=self) + except KeyError: + member = None + + return member, before, after + + def _add_role(self, role: Role): + # roles get added to the bottom (position 1, pos 0 is @everyone) + # so since self.roles has the @everyone role, we can't increment + # its position because it's stuck at position 0. Luckily x += False + # is equivalent to adding 0. So we cast the position to a bool and + # increment it. + for r in self._roles.values(): + r.position += (not r.is_default()) + + self._roles[role.id] = role + + def _remove_role(self, role_id: int) -> Role: + # this raises KeyError if it fails.. + role = self._roles.pop(role_id) + + # since it didn't, we can change the positions now + # basically the same as above except we only decrement + # the position if we're above the role we deleted. + for r in self._roles.values(): + r.position -= r.position > role.position + + return role + + def _from_data(self, guild: g.Guild) -> None: + # according to Stan, this is always available even if the guild is unavailable + # I don't have this guarantee when someone updates the guild. + member_count = guild.get('member_count', None) + if member_count is not None: + self._member_count = member_count + + self.name = guild.get('name') + self.region = try_enum(VoiceRegion, guild.get('region')) # TODO: remove in 2.0 release + self.verification_level = try_enum(VerificationLevel, guild.get('verification_level')) + self.default_notifications = try_enum(NotificationLevel, guild.get('default_message_notifications')) + self.explicit_content_filter = try_enum(ContentFilter, guild.get('explicit_content_filter', 0)) + self.afk_timeout = guild.get('afk_timeout') + self.icon = guild.get('icon') + self.banner = guild.get('banner') + self.unavailable = guild.get('unavailable', False) + self.id = int(guild['id']) + self._roles: Dict[int, Role] = {} + state = self._state # speed up attribute access + for r in guild.get('roles', []): + role = Role(guild=self, data=r, state=state) + self._roles[role.id] = role + for e in guild.get('guild_scheduled_events', []): + state.store_event(guild=self, data=e) + self.mfa_level = guild.get('mfa_level') + self.emojis = tuple(map(lambda d: state.store_emoji(self, d), guild.get('emojis', []))) + self.features = guild.get('features', []) # TODO: make this a private attribute and add a features property that returns GuildFeatures + self.splash = guild.get('splash') + self._system_channel_id = utils._get_as_snowflake(guild, 'system_channel_id') + self.description = guild.get('description') + self.max_presences = guild.get('max_presences') + self.max_members = guild.get('max_members') + self.max_video_channel_users = guild.get('max_video_channel_users') + self.premium_tier = guild.get('premium_tier', 0) + self.premium_subscription_count = guild.get('premium_subscription_count') or 0 + self._system_channel_flags = guild.get('system_channel_flags', 0) + self.preferred_locale = try_enum(Locale, guild.get('preferred_locale')) + self.discovery_splash = guild.get('discovery_splash') + self._rules_channel_id = utils._get_as_snowflake(guild, 'rules_channel_id') + self._public_updates_channel_id = utils._get_as_snowflake(guild, 'public_updates_channel_id') + self._safety_alerts_channel_id = utils._get_as_snowflake(guild, 'safety_alerts_channel_id') + self.premium_progress_bar_enabled: bool = guild.get('premium_progress_bar_enabled', False) + self._handle_incidents_data(guild.get('incidents_data')) + cache_online_members = self._state.member_cache_flags.online + cache_joined = self._state.member_cache_flags.joined + self_id = self._state.self_id + for mdata in guild.get('members', []): + member = Member(data=mdata, guild=self, state=state) + if cache_joined or (cache_online_members and member.raw_status != 'offline') or member.id == self_id: + self._add_member(member) + + self._sync(guild) + self._large = None if member_count is None else self._member_count >= 250 + + self.owner_id = utils._get_as_snowflake(guild, 'owner_id') + self.afk_channel = self.get_channel(utils._get_as_snowflake(guild, 'afk_channel_id')) + welcome_screen = guild.get('welcome_screen', None) + if welcome_screen: + self._welcome_screen = WelcomeScreen(guild=self, state=self._state, data=welcome_screen) + else: + self._welcome_screen = None + self.stickers: Tuple[GuildSticker] = tuple(map(lambda d: state.store_sticker(d), guild.get('stickers', []))) # type: ignore + for obj in guild.get('voice_states', []): + self._update_voice_state(obj, int(obj['channel_id'])) + + def _sync(self, data): + try: + self._large = data['large'] + except KeyError: + pass + + for presence in data.get('presences', []): + user_id = int(presence['user']['id']) + member = self.get_member(user_id) + if member is not None: + member._presence_update(presence) + + _state = self._state + if 'channels' in data: + channels = data['channels'] + for c in channels: + factory, ch_type = _channel_factory(c['type']) + if factory: + self._add_channel(factory(guild=self, data=c, state=_state)) + + if 'threads' in data: + threads = data['threads'] + for t in threads: + factory, ch_type = _channel_factory(t['type']) + if factory: + parent_channel = self.get_channel(int(t['parent_id'])) + if parent_channel is None: + continue # we don't know why this happens sometimes, we skipp this for now + thread = factory(guild=self, data=t, state=_state) + self._add_channel(thread) + if isinstance(parent_channel, ForumChannel): + parent_channel._add_post(thread) + else: + self._add_channel(thread) + parent_channel._add_thread(thread) + + if 'stage_instances' in data: + for i in data['stage_instances']: + stage = self.get_channel(int(i['channel_id'])) + if stage is not None: + self._add_stage_instance(StageInstance(state=_state, channel=stage, data=i)) + + def _handle_incidents_data(self, incidents_data: Optional[g.IncidentsData]): + if incidents_data: + if invites_disabled_until := incidents_data.get('invites_disabled_until'): + self.invites_disabled_until = datetime.fromisoformat(invites_disabled_until) + else: + self.invites_disabled_until = None + if dms_disabled_until := incidents_data.get('dms_disabled_until'): + self.dms_disabled_until = datetime.fromisoformat(dms_disabled_until) + else: + self.dms_disabled_until = None + else: + self.invites_disabled_until = None + self.dms_disabled_until = None + + @property + def application_commands(self) -> List[ApplicationCommand]: + """List[:class:`~discord.ApplicationCommand`]: A list of application-commands from this application that are registered only in this guild. + """ + return list(self._application_commands.values()) + + def get_application_command(self, id: int) -> Optional[ApplicationCommand]: + """Optional[:class:`~discord.ApplicationCommand`]: Returns an application-command from this application that are registered only in this guild with the given id""" + return self._application_commands.get(id, None) + + @property + def channels(self) -> List[Union[GuildChannel, ThreadChannel, ForumPost]]: + """List[:class:`abc.GuildChannel`, :class:`ThreadChannel`, :class:`ForumPost`]: A list of channels that belongs to this guild.""" + return list(self._channels.values()) + + def stage_instances(self) -> List[StageInstance]: + """List[:class:`~discord.StageInstance`]: A list of stage instances that belongs to this guild.""" + return list(self._stage_instances.values()) + + @property + def server_guide_channels(self) -> List[GuildChannel]: + """List[:class:`abc.GuildChannel`]: A list of channels that are part of the servers guide.""" + return [c for c in self._channels.values() if c.flags.is_resource_channel] + + @property + def events(self) -> List[GuildScheduledEvent]: + """List[:class:`~discord.GuildScheduledEvent`]: A list of scheduled events that belong to this guild.""" + return list(self._events.values()) + + scheduled_events = events + + @property + def cached_automod_rules(self) -> List[AutoModRule]: + """ + List[:class:`AutoModRules`]: A list of auto moderation rules that are cached. + + .. admonition:: Reliable Fetching + :class: helpful + + This property is only reliable if :meth:`~Guild.automod_rules` was used before. + To ensure that the rules are up-to-date, use :meth:`~Guild.automod_rules` instead. + """ + return list(self._automod_rules.values()) + + def get_event(self, id: int) -> Optional[GuildScheduledEvent]: + """ + Returns a scheduled event with the given ID. + + Parameters + ---------- + id: :class:`int` + The ID of the event to get. + + Returns + ------- + Optional[:class:`~discord.GuildScheduledEvent`] + The scheduled event or ``None`` if not found. + """ + return self._events.get(id) + + get_scheduled_event = get_event + + @property + def large(self) -> bool: + """:class:`bool`: Indicates if the guild is a 'large' guild. + + A large guild is defined as having more than ``large_threshold`` count + members, which for this library is set to the maximum of 250. + """ + if self._large is None: + try: + return self._member_count >= 250 + except AttributeError: + return len(self._members) >= 250 + return self._large + + @property + def voice_channels(self) -> List[VoiceChannel]: + """List[:class:`VoiceChannel`]: A list of voice channels that belongs to this guild. + + This is sorted by the position and are in UI order from top to bottom. + """ + r = [ch for ch in self._channels.values() if isinstance(ch, VoiceChannel)] + r.sort(key=lambda c: (c.position, c.id)) + return r + + @property + def stage_channels(self) -> List[StageChannel]: + """List[:class:`StageChannel`]: A list of voice channels that belongs to this guild. + + .. versionadded:: 1.7 + + This is sorted by the position and are in UI order from top to bottom. + """ + r = [ch for ch in self._channels.values() if isinstance(ch, StageChannel)] + r.sort(key=lambda c: (c.position, c.id)) + return r + + @property + def me(self) -> Member: + """:class:`Member`: Similar to :attr:`Client.user` except an instance of :class:`Member`. + This is essentially used to get the member version of yourself. + """ + self_id = self._state.user.id + return self.get_member(self_id) + + @property + def voice_client(self) -> Optional[VoiceProtocol]: + """Optional[:class:`VoiceProtocol`]: Returns the :class:`VoiceProtocol` associated with this guild, if any.""" + return self._state._get_voice_client(self.id) + + @property + def text_channels(self) -> List[TextChannel]: + """List[:class:`TextChannel`]: A list of text channels that belongs to this guild. + + This is sorted by the position and are in UI order from top to bottom. + """ + r = [ch for ch in self._channels.values() if isinstance(ch, TextChannel)] + r.sort(key=lambda c: (c.position, c.id)) + return r + + @property + def thread_channels(self) -> List[ThreadChannel]: + """List[:class:`ThreadChannel`]: A list of **cached** thread channels the guild has. + + This is sorted by the position of the threads :attr:`~discord.ThreadChannel.parent` + and are in UI order from top to bottom. + """ + r = list() + [r.extend(ch.threads) for ch in self._channels.values() if isinstance(ch, TextChannel)] + r.sort(key=lambda t: (t.parent_channel.position, t.id)) + return r + + @property + def forum_channels(self) -> List[ForumChannel]: + """List[:class:`ForumChannel`]: A list of forum channels the guild has. + + This is sorted by the position of the forums :attr:`~discord.ForumChannel.parent` + and are in UI order from top to bottom. + """ + r = [ch for ch in self._channels.values() if isinstance(ch, ForumChannel)] + r.sort(key=lambda f: (f.parent_channel.position, f.id)) + return r + + @property + def forum_posts(self) -> List[ForumPost]: + """List[:class:`ForumPost`]: A list of **cached** forum posts the guild has. + + This is sorted by the position of the forums :attr:`~discord.ForumPost.parent` + and are in UI order from top to bottom. + """ + r = [ch for ch in self._channels.values() if isinstance(ch, ForumPost)] + r.sort(key=lambda f: (f.parent_channel.position, f.id)) + return r + + @property + def guide_channels(self) -> GuildChannel: + """List[:class:`GuildChannel`]: A list of channels that are part of the server guide. + + There is an alias for this called :attr:`~Guild.resource_channels`. + """ + r: List[GuildChannel] = [ch for ch in self._channels.values() if ch.flags.is_resource_channel] + r.sort(key=lambda c: (c.position, c.id)) + return r + + resource_channels = guide_channels + + @property + def categories(self) -> List[CategoryChannel]: + """List[:class:`CategoryChannel`]: A list of categories that belongs to this guild. + + This is sorted by the position and are in UI order from top to bottom. + """ + r = [ch for ch in self._channels.values() if isinstance(ch, CategoryChannel)] + r.sort(key=lambda c: (c.position, c.id)) + return r + + def by_category(self) -> List[Tuple[Optional[CategoryChannel], List[GuildChannel]]]: + """Returns every :class:`CategoryChannel` and their associated channels. + + These channels and categories are sorted in the official Discord UI order. + + If the channels do not have a category, then the first element of the tuple is + ``None``. + + Returns + -------- + List[Tuple[Optional[:class:`CategoryChannel`], List[:class:`abc.GuildChannel`]]]: + The categories and their associated channels. + """ + grouped = {} + for channel in self._channels.values(): + if isinstance(channel, CategoryChannel): + grouped.setdefault(channel.id, []) + continue + if isinstance(channel, ThreadChannel): + continue + try: + grouped[channel.category_id].append(channel) + except KeyError: + grouped[channel.category_id] = [channel] + + def key(t): + k, v = t + return ((k.position, k.id) if k else (-1, -1), v) + + _get = self._channels.get + as_list = [(_get(k), v) for k, v in grouped.items()] + as_list.sort(key=key) + for _, channels in as_list: + channels.sort(key=lambda c: (c._sorting_bucket, c.position, c.id)) + return as_list + + def get_channel(self, channel_id: int) -> Optional[ + Union[CategoryChannel, TextChannel, StageChannel, VoiceChannel, ThreadChannel, ForumPost] + ]: + """Returns a channel with the given ID. + + Parameters + ----------- + channel_id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[Union[:class:`.abc.GuildChannel`, :class:`ThreadChannel`, :class:`ForumPost`]] + The channel or ``None`` if not found. + """ + return self._channels.get(channel_id) + + @property + def system_channel(self) -> Optional[TextChannel]: + """Optional[:class:`TextChannel`]: Returns the guild's channel used for system messages. + + If no channel is set, then this returns ``None``. + """ + channel_id = self._system_channel_id + return channel_id and self._channels.get(channel_id) + + @property + def system_channel_flags(self) -> SystemChannelFlags: + """:class:`SystemChannelFlags`: Returns the guild's system channel settings.""" + return SystemChannelFlags._from_value(self._system_channel_flags) + + + @property + def rules_channel(self) -> Optional[TextChannel]: + """Optional[:class:`TextChannel`]: Return's the guild's channel used for the rules. + The guild must be a Community guild. + + If no channel is set, then this returns ``None``. + + .. versionadded:: 1.3 + """ + channel_id = self._rules_channel_id + return channel_id and self._channels.get(channel_id) + + @property + def public_updates_channel(self) -> Optional[TextChannel]: + """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 + Community guild. + + If no channel is set, then this returns ``None``. + + .. versionadded:: 2.0 + """ + channel_id = self._public_updates_channel_id + return channel_id and self._channels.get(channel_id) + + @property + def safety_alerts_channel(self) -> Optional[TextChannel]: + """Optional[:class:`TextChannel`]: Return's the guild's channel where Discord + sends safety alerts in. The guild must be a Community guild. + + If no channel is set, then this returns ``None``. + + .. versionadded:: 2.0 + """ + channel_id = self._safety_alerts_channel_id + return channel_id and self._channels.get(channel_id) + + @property + def emoji_limit(self) -> int: + """:class:`int`: The maximum number of emoji slots this guild has.""" + more_emoji = 200 if 'MORE_EMOJI' in self.features else 50 + return max(more_emoji, self._PREMIUM_GUILD_LIMITS[self.premium_tier].emoji) + + @property + def sticker_limit(self) -> int: + """:class:`int`: The maximum number of sticker slots this guild has.""" + more_sticker = 60 if 'MORE_STICKER' in self.features else 5 + return max(more_sticker, self._PREMIUM_GUILD_LIMITS[self.premium_tier].sticker) + + @property + def bitrate_limit(self) -> float: + """:class:`float`: The maximum bitrate for voice channels this guild can have.""" + vip_guild = self._PREMIUM_GUILD_LIMITS[1].bitrate if 'VIP_REGIONS' in self.features else 96e3 + return max(vip_guild, self._PREMIUM_GUILD_LIMITS[self.premium_tier].bitrate) + + @property + def filesize_limit(self) -> int: + """:class:`int`: The maximum number of bytes files can have when uploaded to this guild.""" + return self._PREMIUM_GUILD_LIMITS[self.premium_tier].filesize + + @property + def members(self) -> List[Member]: + """List[:class:`Member`]: A list of members that belong to this guild.""" + return list(self._members.values()) + + def get_member(self, user_id: int) -> Optional[Member]: + """Returns a member with the given ID. + + Parameters + ----------- + user_id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`Member`] + The member or ``None`` if not found. + """ + return self._members.get(user_id) + + @property + def premium_subscribers(self) -> List[Member]: + """List[:class:`Member`]: A list of members who have "boosted" this guild.""" + return [member for member in self.members if member.premium_since is not None] + + @property + def roles(self) -> List[Role]: + """List[:class:`Role`]: Returns a :class:`list` of the guild's roles in hierarchy order. + + The first element of this list will be the lowest role in the + hierarchy. + """ + return sorted(self._roles.values()) + + def get_role(self, role_id) -> Optional[Role]: + """Returns a role with the given ID. + + Parameters + ----------- + role_id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`Role`] + The role or ``None`` if not found. + """ + return self._roles.get(role_id) + + @property + def default_role(self) -> Role: + """: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[Role]: + """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[Role]: + """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[Member]: + """Optional[:class:`Member`]: The member that owns the guild.""" + return self.get_member(self.owner_id) + + @property + def icon_url(self) -> Asset: + """:class:`Asset`: Returns the guild's icon asset.""" + return self.icon_url_as() + + def is_icon_animated(self) -> bool: + """:class:`bool`: Returns True if the guild has an animated icon.""" + return bool(self.icon and self.icon.startswith('a_')) + + def icon_url_as( + self, + *, + format: Literal['webp', 'jpeg', 'jpg', 'png', 'gif'] = None, + static_format: Literal['webp', 'jpeg', 'jpg', 'png'] = 'webp', + size: int = 1024 + ) -> Asset: + """Returns an :class:`Asset` for the guild's icon. + + The format must be one of 'webp', 'jpeg', 'jpg', 'png' or 'gif', and + 'gif' is only valid for animated avatars. The size must be a power of 2 + between 16 and 4096. + + Parameters + ----------- + format: Optional[:class:`str`] + The format to attempt to convert the icon to. + If the format is ``None``, then it is automatically + detected into either 'gif' or static_format depending on the + icon being animated or not. + static_format: Optional[:class:`str`] + Format to attempt to convert only non-animated icons to. + size: :class:`int` + The size of the image to display. + + Raises + ------ + InvalidArgument + Bad image format passed to ``format`` or invalid ``size``. + + Returns + -------- + :class:`Asset` + The resulting CDN asset. + """ + return Asset._from_guild_icon(self._state, self, format=format, static_format=static_format, size=size) + + @property + def banner_url(self) -> Asset: + """:class:`Asset`: Returns the guild's banner asset.""" + return self.banner_url_as() + + def banner_url_as( + self, + *, + format: Literal['webp', 'jpeg', 'jpg', 'png'] = 'webp', + size: int = 2048 + ) -> Asset: + """Returns an :class:`Asset` for the guild's banner. + + The format must be one of 'webp', 'jpeg', or 'png'. The + size must be a power of 2 between 16 and 4096. + + Parameters + ----------- + format: :class:`str` + The format to attempt to convert the banner to. + size: :class:`int` + The size of the image to display. + + Raises + ------ + InvalidArgument + Bad image format passed to ``format`` or invalid ``size``. + + Returns + -------- + :class:`Asset` + The resulting CDN asset. + """ + return Asset._from_guild_image(self._state, self.id, self.banner, 'banners', format=format, size=size) + + @property + def splash_url(self) -> Asset: + """:class:`Asset`: Returns the guild's invite splash asset.""" + return self.splash_url_as() + + def splash_url_as( + self, + *, + format: Literal['webp', 'jpeg', 'jpg', 'png'] = 'webp', + size: int = 2048 + ) -> Asset: + """Returns an :class:`Asset` for the guild's invite splash. + + The format must be one of 'webp', 'jpeg', 'jpg', or 'png'. The + size must be a power of 2 between 16 and 4096. + + Parameters + ----------- + format: :class:`str` + The format to attempt to convert the splash to. + size: :class:`int` + The size of the image to display. + + Raises + ------ + InvalidArgument + Bad image format passed to ``format`` or invalid ``size``. + + Returns + -------- + :class:`Asset` + The resulting CDN asset. + """ + return Asset._from_guild_image(self._state, self.id, self.splash, 'splashes', format=format, size=size) + + @property + def discovery_splash_url(self) -> Asset: + """:class:`Asset`: Returns the guild's discovery splash asset. + + .. versionadded:: 1.3 + """ + return self.discovery_splash_url_as() + + def discovery_splash_url_as( + self, + *, + format: Literal['webp', 'jpeg', 'jpg', 'png'] = 'webp', + size: int = 2048 + ) -> Asset: + """Returns an :class:`Asset` for the guild's discovery splash. + + The format must be one of 'webp', 'jpeg', 'jpg', or 'png'. The + size must be a power of 2 between 16 and 4096. + + .. versionadded:: 1.3 + + Parameters + ----------- + format: :class:`str` + The format to attempt to convert the splash to. + size: :class:`int` + The size of the image to display. + + Raises + ------ + InvalidArgument + Bad image format passed to ``format`` or invalid ``size``. + + Returns + -------- + :class:`Asset` + The resulting CDN asset. + """ + return Asset._from_guild_image( + self._state, + self.id, + self.discovery_splash, + 'discovery-splashes', + format=format, + size=size + ) + + @property + def member_count(self) -> int: + """:class:`int`: Returns the true member count regardless of it being loaded fully or not. + + .. warning:: + + Due to a Discord limitation, in order for this attribute to remain up-to-date and + accurate, it requires :attr:`Intents.members` to be specified. + + """ + return self._member_count + + @property + def chunked(self) -> bool: + """:class:`bool`: Returns a boolean indicating if the guild is "chunked". + + A chunked guild means that :attr:`member_count` is equal to the + number of members stored in the internal :attr:`members` cache. + + If this value returns ``False``, then you should request for + offline members. + """ + count = getattr(self, '_member_count', None) + if count is None: + return False + return count == len(self._members) + + @property + def shard_id(self) -> Optional[int]: + """Optional[:class:`int`]: Returns the shard ID for this guild if applicable.""" + count = self._state.shard_count + if count is None: + return None + return (self.id >> 22) % count + + @property + def created_at(self) -> datetime: + """:class:`datetime.datetime`: Returns the guild's creation time in UTC.""" + return utils.snowflake_time(self.id) + + def get_member_named(self, name: str) -> Optional[Member]: + """Returns the first member found that matches the name provided. + + The name can have an optional discriminator argument, e.g. "Jake#0001" + or "Jake" will both do the lookup. However, the former will give a more + precise result. Note that the discriminator must have all 4 digits + for this to work. + + If a nickname is passed, then it is looked up via the nickname. Note + however, that a nickname + discriminator combo will not lookup the nickname + but rather the username + discriminator combo due to nickname + discriminator + not being unique. + + If no member is found, ``None`` is returned. + + Parameters + ----------- + name: :class:`str` + The name of the member to lookup with an optional discriminator. + + Returns + -------- + Optional[:class:`Member`] + The member in this guild with the associated name. If not found + then ``None`` is returned. + """ + + result = None + members = self.members + if len(name) > 5 and name[-5] == '#': + # The 5 length is checking to see if #0000 is in the string, + # as a#0000 has a length of 6, the minimum for a potential + # discriminator lookup. + potential_discriminator = name[-4:] + + # do the actual lookup and return if found + # if it isn't found then we'll do a full name lookup below. + result = utils.get(members, name=name[:-5], discriminator=potential_discriminator) + if result is not None: + return result + + def pred(m: Member): + return m.nick == name or m.global_name == name or m.name == name + + return utils.find(pred, members) + + async def create_soundboard_sound(self, + name: str, + sound: Union[str, bytes, io.IOBase, Path], + volume: Optional[float] = 1.0, + emoji_id: Optional[int] = None, + emoji_name: Optional[str] = None, + auto_trim: bool = False) -> SoundboardSound: + """|coro| + + Creates a new soundboard sound in the guild. + + Requires the ``CREATE_GUILD_EXPRESSIONS`` permission. + Triggers a Guild Soundboard Sound Create Gateway event. + Supports the ``X-Audit-Log-Reason`` header. + + Soundboard sounds must be a valid MP3 or OGG file, + with a maximum size of 512 KB and a maximum duration of 5.2 seconds. + + Examples + ---------- + + :class:`bytes` Rawdata: :: + + with open("sound.mp3", "rb") as f: + sound_bytes = f.read() + + sound = await guild.create_soundboard_sound(name="sound", sound=sound_bytes) + + :class:`str` Base64 encoded: :: + + b64 = base64.b64encode(b"RIFF...").decode("utf-8") + encoded = await guild.create_soundboard_sound(name="sound", sound=b64) + + or with prefix :: + + b64 = base64.b64encode(b"RIFF...").decode("utf-8") + encoded = await guild.create_soundboard_sound(name="sound", sound=f"data:audio/ogg;base64,{b64}") + + :class:`io.IOBase`: :: + + with open("sound.ogg", "rb") as f: + sound = await guild.create_soundboard_sound(name="sound", sound=f) + + Parameters + ---------- + name: :class:`str` + The name of the soundboard sound (2–32 characters). + sound: Union[:class:`str`, :class:`bytes`, :class:`io.IOBase`, :class:`pathlib.Path`] + The MP3 or OGG sound data. Can be a file path, raw bytes, or file-like object. + volume: Optional[:class:`float`] + The volume level of the sound (0.0 to 1.0). Defaults to 1.0. + emoji_id: Optional[:class:`int`] + The ID of a custom emoji to associate with the sound. + emoji_name: Optional[:class:`str`] + The Unicode character of a standard emoji to associate with the sound. + auto_trim: Optional[:class:`bool`] + Takes a random point from the audio material that is max. 5.2 seconds long. + + Raises + ------ + discord.Forbidden + You don't have permission to create this sound. + discord.HTTPException + The creation failed. + ValueError + One of the fields is invalid or the sound exceeds the size/duration limits. + + Returns + ------- + :class:`SoundboardSound` + The created SoundboardSound object. + """ + + if not (2 <= len(name) <= 32): + raise ValueError("Soundboard name must be between 2 and 32 characters.") + + if auto_trim: + sound_trim = SoundboardSound._auto_trim(sound) + _sound = SoundboardSound._encode_sound(sound_trim) + else: + _sound = SoundboardSound._encode_sound(sound) + + data = await self._state.http.create_soundboard_sound(guild_id=self.id, name=name, sound=_sound, volume=volume, emoji_id=emoji_id, emoji_name=emoji_name) + return SoundboardSound(guild=self, state=self._state, data=data) + + async def update_soundboard_sound(self, + name: str, + sound_id: int, + volume: Optional[float] = 1.0, + emoji_id: Optional[int] = None, + emoji_name: Optional[str] = None) -> SoundboardSound: + """|coro| + + Updates an existing soundboard sound in the guild. + + For sounds created by the current user, requires either the ``CREATE_GUILD_EXPRESSIONS`` + or ``MANAGE_GUILD_EXPRESSIONS`` permission. For other sounds, requires the + ``MANAGE_GUILD_EXPRESSIONS`` permission. + + All parameters are optional except ``sound_id``. + Triggers a Guild Soundboard Sound Update Gateway event. + Supports the ``X-Audit-Log-Reason`` header. + + Parameters + ---------- + name: Optional[:class:`str`] + The new name of the sound (2–32 characters). + sound_id: :class:`int` + The ID of the soundboard sound to update. + volume: Optional[:class:`float`] + The volume level of the sound (0.0 to 1.0). + emoji_id: Optional[:class:`int`] + The ID of the emoji to associate with the sound. + emoji_name: Optional[:class:`str`] + The name of the emoji to associate with the sound. + + Raises + ------ + discord.Forbidden + You don't have permission to modify this sound. + discord.HTTPException + The modification failed. + ValueError + One of the fields is invalid or out of range. + + Returns + ------- + :class:`SoundboardSound` + The updated soundboard sound object. + """ + + if not (2 <= len(name) <= 32): + raise ValueError("Soundboard name must be between 2 and 32 characters.") + + data = await self._state.http.update_soundboard_sound(guild_id=self.id, sound_id =sound_id, name=name, volume=volume, emoji_id=emoji_id, emoji_name=emoji_name) + return SoundboardSound(guild=self, state=self._state, data=data) + + async def delete_soundboard_sound(self, sound_id: int) -> SoundboardSound: + """|coro| + + Deletes a soundboard sound from the guild. + + For sounds created by the current user, requires either the ``CREATE_GUILD_EXPRESSIONS`` + or ``MANAGE_GUILD_EXPRESSIONS`` permission. For other sounds, requires the + ``MANAGE_GUILD_EXPRESSIONS`` permission. + + This action triggers a Guild Soundboard Sound Delete Gateway event. + + This endpoint supports the ``X-Audit-Log-Reason`` header. + + Parameters + ---------- + sound_id: :class:`int` + The ID of the soundboard sound to delete. + + Raises + ------ + discord.Forbidden + You don't have permission to delete this sound. + discord.HTTPException + Deleting the sound failed. + + Returns + ------- + :class:`SoundboardSound` + The deleted sound_id. + """ + + await self._state.http.delete_soundboard_sound(guild_id=self.id, sound_id=sound_id) + + async def get_soundboard_sound(self, sound_id: int) -> SoundboardSound: + """|coro| + + Retrieves a specific soundboard sound by its ID. + + Includes the user field if the bot has the ``CREATE_GUILD_EXPRESSIONS`` or ``MANAGE_GUILD_EXPRESSIONS`` permission. + + Parameters + ---------- + sound_id: :class:`int` + The ID of the sound to retrieve. + + Raises + ------ + discord.Forbidden + You do not have permission to fetch this sound. + discord.NotFound + The sound with the given ID does not exist. + discord.HTTPException + Fetching the sound failed. + + Returns + ------- + :class:`SoundboardSound` + The retrieved SoundboardSound object. + """ + + data = await self._state.http.get_soundboard_sound(guild_id=self.id, sound_id=sound_id) + return SoundboardSound(guild=self, state=self._state, data=data) + + async def all_soundboard_sound(self) -> SoundboardSound: + """|coro| + + Retrieves a list of all soundboard sounds in the guild. + + If the bot has either the ``CREATE_GUILD_EXPRESSIONS`` or ``MANAGE_GUILD_EXPRESSIONS`` permission, + the returned sounds will include user-related metadata. + + Raises + ------ + discord.Forbidden + You do not have permission to fetch the soundboard sounds. + discord.HTTPException + Fetching the soundboard sounds failed. + + Returns + ------- + List[:class:`SoundboardSound`] + A list of soundboard sounds available in the guild. + """ + + data = await self._state.http.all_soundboard_sounds(guild_id=self.id) + data = data["items"] + return SoundboardSound._from_list(guild=self, state=self._state, data_list=data) + + async def default_soundboard_sounds(self) -> SoundboardSound: + """|coro| + + Returns the default global soundboard sounds available to all users. + + Raises + ------ + discord.HTTPException + Fetching the sounds failed. + + Returns + ------- + List[:class:`SoundboardSound`] + A list of default SoundboardSound objects. + """ + + data = await self._state.http.default_soundboard_sounds() + return SoundboardSound._from_list(guild=self, state=self._state, data_list=data) + + def _create_channel( + self, + name: str, + overwrites: Dict[Union[Role, Member], PermissionOverwrite], + channel_type: ChannelType, + category: Optional[CategoryChannel] = None, + reason: Optional[str] = None, + **options: Any + ): + if overwrites is None: + overwrites = {} + elif not isinstance(overwrites, dict): + raise InvalidArgument('overwrites parameter expects a dict.') + + perms = [] + for target, perm in overwrites.items(): + if not isinstance(target, (Role, Member)): + raise InvalidArgument('Expected Member or Role received {0.__name__}'.format(type(target))) + if not isinstance(perm, PermissionOverwrite): + raise InvalidArgument('Expected PermissionOverwrite received {0.__name__}'.format(type(perm))) + + allow, deny = perm.pair() + payload = { + 'allow': allow.value, + 'deny': deny.value, + 'id': target.id + } + + if isinstance(target, Role): + payload['type'] = 'role' + else: + payload['type'] = 'member' + + perms.append(payload) + + try: + options['rate_limit_per_user'] = options.pop('slowmode_delay') + except KeyError: + pass + + try: + rtc_region = options.pop('rtc_region') + except KeyError: + pass + else: + options['rtc_region'] = None if rtc_region is None else str(rtc_region) + + if channel_type.text or channel_type.forum_channel: + try: + default_auto_archive_duration: AutoArchiveDuration = options.pop('default_auto_archive_duration') + except KeyError: + pass + else: + default_auto_archive_duration = try_enum(AutoArchiveDuration, default_auto_archive_duration) + if not isinstance(default_auto_archive_duration, AutoArchiveDuration): + raise InvalidArgument( + f'{default_auto_archive_duration} is not a valid default_auto_archive_duration' + ) + else: + options['default_auto_archive_duration'] = default_auto_archive_duration.value + + try: + options['default_thread_rate_limit_per_user']: int = options.pop('default_thread_slowmode_delay') + except KeyError: + pass + + parent_id = category.id if category else None + return self._state.http.create_channel( + self.id, channel_type.value, name=name, parent_id=parent_id, + permission_overwrites=perms, reason=reason, **options + ) + + async def create_text_channel( + self, + name: str, + *, + overwrites: Optional[Dict[Snowflake, PermissionOverwrite]] = None, + category: Optional[CategoryChannel] = None, + reason: Optional[str] = None, + **options + ) -> TextChannel: + """|coro| + + Creates a :class:`TextChannel` for the guild. + + Note that you need the :attr:`~Permissions.manage_channels` permission + to create the channel. + + The ``overwrites`` parameter can be used to create a 'secret' + channel upon creation. This parameter expects a :class:`dict` of + overwrites with the target (either a :class:`Member` or a :class:`Role`) + as the key and a :class:`PermissionOverwrite` as the value. + + .. note:: + + Creating a channel of a specified position will not update the position of + other channels to follow suit. A follow-up call to :meth:`~TextChannel.edit` + will be required to update the position of the channel in the channel list. + + Examples + ---------- + + Creating a basic channel: + + .. code-block:: python3 + + channel = await guild.create_text_channel('cool-channel') + + Creating a "secret" channel: + + .. code-block:: python3 + + overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.me: discord.PermissionOverwrite(read_messages=True) + } + + channel = await guild.create_text_channel('secret', overwrites=overwrites) + + Parameters + ----------- + name: :class:`str` + The channel's name. + overwrites + A :class:`dict` of target (either a role or a member) to + :class:`PermissionOverwrite` to apply upon creation of a channel. + Useful for creating secret channels. + category: Optional[:class:`CategoryChannel`] + The category to place the newly created channel under. + The permissions will be automatically synced to category if no + overwrites are provided. + position: :class:`int` + The position in the channel list. This is a number that starts + at 0. e.g. the top channel is position 0. + topic: Optional[:class:`str`] + The new channel's topic. + slowmode_delay: :class:`int` + Specifies the slowmode rate limit for user in this channel, in seconds. + The maximum value possible is `21600`. + default_thread_slowmode_delay: :class:`int` + The initial ``slowmode_delay`` to set on newly created threads in the channel. + This field is copied to the thread at creation time and does not live update. + nsfw: :class:`bool` + To mark the channel as NSFW or not. + reason: Optional[:class:`str`] + The reason for creating this channel. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have the proper permissions to create this channel. + HTTPException + Creating the channel failed. + InvalidArgument + The permission overwrite information is not in proper form. + + Returns + ------- + :class:`TextChannel` + The channel that was just created. + """ + data = await self._create_channel(name, overwrites, ChannelType.text, category, reason=reason, **options) + channel = TextChannel(state=self._state, guild=self, data=data) + + # temporarily add to the cache + self._channels[channel.id] = channel + return channel + + async def create_voice_channel( + self, + name: str, + *, + overwrites: Optional[Dict[Snowflake, PermissionOverwrite]] = None, + category: Optional[CategoryChannel] = None, + reason: Optional[str] = None, + **options + ) -> VoiceChannel: + """|coro| + + This is similar to :meth:`create_text_channel` except makes a :class:`VoiceChannel` instead, in addition + to having the following new parameters. + + Parameters + ----------- + bitrate: :class:`int` + The channel's preferred audio bitrate in bits per second. + user_limit: :class:`int` + The channel's limit for number of members that can be in a voice channel. + rtc_region: Optional[:class:`VoiceRegion`] + The region for the voice channel's voice communication. + A value of ``None`` indicates automatic voice region detection. + + .. versionadded:: 1.7 + + Raises + ------ + Forbidden + You do not have the proper permissions to create this channel. + HTTPException + Creating the channel failed. + InvalidArgument + The permission overwrite information is not in proper form. + + Returns + ------- + :class:`VoiceChannel` + The channel that was just created. + """ + data = await self._create_channel(name, overwrites, ChannelType.voice, category, reason=reason, **options) + channel = VoiceChannel(state=self._state, guild=self, data=data) + + # temporarily add to the cache + self._channels[channel.id] = channel + return channel + + async def create_stage_channel( + self, + name: str, + *, + topic: Optional[str] = None, + category: Optional[CategoryChannel] = None, + overwrites: Optional[Dict[Snowflake, PermissionOverwrite]] = None, + reason: Optional[str] = None, + position: Optional[int] = None + ) -> StageChannel: + """|coro| + + This is similar to :meth:`create_text_channel` except makes a :class:`StageChannel` instead, in addition + to having the following new parameters. + + Parameters + ---------- + topic: Optional[:class:`str`] + The topic of the Stage instance (1-120 characters) + + .. note:: + + The ``slowmode_delay`` and ``nsfw`` parameters are not supported in this function. + + .. versionadded:: 1.7 + + Raises + ------ + Forbidden + You do not have the proper permissions to create this channel. + HTTPException + Creating the channel failed. + InvalidArgument + The permission overwrite information is not in proper form. + + Returns + ------- + :class:`StageChannel` + The channel that was just created. + """ + data = await self._create_channel( + name, overwrites, ChannelType.stage_voice, category, reason=reason, position=position, topic=topic + ) + channel = StageChannel(state=self._state, guild=self, data=data) + + # temporarily add to the cache + self._channels[channel.id] = channel + return channel + + async def create_forum_channel( + self, + name: str, + *, + topic: Optional[str] = None, + slowmode_delay: Optional[int] = None, + default_post_slowmode_delay: Optional[int] = None, + default_auto_archive_duration: Optional[AutoArchiveDuration] = None, + overwrites: Optional[Dict[Snowflake, PermissionOverwrite]] = None, + nsfw: Optional[bool] = None, + category: Optional[CategoryChannel] = None, + position: Optional[int] = None, + reason: Optional[str] = None + ) -> ForumChannel: + """|coro| + + Same as :meth:`create_text_channel` excepts that it creates a forum channel instead + + Parameters + ---------- + name: :class:`str` + The name of the channel + overwrites + A :class:`dict` of target (either a role or a member) to + :class:`PermissionOverwrite` to apply upon creation of a channel. + Useful for creating secret channels. + category: Optional[:class:`CategoryChannel`] + The category to place the newly created channel under. + The permissions will be automatically synced to category if no + overwrites are provided. + position: :class:`int` + The position in the channel list. This is a number that starts + at 0. e.g. the top channel is position 0. + topic: Optional[:class:`str`] + The new channel's topic. + slowmode_delay: :class:`int` + Specifies the slowmode rate limit for user in this channel, in seconds. + The maximum value possible is `21600`. + default_post_slowmode_delay: :class:`int` + The initial ``slowmode_delay`` to set on newly created threads in the channel. + This field is copied to the thread at creation time and does not live update. + default_auto_archive_duration: :class:`AutoArchiveDuration` + The default duration that the clients use (not the API) for newly created threads in the channel, + in minutes, to automatically archive the thread after recent activity + nsfw: :class:`bool` + To mark the channel as NSFW or not. + reason: Optional[:class:`str`] + The reason for creating this channel. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have the proper permissions to create this channel. + HTTPException + Creating the channel failed. + InvalidArgument + The permission overwrite information is not in proper form, + or the ``default_auto_archive_duration`` is not a valid member of :class:`AutoArchiveDuration` + + Returns + ------- + :class:`ForumChannel` + The channel that was just created + """ + data = await self._create_channel( + name, + overwrites, + ChannelType.forum_channel, + category, + topic=topic, + slowmode_delay=slowmode_delay, + default_thread_slowmode_delay=default_post_slowmode_delay, + default_auto_archive_duration=default_auto_archive_duration, + nsfw=nsfw, + position=position, + reason=reason + ) + channel = ForumChannel(state=self._state, guild=self, data=data) + + # temporarily add to the cache + self._channels[channel.id] = channel + return channel + + async def create_category( + self, + name: str, + *, + overwrites: Optional[Dict[Snowflake, PermissionOverwrite]] = None, + reason: Optional[str] = None, + position: Optional[int] = None + ) -> CategoryChannel: + """|coro| + + Same as :meth:`create_text_channel` except makes a :class:`CategoryChannel` instead. + + .. note:: + + The ``category`` parameter is not supported in this function since categories + cannot have categories. + + Raises + ------ + Forbidden + You do not have the proper permissions to create this channel. + HTTPException + Creating the channel failed. + InvalidArgument + The permission overwrite information is not in proper form. + + Returns + ------- + :class:`CategoryChannel` + The channel that was just created. + """ + data = await self._create_channel(name, overwrites, ChannelType.category, reason=reason, position=position) + channel = CategoryChannel(state=self._state, guild=self, data=data) + + # temporarily add to the cache + self._channels[channel.id] = channel + return channel + + create_category_channel = create_category + + async def leave(self): + """|coro| + + Leaves the guild. + + .. note:: + + You cannot leave the guild that you own, you must delete it instead + via :meth:`delete`. + + Raises + -------- + HTTPException + Leaving the guild failed. + """ + await self._state.http.leave_guild(self.id) + + async def delete(self): + """|coro| + + Deletes the guild. You must be the guild owner to delete the + guild. + + Raises + -------- + HTTPException + Deleting the guild failed. + Forbidden + You do not have permissions to delete the guild. + """ + + await self._state.http.delete_guild(self.id) + + async def edit( + self, + name: str = MISSING, + description: str = MISSING, + features: GuildFeatures = MISSING, + icon: Optional[bytes] = MISSING, + banner: Optional[bytes] = MISSING, + splash: Optional[bytes] = MISSING, + discovery_splash: Optional[bytes] = MISSING, + region: Optional[VoiceRegion] = MISSING, + afk_channel: Optional[VoiceChannel] = MISSING, + afk_timeout: Optional[int] = MISSING, + system_channel: Optional[TextChannel] = MISSING, + system_channel_flags: Optional[SystemChannelFlags] = MISSING, + rules_channel: Optional[TextChannel] = MISSING, + public_updates_channel: Optional[TextChannel] = MISSING, + preferred_locale: Optional[Union[str, Locale]] = MISSING, + verification_level: Optional[VerificationLevel] = MISSING, + default_notifications: Optional[NotificationLevel] = MISSING, + explicit_content_filter: Optional[ContentFilter] = MISSING, + vanity_code: Optional[str] = MISSING, + owner: Optional[Union[Member, User]] = MISSING, + *, + reason: Optional[str] = None, + ) -> None: + """|coro| + + Edits the guild. + + You must have the :attr:`~Permissions.manage_guild` permission + to edit the guild. + + .. versionchanged:: 1.4 + The `rules_channel` and `public_updates_channel` keyword-only parameters were added. + + Parameters + ---------- + name: :class:`str` + The new name of the guild. + description: :class:`str` + The new description of the guild. This is only available to guilds that + contain ``PUBLIC`` in :attr:`Guild.features`. + features: :class:`GuildFeatures` + Features to enable/disable will be merged in to the current features. + See the `discord api documentation `_ + for a list of currently mutable features and the required permissions. + icon: :class:`bytes` + A :term:`py:bytes-like object` representing the icon. Only PNG/JPEG is supported. + GIF is only available to guilds that contain ``ANIMATED_ICON`` in :attr:`Guild.features`. + Could be ``None`` to denote removal of the icon. + banner: :class:`bytes` + A :term:`py:bytes-like object` representing the banner. + Could be ``None`` to denote removal of the banner. + splash: :class:`bytes` + A :term:`py:bytes-like object` representing the invite splash. + Only PNG/JPEG supported. Could be ``None`` to denote removing the + splash. This is only available to guilds that contain ``INVITE_SPLASH`` + in :attr:`Guild.features`. + discovery_splash: :class:`bytes` + A :term:`py:bytes-like object` representing the discovery splash. + Only PNG/JPEG supported. Could be ``None`` to denote removing the splash. + This is only available to guilds that contain ``DISCOVERABLE`` in :attr:`Guild.features`. + region: :class:`VoiceRegion` + Deprecated: The new region for the guild's voice communication. + afk_channel: Optional[:class:`VoiceChannel`] + The new channel that is the AFK channel. Could be ``None`` for no AFK channel. + afk_timeout: :class:`int` + The number of seconds until someone is moved to the AFK channel. + owner: :class:`Member` + The new owner of the guild to transfer ownership to. Note that you must + be owner of the guild to do this. + verification_level: :class:`VerificationLevel` + The new verification level for the guild. + default_notifications: :class:`NotificationLevel` + The new default notification level for the guild. + explicit_content_filter: :class:`ContentFilter` + The new explicit content filter for the guild. + vanity_code: :class:`str` + The new vanity code for the guild. + system_channel: Optional[:class:`TextChannel`] + The new channel that is used for the system channel. Could be ``None`` for no system channel. + system_channel_flags: :class:`SystemChannelFlags` + The new system channel settings to use with the new system channel. + preferred_locale: :class:`str` + The new preferred locale for the guild. Used as the primary language in the guild. + If set, this must be an ISO 639 code, e.g. ``en-US`` or ``ja`` or ``zh-CN``. + rules_channel: Optional[:class:`TextChannel`] + The new channel that is used for rules. This is only available to + guilds that contain ``PUBLIC`` in :attr:`Guild.features`. Could be ``None`` for no rules + channel. + public_updates_channel: Optional[:class:`TextChannel`] + The new channel that is used for public updates from Discord. This is only available to + guilds that contain ``PUBLIC`` in :attr:`Guild.features`. Could be ``None`` for no + public updates channel. + reason: Optional[:class:`str`] + The reason for editing this guild. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to edit the guild. + HTTPException + Editing the guild failed. + InvalidArgument + The image format passed in to ``icon`` is invalid. It must be + PNG or JPG. This is also raised if you are not the owner of the + guild and request an ownership transfer. + """ + + http = self._state.http + + fields = {} + + if name is not MISSING: + fields['name'] = name + + if description is not MISSING: + fields['description'] = description + + if icon is not MISSING: + fields['icon'] = utils._bytes_to_base64_data(icon) + + if banner is not MISSING: + fields['banner'] = utils._bytes_to_base64_data(banner) + + if splash is not MISSING: + fields['splash'] = utils._bytes_to_base64_data(splash) + + if features is not MISSING: + current_features = GuildFeatures(self.features) + fields['features'] = current_features.merge(features).parsed() + + if discovery_splash is not MISSING: + fields['discovery_splash'] = utils._bytes_to_base64_data(discovery_splash) + + if region is not MISSING: + import warnings + warnings.warn('The region parameter is deprecated and will be removed in a future version.', DeprecationWarning) + if not isinstance(region, VoiceRegion): + raise InvalidArgument('region field must be of type VoiceRegion') + fields['region'] = region.value + + if afk_channel is not MISSING: + fields['afk_channel_id'] = afk_channel.id if afk_channel else None + + if afk_timeout is not MISSING: + fields['afk_timeout'] = afk_timeout + + if owner is not MISSING: + fields['owner_id'] = owner.id + + if verification_level is not MISSING: + if not isinstance(verification_level, VerificationLevel): + raise InvalidArgument('verification_level field must be of type VerificationLevel') + fields['verification_level'] = verification_level.value + + if default_notifications is not MISSING: + if not isinstance(default_notifications, NotificationLevel): + raise InvalidArgument('default_notifications field must be of type NotificationLevel') + fields['default_message_notifications'] = default_notifications.value + + if explicit_content_filter is not MISSING: + if not isinstance(explicit_content_filter, ContentFilter): + raise InvalidArgument('explicit_content_filter field must be of type ContentFilter') + fields['explicit_content_filter'] = explicit_content_filter.value + + if vanity_code is not MISSING: + fields['vanity_url_code'] = vanity_code + + if system_channel is not MISSING: + fields['system_channel_id'] = system_channel.id if system_channel else None + + if system_channel_flags is not MISSING: + if not isinstance(system_channel_flags, SystemChannelFlags): + raise InvalidArgument('system_channel_flags field must be of type SystemChannelFlags') + fields['system_channel_flags'] = system_channel_flags.value + + if preferred_locale is not MISSING: + fields['preferred_locale'] = str(preferred_locale) + + if rules_channel is not MISSING: + fields['rules_channel_id'] = rules_channel.id if rules_channel else None + + if public_updates_channel is not MISSING: + fields['public_updates_channel_id'] = public_updates_channel.id if public_updates_channel else None + + await http.edit_guild(self.id, reason=reason, **fields) + + async def fetch_channels(self) -> List[GuildChannel]: + """|coro| + + Retrieves all :class:`abc.GuildChannel` that the guild has. + + .. note:: + + This method is an API call. For general usage, consider :attr:`channels` instead. + + .. versionadded:: 1.2 + + Raises + ------- + InvalidData + An unknown channel type was received from Discord. + HTTPException + Retrieving the channels failed. + + Returns + ------- + List[:class:`abc.GuildChannel`] + All channels in the guild. + """ + data = await self._state.http.get_all_guild_channels(self.id) + + def convert(d): + factory, ch_type = _channel_factory(d['type']) + if factory is None: + raise InvalidData('Unknown channel type {type} for channel ID {id}.'.format_map(d)) + channel = factory(guild=self, state=self._state, data=d) + return channel + + return [convert(d) for d in data] + + async def fetch_active_threads(self) -> List[Union[ThreadChannel, ForumPost]]: + """|coro| + + Returns all active threads and forum posts in the guild. + This includes all public and private threads as long as the current user has permissions to access them. + Threads are ordered by their id, in descending order. + + .. note:: + + This method is an API call. + + .. versionadded:: 2.0 + + Returns + ------- + List[Union[:class:`ThreadChannel`, :class:`ForumPost`]] + The active threads and forum posts in the guild. + """ + state = self._state + data = await state.http.list_active_threads(self.id) + thread_members = data['thread_members'] + + def convert(d): + thread_member = thread_members.get(int(d['id'])) + if thread_member: + d['member'] = thread_member + return _thread_factory(guild=self, state=state, data=d) + + return [convert(d) for d in data['threads']] + + def fetch_members( + self, + *, + limit: int = 1000, + after: Optional[Union[Snowflake, datetime]] = None + ) -> MemberIterator: + """Retrieves an :class:`.AsyncIterator` that enables receiving the guild's members. In order to use this, + :meth:`Intents.members` must be enabled. + + .. note:: + + This method is an API call. For general usage, consider :attr:`members` instead. + + .. versionadded:: 1.3 + + All parameters are optional. + + Parameters + ---------- + limit: Optional[:class:`int`] + The number of members to retrieve. Defaults to 1000. + Pass ``None`` to fetch all members. Note that this is potentially slow. + after: Optional[Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`]] + Retrieve members after this date or object. + If a date is provided it must be a timezone-naive datetime representing UTC time. + + Raises + ------ + ClientException + The members intent is not enabled. + HTTPException + Getting the members failed. + + Yields + ------ + :class:`.Member` + The member with the member data parsed. + + Examples + -------- + + Usage :: + + async for member in guild.fetch_members(limit=150): + print(member.name) + + Flattening into a list :: + + members = await guild.fetch_members(limit=150).flatten() + # members is now a list of Member... + """ + + if not self._state._intents.members: + raise ClientException('Intents.members must be enabled to use this.') + + return MemberIterator(self, limit=limit, after=after) + + async def fetch_member(self, member_id: id) -> Member: + """|coro| + + Retrieves a :class:`Member` from a guild ID, and a member ID. + + .. note:: + + This method is an API call. If you have :attr:`Intents.members` and member cache enabled, consider :meth:`get_member` instead. + + Parameters + ----------- + member_id: :class:`int` + The member's ID to fetch from. + + Raises + ------- + Forbidden + You do not have access to the guild. + HTTPException + Fetching the member failed. + + Returns + -------- + :class:`Member` + The member from the member ID. + """ + data = await self._state.http.get_member(self.id, member_id) + return Member(data=data, state=self._state, guild=self) + + async def fetch_ban(self, user: Snowflake) -> BanEntry: + """|coro| + + Retrieves the :class:`BanEntry` for a user. + + You must have the :attr:`~Permissions.ban_members` permission + to get this information. + + Parameters + ----------- + user: :class:`abc.Snowflake` + The user to get ban information from. + + Raises + ------ + Forbidden + You do not have proper permissions to get the information. + NotFound + This user is not banned. + HTTPException + An error occurred while fetching the information. + + Returns + ------- + :class:`BanEntry` + The :class:`BanEntry` object for the specified user. + """ + data = await self._state.http.get_ban(user.id, self.id) # type: ignore + return BanEntry( + user=User(state=self._state, data=data['user']), + reason=data['reason'] + ) + + def bans( + self, + limit: Optional[int] = None, + before: Optional[Union['Snowflake', datetime]] = None, + after: Optional[Union['Snowflake', datetime]] = None + ) -> BanIterator: + """Retrieves an :class:`.AsyncIterator` that enables receiving the guild's bans. + + You must have the :attr:`~Permissions.ban_members` permission + to get this information. + + .. note:: + + This method is an API call. Use it careful. + + All parameters are optional. + + Parameters + ---------- + limit: Optional[:class:`int`] + The number of bans to retrieve. Defaults to all. + Note that this is potentially slow. + before: Optional[Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`]] + Retrieve members before this date or object. + If a date is provided it must be a timezone-naive datetime representing UTC time. + after: Optional[Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`]] + Retrieve members after this date or object. + If a date is provided it must be a timezone-naive datetime representing UTC time. + + Raises + ------ + Forbidden + You do not have proper permissions to get the information. + HTTPException + Getting the bans failed. + + Yields + ------ + :class:`.BanEntry` + The ban entry containing the user and an optional reason. + + Examples + -------- + + Usage :: + + async for ban_entry in guild.bans(limit=150): + print(ban_entry.user) + + Flattening into a list :: + + ban_entries = await guild.bans(limit=150).flatten() + # ban_entries is now a list of BanEntry... + """ + return BanIterator(self, limit=limit, before=before, after=after) + + @overload + async def prune_members( + self, + *, + days: int, + roles: Optional[List[Snowflake]], + reason: Optional[str] + ) -> int: + ... + + @overload + async def prune_members( + self, + *, + days: int, + compute_prune_count: Literal[True], + roles: Optional[List[Snowflake]], + reason: Optional[str] + ) -> int: + ... + + @overload + async def prune_members( + self, + *, + days: int, + compute_prune_count: Literal[False], + roles: Optional[List[Snowflake]], + reason: Optional[str] + ) -> None: + ... + + async def prune_members( + self, + *, + days: int, + compute_prune_count: bool = True, + roles: List[Snowflake] = None, + reason: Optional[str] = None + ) -> Optional[int]: + r"""|coro| + + Prunes the guild from its inactive members. + + The inactive members are denoted if they have not logged on in + ``days`` number of days, and they have no roles. + + You must have the :attr:`~Permissions.kick_members` permission + to use this. + + To check how many members you would prune without actually pruning, + see the :meth:`estimate_pruned_members` function. + + To prune members that have specific roles see the ``roles`` parameter. + + .. versionchanged:: 1.4 + The ``roles`` keyword-only parameter was added. + + Parameters + ----------- + days: :class:`int` + The number of days before counting as inactive. + reason: Optional[:class:`str`] + The reason for doing this action. Shows up on the audit log. + compute_prune_count: :class:`bool` + Whether to compute the prune count. This defaults to ``True`` + which makes it prone to timeouts in very large guilds. In order + to prevent timeouts, you must set this to ``False``. If this is + set to ``False``\, then this function will always return ``None``. + roles: Optional[List[:class:`abc.Snowflake`]] + A list of :class:`abc.Snowflake` that represent roles to include in the pruning process. If a member + has a role that is not specified, they'll be excluded. + + Raises + ------- + Forbidden + You do not have permissions to prune members. + HTTPException + An error occurred while pruning members. + InvalidArgument + An integer was not passed for ``days``. + + Returns + --------- + Optional[:class:`int`] + The number of members pruned. If ``compute_prune_count`` is ``False`` + then this returns ``None``. + """ + + if not isinstance(days, int): + raise InvalidArgument('Expected int for ``days``, received {0.__class__.__name__} instead.'.format(days)) + + if roles: + roles = [str(role.id) for role in roles] # type: ignore + + data = await self._state.http.prune_members( + self.id, + days, + compute_prune_count=compute_prune_count, + roles=roles, + reason=reason + ) + return data['pruned'] + + async def templates(self) -> List[Template]: + """|coro| + + Gets the list of templates from this guild. + + Requires :attr:`~.Permissions.manage_guild` permissions. + + .. versionadded:: 1.7 + + Raises + ------- + Forbidden + You don't have permissions to get the templates. + + Returns + -------- + List[:class:`Template`] + The templates for this guild. + """ + from .template import Template + data = await self._state.http.guild_templates(self.id) + return [Template(data=d, state=self._state) for d in data] + + async def webhooks(self) -> List[Webhook]: + """|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. + """ + + 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] + + async def estimate_pruned_members(self, *, days: int, roles: Optional[List[Snowflake]] = None) -> int: + """|coro| + + Similar to :meth:`prune_members` except instead of actually + pruning members, it returns how many members it would prune + from the guild had it been called. + + Parameters + ----------- + days: :class:`int` + The number of days before counting as inactive. + roles: Optional[List[:class:`abc.Snowflake`]] + A list of :class:`abc.Snowflake` that represent roles to include in the estimate. If a member + has a role that is not specified, they'll be excluded. + + .. versionadded:: 1.7 + + Raises + ------- + Forbidden + You do not have permissions to prune members. + HTTPException + An error occurred while fetching the prune members estimate. + InvalidArgument + An integer was not passed for ``days``. + + Returns + --------- + :class:`int` + The number of members estimated to be pruned. + """ + + if not isinstance(days, int): + raise InvalidArgument('Expected int for ``days``, received {0.__class__.__name__} instead.'.format(days)) + + if roles: + roles = [str(role.id) for role in roles] # type: ignore + + data = await self._state.http.estimate_pruned_members(self.id, days, roles) + return data['pruned'] + + async def invites(self) -> List[Invite]: + """|coro| + + Returns a list of all active instant invites from the guild. + + You must have the :attr:`~Permissions.manage_guild` permission to get + this information. + + Raises + ------- + Forbidden + You do not have proper permissions to get the information. + HTTPException + An error occurred while fetching the information. + + Returns + ------- + List[:class:`Invite`] + The list of invites that are currently active. + """ + + data = await self._state.http.invites_from(self.id) + result = [] + for invite in data: + channel = self.get_channel(int(invite['channel']['id'])) + invite['channel'] = channel + invite['guild'] = self + result.append(Invite(state=self._state, data=invite)) + + return result + + async def create_template(self, *, name: str, description: Optional[str] = None) -> Template: + """|coro| + + Creates a template for the guild. + + You must have the :attr:`~Permissions.manage_guild` permission to + do this. + + .. versionadded:: 1.7 + + Parameters + ----------- + name: :class:`str` + The name of the template. + description: Optional[:class:`str`] + The description of the template. + """ + from .template import Template + + payload = { + 'name': name + } + + if description: + payload['description'] = description + + data = await self._state.http.create_template(self.id, payload) + + return Template(state=self._state, data=data) + + async def create_integration(self, *, type: IntegrationType, id: int): + """|coro| + + Attaches an integration to the guild. + + You must have the :attr:`~Permissions.manage_guild` permission to + do this. + + .. versionadded:: 1.4 + + Parameters + ----------- + type: :class:`str` + The integration type (e.g. Twitch). + id: :class:`int` + The integration ID. + + Raises + ------- + Forbidden + You do not have permission to create the integration. + HTTPException + The account could not be found. + """ + await self._state.http.create_integration(self.id, type, id) + + async def integrations(self) -> List[Integration]: + """|coro| + + Returns a list of all integrations attached to the guild. + You must have the :attr:`~Permissions.manage_guild` permission to + do this. + .. versionadded:: 1.4 + Raises + ------- + Forbidden + You do not have permission to create the integration. + HTTPException + Fetching the integrations failed. + Returns + -------- + List[:class:`Integration`] + The list of integrations that are attached to the guild. + """ + data = await self._state.http.get_all_integrations(self.id) + + def convert(d): + factory, itype = _integration_factory(d['type']) + if factory is None: + raise InvalidData('Unknown integration type {type!r} for integration ID {id}'.format_map(d)) + return factory(guild=self, data=d) + + return [convert(d) for d in data] + + async def fetch_emojis(self) -> List[Emoji]: + r"""|coro| + + Retrieves all custom :class:`Emoji`\s from the guild. + + .. note:: + + This method is an API call. For general usage, consider :attr:`emojis` instead. + + Raises + --------- + HTTPException + An error occurred fetching the emojis. + + Returns + -------- + List[:class:`Emoji`] + The retrieved emojis. + """ + data = await self._state.http.get_all_custom_emojis(self.id) + return [Emoji(guild=self, state=self._state, data=d) for d in data] + + async def fetch_emoji(self, emoji_id: int) -> Emoji: + """|coro| + + Retrieves a custom :class:`Emoji` from the guild. + + .. note:: + + This method is an API call. + For general usage, consider iterating over :attr:`emojis` instead. + + Parameters + ------------- + emoji_id: :class:`int` + The emoji's ID. + + Raises + --------- + NotFound + The emoji requested could not be found. + HTTPException + An error occurred fetching the emoji. + + Returns + -------- + :class:`Emoji` + The retrieved emoji. + """ + data = await self._state.http.get_custom_emoji(self.id, emoji_id) + return Emoji(guild=self, state=self._state, data=data) + + async def create_custom_emoji( + self, + *, + name: str, + image: bytes, + roles: Optional[List[Snowflake]] = None, + reason: Optional[str] = None + ) -> Emoji: + r"""|coro| + + Creates a custom :class:`Emoji` for the guild. + + There is currently a limit of 50 static and animated emojis respectively per guild, + unless the guild has the ``MORE_EMOJI`` feature which extends the limit to 200. + + You must have the :attr:`~Permissions.manage_emojis` permission to + do this. + + Parameters + ----------- + name: :class:`str` + The emoji name. Must be at least 2 characters. + image: :class:`bytes` + The :term:`py:bytes-like object` representing the image data to use. + Only JPG, PNG and GIF images are supported. + roles: Optional[List[:class:`Role`]] + A :class:`list` of :class:`Role`\s that can use this emoji. Leave empty to make it available to everyone. + reason: Optional[:class:`str`] + The reason for creating this emoji. Shows up on the audit log. + + Raises + ------- + Forbidden + You are not allowed to create emojis. + HTTPException + An error occurred creating an emoji. + + Returns + -------- + :class:`Emoji` + The created emoji. + """ + + img = utils._bytes_to_base64_data(image) + if roles: + roles = [role.id for role in roles] # type: ignore + data = await self._state.http.create_custom_emoji(self.id, name, img, roles=roles, reason=reason) + return self._state.store_emoji(self, data) + + async def fetch_roles(self) -> List[Role]: + """|coro| + + Retrieves all :class:`Role` that the guild has. + + .. note:: + + This method is an API call. For general usage, consider :attr:`roles` instead. + + .. versionadded:: 1.3 + + Raises + ------- + HTTPException + Retrieving the roles failed. + + Returns + ------- + List[:class:`Role`] + All roles in the guild. + """ + data = await self._state.http.get_roles(self.id) + return [Role(guild=self, state=self._state, data=d) for d in data] + + @overload + async def create_role( + self, + *, + name: str = MISSING, + permissions: Permissions = MISSING, + color: Union[Colour, int] = MISSING, + colour: Union[Colour, int] = MISSING, + hoist: bool = False, + mentionable: bool = False, + reason: Optional[str] = None + ) -> Role: + ... + + @overload + async def create_role( + self, + *, + icon: bytes, + name: str = MISSING, + permissions: Permissions = MISSING, + color: Union[Colour, int] = MISSING, + colour: Union[Colour, int] = MISSING, + hoist: bool = False, + mentionable: bool = False, + reason: Optional[str] = None + ) -> Role: + ... + + @overload + async def create_role( + self, + *, + unicode_emoji: str, + name: str = MISSING, + permissions: Permissions = MISSING, + color: Union[Colour, int] = MISSING, + colour: Union[Colour, int] = MISSING, + hoist: bool = False, + mentionable: bool = False, + reason: Optional[str] = None + ) -> Role: + ... + + async def create_role( + self, + *, + name: str = MISSING, + permissions: Permissions = MISSING, + color: Union[Colour, int] = MISSING, + colour: Union[Colour, int] = MISSING, + hoist: bool = False, + mentionable: bool = False, + icon: Optional[bytes] = None, + unicode_emoji: Optional[str] = None, + reason: Optional[str] = None + ) -> Role: + """|coro| + + Creates a :class:`Role` for the guild. + + All fields are optional. + + You must have the :attr:`~Permissions.manage_roles` permission to + do this. + + .. versionchanged:: 1.6 + Can now pass ``int`` to ``colour`` keyword-only parameter. + + .. versionadded:: 2.0 + Added the ``icon`` and ``unicode_emoji`` keyword-only parameters. + + .. note:: + The ``icon`` and ``unicode_emoji`` can't be used together. + Both of them can only be used when ``ROLE_ICONS`` is in the guild :meth:`~Guild.features`. + + Parameters + ----------- + name: :class:`str` + The role name. Defaults to 'new role'. + permissions: :class:`Permissions` + The permissions to have. Defaults to no permissions. + color: Union[:class:`Colour`, :class:`int`] + The colour for the role. Defaults to :meth:`Colour.default`. + colour: Union[:class:`Colour`, :class:`int`] + The colour for the role. Defaults to :meth:`Colour.default`. + This is aliased to ``color`` as well. + hoist: :class:`bool` + Indicates if the role should be shown separately in the member list. + Defaults to ``False``. + mentionable: :class:`bool` + Indicates if the role should be mentionable by others. + Defaults to ``False``. + icon: Optional[:class:`bytes`] + The :term:`py:bytes-like object` representing the image data to use as the role :attr:`~Role.icon`. + unicode_emoji: Optional[:class:`str`] + The unicode emoji to use as the role :attr:`~Role.unicode_emoji`. + reason: Optional[:class:`str`] + The reason for creating this role. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to create the role. + HTTPException + Creating the role failed. + InvalidArgument + Both ``icon`` and ``unicode_emoji`` were passed. + + Returns + -------- + :class:`Role` + The newly created role. + """ + + fields = {} + + if name is not MISSING: + fields['name'] = name + + if permissions is not MISSING: + fields['permissions'] = permissions.value + + color = color if color is not MISSING else colour + + if color is not MISSING: + if isinstance(color, Colour): + color = color.value + fields['color'] = color + + if hoist is not MISSING: + fields['hoist'] = hoist + + if mentionable is not MISSING: + fields['mentionable'] = mentionable + + if icon is not None: + if unicode_emoji is not None: + raise InvalidArgument('icon and unicode_emoji cannot be used together.') + fields['icon'] = utils._bytes_to_base64_data(icon) + + elif unicode_emoji is not None: + fields['unicode_emoji'] = str(unicode_emoji) + + data = await self._state.http.create_role(self.id, reason=reason, fields=fields) + role = Role(guild=self, data=data, state=self._state) + self._add_role(role) + # TODO: add to cache + return role + + async def edit_role_positions(self, positions: Dict[Role, int], *, reason: Optional[str] = None) -> List[Role]: + """|coro| + + Bulk edits a list of :class:`Role` in the guild. + + You must have the :attr:`~Permissions.manage_roles` permission to + do this. + + .. versionadded:: 1.4 + + Example: + + .. code-block:: python3 + + positions = { + bots_role: 1, # penultimate role + tester_role: 2, + admin_role: 6 + } + + await guild.edit_role_positions(positions=positions) + + Parameters + ----------- + positions + A :class:`dict` of :class:`Role` to :class:`int` to change the positions + of each given role. + reason: Optional[:class:`str`] + The reason for editing the role positions. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to move the roles. + HTTPException + Moving the roles failed. + InvalidArgument + An invalid keyword argument was given. + + Returns + -------- + List[:class:`Role`] + A list of all the roles in the guild. + """ + if not isinstance(positions, dict): + raise InvalidArgument('positions parameter expects a dict.') + + role_positions = [] + for role, position in positions.items(): + + payload = { + 'id': role.id, + 'position': position + } + + role_positions.append(payload) + + data = await self._state.http.move_role_position(self.id, role_positions, reason=reason) + roles = [] + for d in data: + role = Role(guild=self, data=d, state=self._state) + roles.append(role) + self._roles[role.id] = role + + return roles + + async def kick(self, user: Snowflake, *, reason: Optional[str] = None): + """|coro| + + Kicks a user from the guild. + + The user must meet the :class:`abc.Snowflake` abc. + + You must have the :attr:`~Permissions.kick_members` permission to + do this. + + Parameters + ----------- + user: :class:`abc.Snowflake` + The user to kick from their guild. + reason: Optional[:class:`str`] + The reason the user got kicked. + + Raises + ------- + Forbidden + You do not have the proper permissions to kick. + HTTPException + Kicking failed. + """ + await self._state.http.kick(user.id, self.id, reason=reason) # type: ignore + + async def ban( + self, + user: Snowflake, + *, + delete_message_days: Optional[int] = None, + delete_message_seconds: Optional[int] = 0, + reason: Optional[str] = None + ) -> None: + """|coro| + + Bans a user from the guild. + + The user must meet the :class:`abc.Snowflake` abc. + + You must have the :attr:`~Permissions.ban_members` permission to + do this. + + Parameters + ----------- + user: :class:`abc.Snowflake` + The user to ban from their guild. + delete_message_days: :class:`int` + The number of days worth of messages to delete from the user + in the guild. The minimum is 0 and the maximum is 7. + + .. deprecated:: 2.0 + delete_message_seconds: :class:`int` + The number of days worth of messages to delete from the user + in the guild. The minimum is 0 and the maximum is 604800 (7 days). + + .. versionadded:: 2.0 + reason: Optional[:class:`str`] + The reason the user got banned. + + Raises + ------- + Forbidden + You do not have the proper permissions to ban. + HTTPException + Banning failed. + """ + if delete_message_days is not None: + import warnings + warnings.warn( + 'delete_message_days is deprecated, use delete_message_seconds instead.', + DeprecationWarning, + stacklevel=2 + ) + delete_message_seconds = delete_message_days * 86400 + await self._state.http.ban(user.id, self.id, delete_message_seconds, reason=reason) # type: ignore + + async def unban(self, user: Snowflake, *, reason: Optional[str] = None): + """|coro| + + Unbans a user from the guild. + + The user must meet the :class:`abc.Snowflake` abc. + + You must have the :attr:`~Permissions.ban_members` permission to + do this. + + Parameters + ----------- + user: :class:`abc.Snowflake` + The user to unban. + reason: Optional[:class:`str`] + The reason for doing this action. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have the proper permissions to unban. + HTTPException + Unbanning failed. + """ + await self._state.http.unban(user.id, self.id, reason=reason) # type: ignore + + async def vanity_invite(self) -> Invite: + """|coro| + + Returns the guild's special vanity invite. + + The guild must have ``VANITY_URL`` in :attr:`~Guild.features`. + + You must have the :attr:`~Permissions.manage_guild` permission to use + this as well. + + Raises + ------- + Forbidden + You do not have the proper permissions to get this. + HTTPException + Retrieving the vanity invite failed. + + Returns + -------- + :class:`Invite` + The special vanity invite. + """ + + # we start with { code: abc } + payload = await self._state.http.get_vanity_code(self.id) + + # get the vanity URL channel since default channels aren't + # reliable or a thing anymore + data = await self._state.http.get_invite(payload['code']) + + payload['guild'] = self + payload['channel'] = self.get_channel(int(data['channel']['id'])) + payload['revoked'] = False + payload['temporary'] = False + payload['max_uses'] = 0 + payload['max_age'] = 0 + return Invite(state=self._state, data=payload) + + def audit_logs( + self, + *, + limit: int = 100, + before: Optional[Union[Snowflake, datetime]] = None, + after: Optional[Union[Snowflake, datetime]] = None, + oldest_first: Optional[bool] = None, + user: Optional[Snowflake] = None, + action: Optional[AuditLogAction] = None + ): + """Returns an :class:`AsyncIterator` that enables receiving the guild's audit logs. + + You must have the :attr:`~Permissions.view_audit_log` permission to use this. + + Examples + ---------- + + Getting the first 100 entries: :: + + async for entry in guild.audit_logs(limit=100): + print('{0.user} did {0.action} to {0.target}'.format(entry)) + + Getting entries for a specific action: :: + + async for entry in guild.audit_logs(action=discord.AuditLogAction.ban): + print('{0.user} banned {0.target}'.format(entry)) + + Getting entries made by a specific user: :: + + entries = await guild.audit_logs(limit=None, user=guild.me).flatten() + await channel.send('I made {} moderation actions.'.format(len(entries))) + + Parameters + ----------- + limit: Optional[:class:`int`] + The number of entries to retrieve. If ``None`` retrieve all entries. + before: Union[:class:`abc.Snowflake`, :class:`datetime.datetime`] + Retrieve entries before this date or entry. + If a date is provided it must be a timezone-naive datetime representing UTC time. + after: Union[:class:`abc.Snowflake`, :class:`datetime.datetime`] + Retrieve entries after this date or entry. + If a date is provided it must be a timezone-naive datetime representing UTC time. + oldest_first: :class:`bool` + If set to ``True``, return entries in oldest->newest order. Defaults to ``True`` if + ``after`` is specified, otherwise ``False``. + user: :class:`abc.Snowflake` + The moderator to filter entries from. + action: :class:`AuditLogAction` + The action to filter with. + + Raises + ------- + Forbidden + You are not allowed to fetch audit logs + HTTPException + An error occurred while fetching the audit logs. + + Yields + -------- + :class:`AuditLogEntry` + The audit log entry. + """ + if user: + user = user.id # type: ignore + + if action: + action = action.value + + return AuditLogIterator( + self, + before=before, + after=after, + limit=limit, + oldest_first=oldest_first, + user_id=user, + action_type=action + ) + + async def widget(self) -> Widget: + """|coro| + + Returns the widget of the guild. + + .. note:: + + The guild must have the widget enabled to get this information. + + Raises + ------- + Forbidden + The widget for this guild is disabled. + HTTPException + Retrieving the widget failed. + + Returns + -------- + :class:`Widget` + The guild's widget. + """ + data = await self._state.http.get_widget(self.id) + + return Widget(state=self._state, data=data) + + async def chunk(self, *, cache: bool = True): + """|coro| + + Requests all members that belong to this guild. In order to use this, + :meth:`Intents.members` must be enabled. + + This is a websocket operation and can be slow. + + .. versionadded:: 1.5 + + Parameters + ----------- + cache: :class:`bool` + Whether to cache the members as well. + + Raises + ------- + ClientException + The members intent is not enabled. + """ + + if not self._state._intents.members: + raise ClientException('Intents.members must be enabled to use this.') + + if not self._state.is_guild_evicted(self): + return await self._state.chunk_guild(self, cache=cache) + + async def query_members( + self, + query: Optional[str] = None, + *, + limit: int = 5, + user_ids: Optional[List[int]] = None, + presences: bool = False, + cache: bool = True + ) -> List[Member]: + """|coro| + + Request members that belong to this guild whose username starts with + the query given. + + This is a websocket operation and can be slow. + + .. versionadded:: 1.3 + + Parameters + ----------- + query: Optional[:class:`str`] + The string that the username's start with. + limit: :class:`int` + The maximum number of members to send back. This must be + a number between 5 and 100. + presences: :class:`bool` + Whether to request for presences to be provided. This defaults + to ``False``. + + .. versionadded:: 1.6 + + cache: :class:`bool` + Whether to cache the members internally. This makes operations + such as :meth:`get_member` work for those that matched. + user_ids: Optional[List[:class:`int`]] + List of user IDs to search for. If the user ID is not in the guild then it won't be returned. + + .. versionadded:: 1.4 + + + Raises + ------- + asyncio.TimeoutError + The query timed out waiting for the members. + ValueError + Invalid parameters were passed to the function + ClientException + The presences intent is not enabled. + + Returns + -------- + List[:class:`Member`] + The list of members that have matched the query. + """ + + if presences and not self._state._intents.presences: + raise ClientException('Intents.presences must be enabled to use this.') + + if query is None: + if query == '': + raise ValueError('Cannot pass empty query string.') + + if user_ids is None: + raise ValueError('Must pass either query or user_ids') + + if user_ids is not None and query is not None: + raise ValueError('Cannot pass both query and user_ids') + + if user_ids is not None and not user_ids: + raise ValueError('user_ids must contain at least 1 value') + + limit = min(100, limit or 5) + return await self._state.query_members( + self, + query=query, + limit=limit, + user_ids=user_ids, + presences=presences, + cache=cache + ) + + async def change_voice_state( + self, + *, + channel: Optional[Union[VoiceChannel, StageChannel]], + self_mute: bool = False, + self_deaf: bool = False + ): + """|coro| + + Changes client's voice state in the guild. + + .. versionadded:: 1.4 + + Parameters + ----------- + channel: Optional[:class:`VoiceChannel`] + Channel the client wants to join. Use ``None`` to disconnect. + self_mute: :class:`bool` + Indicates if the client should be self-muted. + self_deaf: :class:`bool` + Indicates if the client should be self-deafened. + """ + ws = self._state._get_websocket(self.id) + channel_id = channel.id if channel else None + await ws.voice_state(self.id, channel_id, self_mute, self_deaf) + + async def create_sticker( + self, + name: str, + file: Union[UploadFile, PathLike[str], PathLike[bytes]], + tags: Union[str, List[str]], + description: Optional[str] = None, + *, + reason: Optional[str] = None + ) -> GuildSticker: + """|coro| + + Create a new sticker for the guild. + + Requires the ``MANAGE_EMOJIS_AND_STICKERS`` permission. + + + Parameters + ---------- + name: :class:`str` + The name of the sticker (2-30 characters). + tags: Union[:class:`str`, List[:class:`str`]] + Autocomplete/suggestion tags for the sticker separated by ``,`` or in a list. (max 200 characters). + description: Optional[:class:`str`] + The description of the sticker (None or 2-100 characters). + file: Union[:class:`UploadFile`, :class:`str`] + The sticker file to upload or the path to it, must be a PNG, APNG, GIF or Lottie JSON file, max 500 KB + reason: Optional[:class:`str`] + The reason for creating the sticker., shows up in the audit-log. + + Raises + ------ + discord.Forbidden: + You don't have the permissions to upload stickers in this guild. + discord.HTTPException: + Creating the sticker failed. + ValueError + Any of name, description or tags is too short/long. + + Return + ------ + :class:`GuildSticker` + The new GuildSticker created on success. + """ + if 2 > len(name) > 30: + raise ValueError(f'The name must be between 2 and 30 characters in length; got {len(name)}.') + if not isinstance(file, UploadFile): + file = UploadFile(file) + if description is not None and 2 > len(description) > 100: + raise ValueError(f'The description must be between 2 and 100 characters in length; got {len(description)}.') + if isinstance(tags, list): + tags = ','.join(tags) + if len(tags) > 200: + raise ValueError(f'The tags could be max. 200 characters in length; {len(tags)}.') + try: + data = await self._state.http.create_guild_sticker( + guild_id=self.id, + name=name, + description=description, + tags=tags, + file=file, + reason=reason + ) + finally: + file.close() + return self._state.store_sticker(data) + + async def fetch_events( + self, + with_user_count: bool = True + ) -> Optional[List[GuildScheduledEvent]]: + """|coro| + + Retrieves a :class:`list` of scheduled events the guild has. + + .. note:: + + This method is an API call. + For general usage, consider iterating over :attr:`events` instead. + + Parameters + ---------- + with_user_count: :class:`bool` + Whether to include the number of interested users the event has, default ``True``. + + Returns + ------- + Optional[List[:class:`GuildScheduledEvent`]] + A list of scheduled events the guild has. + """ + data = self._state.http.get_guild_events(guild_id=self.id, with_user_count=with_user_count) + [self._add_event(GuildScheduledEvent(state=self._state, guild=self, data=d)) for d in data] + return self.events + + async def fetch_event( + self, + id: int, + with_user_count: bool = True + ) -> Optional[GuildScheduledEvent]: + """|coro| + + Fetches the :class:`GuildScheduledEvent` with the given id. + + Parameters + ---------- + id: :class:`int` + The id of the event to fetch. + with_user_count: :class:`bool` + Whether to include the number of interested users the event has, default ``True``. + + Returns + ------- + Optional[:class:`GuildScheduledEvent`] + The event on success. + """ + data = await self._state.http.get_guild_event(guild_id=self.id, event_id=id, with_user_count=with_user_count) + if data: + event = GuildScheduledEvent(state=self._state, guild=self, data=data) + self._add_event(event) + return event + + async def create_scheduled_event( + self, + name: str, + entity_type: EventEntityType, + start_time: datetime, + end_time: Optional[datetime] = None, + channel: Optional[Union[StageChannel, VoiceChannel]] = None, + description: Optional[str] = None, + location: Optional[str] = None, + cover_image: Optional[bytes] = None, + *, + reason: Optional[str] = None + ) -> GuildScheduledEvent: + """|coro| + + Schedules a new Event in this guild. Requires ``MANAGE_EVENTS`` at least in the :attr:`channel` + or in the entire guild if :attr:`~GuildScheduledEvent.type` is :attr:`~EventType.external`. + + Parameters + ---------- + name: :class:`str` + The name of the scheduled event. 1-100 characters long. + entity_type: :class:`EventEntityType` + The entity_type of the scheduled event. + + .. important:: + :attr:`end_time` and :attr:`location` must be provided if entity_type is :class:`~EventEntityType.external`, otherwise :attr:`channel` + + start_time: :class:`datetime.datetime` + The time when the event will start. Must be a valid date in the future. + end_time: Optional[:class:`datetime.datetime`] + The time when the event will end. Must be a valid date in the future. + + .. important:: + If :attr:`entity_type` is :class:`~EventEntityType.external` this must be provided. + channel: Optional[Union[:class:`StageChannel`, :class:`VoiceChannel`]] + The channel in which the event takes place. + Must be provided if :attr:`entity_type` is :class:`~EventEntityType.stage` or :class:`~EventEntityType.voice`. + description: Optional[:class:`str`] + The description of the scheduled event. 1-1000 characters long. + location: Optional[:class:`str`] + The location where the event will take place. 1-100 characters long. + + .. important:: + This must be provided if :attr:`~GuildScheduledEvent.entity_type` is :attr:`~EventEntityType.external` + + cover_image: Optional[:class:`bytes`] + The cover image of the scheduled event. + reason: Optional[:class:`str`] + The reason for scheduling the event, shows up in the audit-log. + + Returns + ------- + :class:`~discord.GuildScheduledEvent` + The scheduled event on success. + + Raises + ------ + TypeError: + Any parameter is of wrong type. + errors.InvalidArgument: + entity_type is :attr:`~EventEntityType.stage` or :attr:`~EventEntityType.voice` but ``channel`` is not provided + or :attr:`~EventEntityType.external` but no ``location`` and/or ``end_time`` provided. + ValueError: + The value of any parameter is invalid. (e.g. to long/short) + errors.Forbidden: + You don't have permissions to schedule the event. + discord.HTTPException: + Scheduling the event failed. + """ + + fields: Dict[str, Any] = {} + + if not isinstance(entity_type, EventEntityType): + entity_type = try_enum(EventEntityType, entity_type) + if not isinstance(entity_type, EventEntityType): + raise ValueError('entity_type must be a valid EventEntityType.') + + if 1 > len(name) > 100: + raise ValueError(f'The length of the name must be between 1 and 100 characters long; got {len(name)}.') + fields['name'] = str(name) + + if int(entity_type) == 3 and not location: + raise InvalidArgument('location must be provided if type is EventEntityType.external') + elif int(entity_type) != 3 and not channel: + raise InvalidArgument('channel must be provided if type is EventEntityType.stage or EventEntityType.voice.') + + fields['entity_type'] = int(entity_type) + + if channel is not None and not entity_type.external: + if not isinstance(channel, (VoiceChannel, StageChannel)): + raise TypeError( + f'The channel must be a StageChannel or VoiceChannel object, not {channel.__class__.__name__}.' + ) + if int(entity_type) not in (1, 2): + entity_type = {StageChannel: EventEntityType.stage, VoiceChannel: EventEntityType.voice}.get(type(channel)) + fields['entity_type'] = entity_type.value + fields['channel_id'] = str(channel.id) + fields['entity_metadata'] = None + + if description is not None: + if 1 > len(description) > 1000: + raise ValueError( + f'The length of the description must be between 1 and 1000 characters long; got {len(description)}.' + ) + fields['description'] = description + + if location is not None: + if 1 > len(location) > 100: + raise ValueError( + f'The length of the location must be between 1 and 100 characters long; got {len(location)}.' + ) + if not entity_type.external: + entity_type = EventEntityType.external + fields['entity_type'] = entity_type.value + fields['channel_id'] = None + fields['entity_metadata'] = {'location': location} + + if entity_type.external and not end_time: + raise ValueError('end_time is required for external events.') + + if not isinstance(start_time, datetime): + raise TypeError(f'The start_time must be a datetime.datetime object, not {start_time.__class__.__name__}.') + elif start_time < datetime.utcnow(): + raise ValueError(f'The start_time could not be in the past.') + + fields['scheduled_start_time'] = start_time.isoformat() + + if end_time: + if not isinstance(end_time, datetime): + raise TypeError(f'The end_time must be a datetime.datetime object, not {end_time.__class__.__name__}.') + elif end_time < datetime.utcnow(): + raise ValueError(f'The end_time could not be in the past.') + + fields['scheduled_end_time'] = end_time.isoformat() + + fields['privacy_level'] = 2 + + if cover_image: + if not isinstance(cover_image, bytes): + raise ValueError(f'cover_image must be of type bytes, not {cover_image.__class__.__name__}') + as_base64 = utils._bytes_to_base64_data(cover_image) + fields['image'] = as_base64 + + data = await self._state.http.create_guild_event(guild_id=self.id, fields=fields, reason=reason) + event = GuildScheduledEvent(state=self._state, guild=self, data=data) + self._add_event(event) + return event + + async def _register_application_command(self, command): + client = self._state._get_client() + try: + client._guild_specific_application_commands[self.id] + except KeyError: + client._guild_specific_application_commands[self.id] = { + 'chat_input': {}, + 'message': {}, + 'user': {} + } + client._guild_specific_application_commands[self.id][command.type.name][command.name] = command + data = command.to_dict() + command_data = await self._state.http.create_application_command(self._state._get_client().app.id, data=data, + guild_id=self.id + ) + command._fill_data(command_data) + client._application_commands[command.id] = self._application_commands[command.id] = command + return command + + async def add_slash_command( + self, + name: str, + name_localizations: Optional[Localizations] = Localizations(), + description: str = 'No description', + description_localizations: Optional[Localizations] = Localizations(), + is_nsfw: bool = False, + default_required_permissions: Optional['Permissions'] = None, + options: Optional[List[Union['SubCommandGroup', 'SubCommand', 'SlashCommandOption']]] = [], + connector: Optional[Dict[str, str]] = {}, + func: Awaitable = default_callback, + cog: Optional[Cog] = None + ) -> SlashCommand: + command = SlashCommand( + name=name, + name_localizations=name_localizations, + description=description, + description_localizations=description_localizations, + is_nsfw=is_nsfw, + default_member_permissions=default_required_permissions, + options=options, + connector=connector, + func=func, + guild_id=self.id, + state=self._state, + cog=cog + ) + return await self._register_application_command(command) + + async def add_message_command( + self, + name: str, + name_localizations: Optional[Localizations] = Localizations(), + description: str = 'No description', + description_localizations: Optional[Localizations] = Localizations(), + is_nsfw: bool = False, + default_required_permissions: Optional[Permissions] = None, + func: Awaitable = default_callback, + cog: Optional[Cog] = None + ) -> MessageCommand: + command = MessageCommand( + name=name, + name_localizations=name_localizations, + description=description, + description_localizations=description_localizations, + is_nsfw=is_nsfw, + default_member_permissions=default_required_permissions, + func=func, + guild_id=self.id, + state=self._state, + cog=cog + ) + return await self._register_application_command(command) + + async def add_user_command( + self, + name: str, + name_localizations: Optional[Localizations] = Localizations(), + description: str = 'No description', + description_localizations: Optional[Localizations] = Localizations(), + is_nsfw: bool = False, + default_required_permissions: Optional[Permissions] = None, + func: Awaitable = default_callback, + cog: Optional[Cog] = None + ) -> UserCommand: + command = UserCommand( + name=name, + name_localizations=name_localizations, + description=description, + description_localizations=description_localizations, + is_nsfw=is_nsfw, + default_member_permissions=default_required_permissions, + func=func, + guild_id=self.id, + state=self._state, + cog=cog + ) + return await self._register_application_command(command) + + async def welcome_screen(self) -> Optional[WelcomeScreen]: + """|coro| + + Fetches the welcome screen from the guild if any. + + .. versionadded:: 2.0 + + Returns + -------- + Optional[:class:`~discord.WelcomeScreen`] + The welcome screen of the guild if any. + """ + data = await self._state.http.get_welcome_screen(self.id) + if data: + return WelcomeScreen(state=self._state, guild=self, data=data) + + async def onboarding(self) -> Optional[Onboarding]: + """|coro| + + Fetches the + `onboarding `_ + configuration for the guild. + + .. versionadded:: 2.0 + + Returns + -------- + :class:`Onboarding` + The onboarding configuration fetched. + """ + data = await self._state.http.get_guild_onboarding(self.id) + return Onboarding(data=data, state=self._state, guild=self) + + async def edit_onboarding( + self, + *, + prompts: List[PartialOnboardingPrompt] = MISSING, + default_channels: List[Snowflake] = MISSING, + enabled: bool = MISSING, + mode: OnboardingMode = MISSING, + reason: Optional[str] = None + ) -> Onboarding: + """|coro| + + Edits the onboarding configuration for the guild. + + .. versionadded:: 2.0 + + Parameters + ---------- + prompts: List[:class:`PartialOnboardingPrompt`] + The prompts that will be shown to new members + (if :attr:`~PartialOnboardingPrompt.is_onboarding` is ``True``, otherwise only in customise community). + default_channels: List[:class:`.Snowflake`] + The channels that will be added to new members' channel list by default. + enabled: :class:`bool` + Whether the onboarding configuration is enabled. + mode: :class:`OnboardingMode` + The mode that will be used for the onboarding configuration. + reason: Optional[:class:`str`] + The reason for editing the onboarding configuration. Shows up in the audit log. + + Raises + ------- + Forbidden + You do not have permissions to edit the onboarding configuration. + HTTPException + Editing the onboarding configuration failed. + + Returns + -------- + :class:`Onboarding` + The new onboarding configuration. + """ + data = await self._state.http.edit_guild_onboarding( + self.id, + prompts=[p._to_dict() for p in prompts] if prompts is not MISSING else None, + default_channel_ids=[c.id for c in default_channels] if default_channels is not MISSING else None, + enabled=enabled if enabled is not MISSING else None, + mode=mode.value if mode is not MISSING else None, + reason=reason + ) + + async def automod_rules(self) -> List[AutoModRule]: + """|coro| + + Fetches the Auto Moderation rules for this guild + + .. warning:: + This is an API-call, use it carefully. + + Returns + -------- + List[:class:`~discord.AutoModRule`] + A list of AutoMod rules the guild has + """ + data = await self._state.http.get_automod_rules(guild_id=self.id) + for rule in data: + self._add_automod_rule(AutoModRule(state=self._state, guild=self, **rule)) # TODO: Advanced caching + return self.cached_automod_rules + + async def fetch_automod_rule(self, rule_id: int) -> AutoModRule: + """|coro| + + Fetches a Auto Moderation rule for this guild by their ID + + Raises + ------- + Forbidden + You do not have permissions to fetch the rule. + HTTPException + Fetching the rule failed. + + Returns + -------- + :class:`~discord.AutoModRule` + The AutoMod rule + """ + data = await self._state.http.get_automod_rule(self.id, rule_id) + rule = AutoModRule(state=self._state, guild=self, **data) + self._add_automod_rule(rule) + return rule + + async def create_automod_rule( + self, + name: str, + event_type: AutoModEventType, + trigger_type: AutoModTriggerType, + trigger_metadata: AutoModTriggerMetadata, + actions: List[AutoModAction], + enabled: bool = True, + exempt_roles: List[Snowflake] = [], + exempt_channels: List[Snowflake] = [], + *, + reason: Optional[str] = None + ) -> AutoModRule: + """|coro| + + Creates a new AutoMod rule for this guild + + Parameters + ----------- + name: :class:`str` + The name, the rule should have. Only valid if it's not a preset rule. + event_type: :class:`~discord.AutoModEventType` + Indicates in what event context a rule should be checked. + trigger_type: :class:`~discord.AutoModTriggerType` + Characterizes the type of content which can trigger the rule. + trigger_metadata: :class:`~discord.AutoModTriggerMetadata` + Additional data used to determine whether a rule should be triggered. + Different fields are relevant based on the value of :attr:`~AutoModRule.trigger_type`. + actions: List[:class:`~discord.AutoModAction`] + The actions which will execute when the rule is triggered. + enabled: :class:`bool` + Whether the rule is enabled, default :obj:`True`. + exempt_roles: List[:class:`.Snowflake`] + Up to 20 :class:`~discord.Role`'s, that should not be affected by the rule. + exempt_channels: List[:class:`.Snowflake`] + Up to 50 :class:`~discord.TextChannel`/:class:`~discord.VoiceChannel`'s, that should not be affected by the rule. + reason: Optional[:class:`str`] + The reason for creating the rule. Shows up in the audit log. + + Raises + ------ + :exc:`discord.Forbidden` + The bot is missing permissions to create AutoMod rules + :exc:`~discord.HTTPException` + Creating the rule failed + + Returns + -------- + :class:`~discord.AutoModRule` + The AutoMod rule created + """ + data = { + 'name': name, + 'event_type': event_type if isinstance(event_type, int) else event_type.value, + 'trigger_type': trigger_type if isinstance(trigger_type, int) else trigger_type.value, + 'trigger_metadata': trigger_metadata.to_dict(), + 'actions': [a.to_dict() for a in actions], + 'enabled': enabled, + 'exempt_roles': [str(r.id) for r in exempt_roles], # type: ignore + } + for action in actions: # Add the channels where messages should be logged to, to the exempted channels + if action.type.send_alert_message and action.channel_id not in exempt_channels: + exempt_channels.append(action.channel_id) + data['exempt_channels'] = [str(r.id) for r in exempt_channels] + rule_data = await self._state.http.create_automod_rule(guild_id=self.id, data=data, reason=reason) + rule = AutoModRule(state=self._state, guild=self, **rule_data) + self._add_automod_rule(rule) + return rule + + async def edit_incidents_actions( + self, + *, + invites_disabled_until: Optional[datetime], + dms_disabled_until: Optional[datetime], + reason: Optional[str] = None + ) -> None: + """|coro| + + Edits the incident actions of the guild. + This requires the :attr:`~Permissions.manage_guild` permission. + + Parameters + ---------- + invites_disabled_until: Optional[:class:`datetime.datetime`] + When invites should be possible again (up to 24h in the future). + Set to ``None`` to enable invites again. + dms_disabled_until: Optional[:class:`datetime.datetime`] + When direct messages should be enabled again (up to 24h in the future). + Set to ``None`` to enable direct messages again. + reason: Optional[:class:`str`] + The reason for editing the incident actions. Shows up in the audit log. + + Raises + ------ + Forbidden + You don't have permissions to edit the incident actions. + HTTPException + Editing the incident actions failed. + """ + + data = await self._state.http.edit_guild_incident_actions( + guild_id=self.id, + invites_disabled_until=invites_disabled_until.isoformat() if invites_disabled_until else None, + dms_disabled_until=dms_disabled_until.isoformat() if dms_disabled_until else None, + reason=reason + ) + + diff --git a/discord/http.py b/discord/http.py index ee23847a..936dafc1 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1,1943 +1,1976 @@ -# -*- coding: utf-8 -*- - -""" -The MIT License (MIT) - -Copyright (c) 2015-2021 Rapptz & (c) 2021-present mccoderpy - -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. -""" -from __future__ import annotations - -from typing import ( - Any, - Coroutine, - Dict, - List, - Optional, - Sequence, - TYPE_CHECKING, - TypeVar, - Union, -) -from typing_extensions import Literal - -import asyncio -import json -import logging -import sys -from urllib.parse import quote as _uriquote -import weakref - -import aiohttp - -from .errors import HTTPException, Forbidden, NotFound, LoginFailure, DiscordServerError, GatewayNotFound, InvalidArgument -from .file import File -from .enums import Locale -from .gateway import DiscordClientWebSocketResponse -from .mentions import AllowedMentions -from .components import ActionRow, Button, BaseSelect -from . import __version__, utils - - -if TYPE_CHECKING: - from .flags import MessageFlags - from enums import InteractionCallbackType - from .embeds import Embed - from .message import Attachment, MessageReference - from .types import ( - guild, - monetization, - ) - from .types.snowflake import SnowflakeID - from .utils import SnowflakeList - - T = TypeVar('T') - Response = Coroutine[Any, Any, T] - - -MISSING = utils.MISSING - - -log = logging.getLogger(__name__) - - -async def json_or_text(response): - text = await response.text(encoding='utf-8') - try: - if 'application/json' in response.headers['content-type']: - return json.loads(text) - except KeyError: - # Thanks Cloudflare - pass - - return text - - -class MultipartParameters: - def __init__( - self, - payload: Optional[Dict[str, Any]] = None, - multipart: Optional[List[Dict[str, Any]]] = None, - files: Optional[Sequence[File]] = None - ): - self.payload: Optional[Dict[str, Any]] = payload - self.multipart: Optional[List[Dict[str, Any]]] = multipart - self.files: Optional[Sequence[File]] = files - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.files: - for file in self.files: - file.close() - - -def handle_message_parameters( - content: Optional[str] = MISSING, - *, - username: str = MISSING, - avatar_url: Any = MISSING, - tts: bool = False, - nonce: Optional[Union[int, str]] = None, - flags: MessageFlags = MISSING, - file: Optional[File] = MISSING, - files: Sequence[File] = MISSING, - embed: Optional[Embed] = MISSING, - embeds: Sequence[Embed] = MISSING, - attachments: Sequence[Union[Attachment, File]] = MISSING, - components: List[Union[ActionRow, List[Union[Button, BaseSelect]]]] = MISSING, - allowed_mentions: Optional[AllowedMentions] = MISSING, - message_reference: Optional[MessageReference] = MISSING, - stickers: Optional[SnowflakeList] = MISSING, - previous_allowed_mentions: Optional[AllowedMentions] = None, - mention_author: Optional[bool] = None, - thread_name: str = MISSING, - channel_payload: Dict[str, Any] = MISSING -) -> MultipartParameters: - """ - Helper function to handle message parameters. - """ - payload: Dict[str, Any] = {} - - if embed is not MISSING or embeds is not MISSING: - _embeds = [] - if embed not in {MISSING, None}: - _embeds.append(embed.to_dict()) - if embeds is not MISSING and embeds is not None: - _embeds.extend([e.to_dict() for e in embeds]) - if len(_embeds) > 10: - raise TypeError(f"Only can send up to 10 embeds per message; got {len(embeds)}") - payload['embeds'] = _embeds - - if content is not MISSING: - if content is None: - payload['content'] = None - else: - payload['content'] = str(content) - - if components is not MISSING: - if components is None: - payload['components'] = [] - else: - _components = [] - for component in (list(components) if not isinstance(components, list) else components): - if isinstance(component, (Button, BaseSelect)): - _components.extend(ActionRow(component).to_dict()) - elif isinstance(component, ActionRow): - _components.extend(component.to_dict()) - elif isinstance(component, list): - _components.extend( - ActionRow(*[obj for obj in component]).to_dict() - ) - if len(_components) > 5: - raise TypeError(f"Only can send up to 5 ActionRows per message; got {len(_components)}") - payload['components'] = _components - - if nonce is not None: - payload['nonce'] = str(nonce) - - if message_reference is not MISSING: - payload['message_reference'] = message_reference - - if stickers is not MISSING: - if stickers is None: - payload['sticker_ids'] = [] - else: - payload['sticker_ids'] = stickers - - payload['tts'] = tts - if avatar_url: - payload['avatar_url'] = str(avatar_url) - if username: - payload['username'] = username - - if flags is not MISSING: - payload['flags'] = flags.value - - if thread_name is not MISSING: - payload['thread_name'] = thread_name - - if allowed_mentions: - if previous_allowed_mentions is not None: - payload['allowed_mentions'] = previous_allowed_mentions.merge(allowed_mentions).to_dict() - else: - payload['allowed_mentions'] = allowed_mentions.to_dict() - elif previous_allowed_mentions is not None: - payload['allowed_mentions'] = previous_allowed_mentions.to_dict() - - if mention_author is not None: - if 'allowed_mentions' not in payload: - payload['allowed_mentions'] = AllowedMentions().to_dict() - payload['allowed_mentions']['replied_user'] = mention_author - - if file is not MISSING: - if files is not MISSING and files is not None: - files = [file, *files] - else: - files = [file] - - if attachments is MISSING: - attachments = files - else: - files = [a for a in attachments if isinstance(a, File)] - - if attachments is not MISSING: - file_index = 0 - attachments_payload = [] - for attachment in attachments: - if isinstance(attachment, File): - attachments_payload.append(attachment.to_dict(file_index)) - file_index += 1 - else: - attachments_payload.append(attachment.to_dict()) # type: ignore - payload['attachments'] = attachments_payload - - if channel_payload is not MISSING: - payload = { - 'message': payload, - } - payload.update(channel_payload) - - multipart = [] - if files: - multipart.append({'name': 'payload_json', 'value': utils.to_json(payload)}) - payload = None - for index, file in enumerate(files): - multipart.append( - { - 'name': f'files[{index}]', - 'value': file.fp, - 'filename': file.filename, - 'content_type': 'application/octet-stream' - } - ) - - return MultipartParameters(payload=payload, multipart=multipart, files=files) - - -def handle_interaction_message_parameters( - *, - type: InteractionCallbackType, - content: Optional[str] = MISSING, - tts: bool = False, - nonce: Optional[Union[int, str]] = None, - flags: MessageFlags = MISSING, - file: Optional[File] = MISSING, - files: Sequence[File] = MISSING, - embed: Optional[Embed] = MISSING, - embeds: Sequence[Embed] = MISSING, - attachments: Sequence[Union[Attachment, File]] = MISSING, - components: List[Union[ActionRow, List[Union[Button, BaseSelect]]]] = MISSING, - allowed_mentions: Optional[AllowedMentions] = MISSING, - message_reference: Optional[MessageReference] = MISSING, - stickers: Optional[SnowflakeList] = MISSING, - previous_allowed_mentions: Optional[AllowedMentions] = None, - mention_author: Optional[bool] = None -) -> MultipartParameters: - """ - Helper function to handle message parameters for interaction responses. - """ - payload: Dict[str, Any] = {} - - if embed is not MISSING or embeds is not MISSING: - _embeds = [] - if embed not in {MISSING, None}: - _embeds.append(embed.to_dict()) - if embeds is not MISSING and embeds is not None: - _embeds.extend([e.to_dict() for e in embeds]) - if len(_embeds) > 10: - raise TypeError(f"Only can send up to 10 embeds per message; got {len(embeds)}") - payload['embeds'] = _embeds - - if content is not MISSING: - if content is None: - payload['content'] = None - else: - payload['content'] = str(content) - - if components is not MISSING: - if components is None: - payload['components'] = [] - else: - _components = [] - for component in ([components] if not isinstance(components, list) else components): - if isinstance(component, (Button, BaseSelect)): - _components.extend(ActionRow(component).to_dict()) - elif isinstance(component, ActionRow): - _components.extend(component.to_dict()) - elif isinstance(component, list): - _components.extend( - ActionRow(*[obj for obj in component]).to_dict() - ) - if len(_components) > 5: - raise TypeError(f"Only can send up to 5 ActionRows per message; got {len(_components)}") - payload['components'] = _components - - if nonce is not None: - payload['nonce'] = str(nonce) - - if message_reference is not MISSING: - payload['message_reference'] = message_reference.to_message_reference_dict() - - if stickers is not MISSING: - if stickers is None: - payload['sticker_ids'] = [] - else: - payload['sticker_ids'] = stickers - - payload['tts'] = tts - - if flags is not MISSING: - payload['flags'] = flags.value - - if allowed_mentions: - if previous_allowed_mentions is not None: - payload['allowed_mentions'] = previous_allowed_mentions.merge(allowed_mentions).to_dict() - else: - payload['allowed_mentions'] = allowed_mentions.to_dict() - elif previous_allowed_mentions is not None: - payload['allowed_mentions'] = previous_allowed_mentions.to_dict() - - if mention_author is not None: - if 'allowed_mentions' not in payload: - payload['allowed_mentions'] = AllowedMentions().to_dict() - payload['allowed_mentions']['replied_user'] = mention_author - - if file is not MISSING: - if files is not MISSING and files is not None: - files = [file, *files] - else: - files = [file] - - if attachments is MISSING: - attachments = files - else: - files = [a for a in attachments if isinstance(a, File)] - - if attachments is not MISSING: - file_index = 0 - attachments_payload = [] - for attachment in attachments: - if isinstance(attachment, File): - attachments_payload.append(attachment.to_dict(file_index)) - file_index += 1 - else: - attachments_payload.append(attachment.to_dict()) # type: ignore - payload['attachments'] = attachments_payload - - multipart = [] - payload = {'type': int(type), 'data': payload} - if files: - multipart.append({'name': 'payload_json', 'value': utils.to_json(payload)}) - payload = None - for index, file in enumerate(files): - multipart.append( - { - 'name': f'files[{index}]', - 'value': file.fp, - 'filename': file.filename, - 'content_type': 'application/octet-stream' - } - ) - - return MultipartParameters(payload=payload, multipart=multipart, files=files) - - -class Route: - BASE = 'https://discord.com/api/v10' - - def __init__(self, method, path, **parameters): - self.path = path - self.method = method - url = (self.BASE + self.path) - if parameters: - self.url = url.format(**{k: _uriquote(v) if isinstance(v, str) else v for k, v in parameters.items()}) - else: - self.url = url - - # major parameters: - self.channel_id = parameters.get('channel_id') - self.guild_id = parameters.get('guild_id') - - @property - def bucket(self): - # the bucket is just method + path w/ major parameters - return '{0.channel_id}:{0.guild_id}:{0.path}'.format(self) - - -class MaybeUnlock: - def __init__(self, lock): - self.lock = lock - self._unlock = True - - def __enter__(self): - return self - - def defer(self): - self._unlock = False - - def __exit__(self, type, value, traceback): - if self._unlock: - self.lock.release() - - -# For some reason, the Discord voice websocket expects this header to be -# completely lowercase while aiohttp respects spec and does it as case-insensitive - -aiohttp.hdrs.WEBSOCKET = 'websocket' - - -class HTTPClient: - """Represents an HTTP client sending HTTP requests to the Discord API.""" - - SUCCESS_LOG = '{method} {url} has received {text}' - REQUEST_LOG = '{method} {url} with {json} has returned {status}' - - def __init__( - self, - connector=None, - *, - proxy: Optional[str] = None, - proxy_auth: Optional[aiohttp.BasicAuth] = None, - loop: Optional[asyncio.AbstractEventLoop] = None, - unsync_clock: bool = True, - api_version: int = 10, - api_error_locale: Optional[Locale] = 'en-US' - ): - self.loop = asyncio.get_event_loop() if loop is None else loop - self.connector = connector - self.__session: aiohttp.ClientSession = None # filled in static_login - self._locks = weakref.WeakValueDictionary() - self._global_over = asyncio.Event() - self._global_over.set() - self.token = None - self.proxy = proxy - self.proxy_auth = proxy_auth - self.use_clock = not unsync_clock - self.api_version = api_version - self.api_error_locale = str(api_error_locale) - Route.BASE = f'https://discord.com/api/v{api_version}' - - user_agent = 'DiscordBot (https://github.com/mccoderpy/discord.py-message-components {0}) Python/{1[0]}.{1[1]} aiohttp/{2}' - self.user_agent = user_agent.format(__version__, sys.version_info, aiohttp.__version__) - - def recreate(self): - if self.__session.closed: - self.__session = aiohttp.ClientSession( - connector=self.connector, - ws_response_class=DiscordClientWebSocketResponse - ) - - async def ws_connect(self, url, *, compress=0): - kwargs = { - 'proxy_auth': self.proxy_auth, - 'proxy': self.proxy, - 'max_msg_size': 0, - 'timeout': 30.0, - 'autoclose': False, - 'headers': { - 'User-Agent': self.user_agent, - 'X-Discord-Locale': self.api_error_locale - }, - 'compress': compress - } - return await self.__session.ws_connect(url, **kwargs) - - async def request(self, route, *, files=None, form=None, **kwargs): - bucket = route.bucket - method = route.method - url = route.url - - lock = self._locks.get(bucket) - if lock is None: - lock = asyncio.Lock() - if bucket is not None: - self._locks[bucket] = lock - - # header creation - headers = kwargs.get('headers', {}) - headers.update({ - 'User-Agent': self.user_agent, - 'X-Discord-Locale': self.api_error_locale - }) - - if self.token is not None: - headers['Authorization'] = 'Bot ' + self.token - # some checking if it's a JSON request - if 'json' in kwargs: - headers['Content-Type'] = 'application/json' - kwargs['data'] = utils.to_json(kwargs.pop('json')) - - if 'content_type' in kwargs: - headers['Content-Type'] = kwargs.pop('content_type') - - try: - reason = kwargs.pop('reason') - except KeyError: - pass - else: - if reason: - headers['X-Audit-Log-Reason'] = _uriquote(reason, safe='/ ') - - kwargs['headers'] = headers - - # Proxy support - if self.proxy is not None: - kwargs['proxy'] = self.proxy - if self.proxy_auth is not None: - kwargs['proxy_auth'] = self.proxy_auth - - if not self._global_over.is_set(): - # wait until the global lock is complete - await self._global_over.wait() - - await lock.acquire() - with MaybeUnlock(lock) as maybe_lock: - for tries in range(5): - if files: - for f in files: - f.reset(seek=tries) - - if form: - form_data = aiohttp.FormData(quote_fields=False) - for params in form: - form_data.add_field(**params) - kwargs['data'] = form_data - - try: - async with self.__session.request(method, url, **kwargs) as r: - log.debug('%s %s with %s has returned %s', method, url, kwargs.get('data'), r.status) - - # even errors have text involved in them so this is safe to call - data = await json_or_text(r) - - # check if we have rate limit header information - remaining = r.headers.get('X-Ratelimit-Remaining') - if remaining == '0' and r.status != 429: - # we've depleted our current bucket - delta = utils._parse_ratelimit_header(r, use_clock=self.use_clock) - log.debug('A rate limit bucket has been exhausted (bucket: %s, retry: %s).', bucket, delta) - maybe_lock.defer() - self.loop.call_later(delta, lock.release) - - # the request was successful so just return the text/json - if 300 > r.status >= 200: - log.debug('%s %s has received %s', method, url, data) - return data - - # we are being rate limited - if r.status == 429: - if not r.headers.get('Via'): - # Banned by Cloudflare more than likely. - raise HTTPException(r, data) - - fmt = 'We are being rate limited. Retrying in %.2f seconds. Handled under the bucket "%s"' - - # sleep a bit - retry_after = data['retry_after'] - log.warning(fmt, retry_after, bucket) - - # check if it's a global rate limit - is_global = data.get('global', False) - if is_global: - log.warning('Global rate limit has been hit. Retrying in %.2f seconds.', retry_after) - self._global_over.clear() - - await asyncio.sleep(retry_after) - log.debug('Done sleeping for the rate limit. Retrying...') - - # release the global lock now that the - # global rate limit has passed - if is_global: - self._global_over.set() - log.debug('Global rate limit is now over.') - - continue - - # we've received a 500 or 502, unconditional retry - if r.status in {500, 502}: - await asyncio.sleep(1 + tries * 2) - continue - - # the usual error cases - if r.status == 403: - raise Forbidden(r, data) - elif r.status == 404: - raise NotFound(r, data) - elif r.status == 503: - raise DiscordServerError(r, data) - else: - raise HTTPException(r, data) - - # This is handling exceptions from the request - except OSError as e: - # Connection reset by peer - if tries < 4 and e.errno in (54, 10054): - continue - raise - - # We've run out of retries, raise. - if r.status >= 500: - raise DiscordServerError(r, data) - - raise HTTPException(r, data) - - async def get_from_cdn(self, url): - async with self.__session.get(url) as resp: - if resp.status == 200: - return await resp.read() - elif resp.status == 404: - raise NotFound(resp, 'asset not found') - elif resp.status == 403: - raise Forbidden(resp, 'cannot retrieve asset') - else: - raise HTTPException(resp, 'failed to get asset') - - # state management - - async def close(self): - if self.__session: - await self.__session.close() - await asyncio.sleep(0.025) # wait for the connection to be released - - def _token(self, token): - self.token = token - self._ack_token = None - - # login management - - async def static_login(self, token): - # Necessary to get aiohttp to stop complaining about _session creation - self.__session = aiohttp.ClientSession(connector=self.connector, ws_response_class=DiscordClientWebSocketResponse) - old_token = self.token - self._token(token) - - try: - data = await self.request(Route('GET', '/users/@me')) - except HTTPException as exc: - self._token(old_token) - if exc.response.status == 401: - raise LoginFailure('Improper token has been passed.') from exc - raise - - return data - - def logout(self): - return self.request(Route('POST', '/auth/logout')) - - # Group functionality - - def start_group(self, user_id, recipients): - payload = { - 'recipients': recipients - } - - return self.request(Route('POST', '/users/{user_id}/channels', user_id=user_id), json=payload) - - def leave_group(self, channel_id): - return self.request(Route('DELETE', '/channels/{channel_id}', channel_id=channel_id)) - - def add_group_recipient(self, channel_id, user_id): - r = Route('PUT', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id) - return self.request(r) - - def remove_group_recipient(self, channel_id, user_id): - r = Route('DELETE', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id) - return self.request(r) - - def edit_group(self, channel_id, **options): - valid_keys = ('name', 'icon') - payload = { - k: v for k, v in options.items() if k in valid_keys - } - - return self.request(Route('PATCH', '/channels/{channel_id}', channel_id=channel_id), json=payload) - - def convert_group(self, channel_id): - return self.request(Route('POST', '/channels/{channel_id}/convert', channel_id=channel_id)) - - # Message management - - def start_private_message(self, user_id): - payload = { - 'recipient_id': user_id - } - - return self.request(Route('POST', '/users/@me/channels'), json=payload) - - def send_message(self, channel_id, *, params: MultipartParameters): - r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id) - if params.files: - return self.request(r, files=params.files, form=params.multipart) - else: - return self.request(r, json=params.payload) - - def send_voice_message(self, channel_id, *, params: MultipartParameters, x_super_properties: str): - r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id) - return self.request(r, files=params.files, form=params.multipart, headers={'x-super-properties': x_super_properties}) - - def send_typing(self, channel_id): - return self.request(Route('POST', '/channels/{channel_id}/typing', channel_id=channel_id)) - - def delete_message(self, channel_id, message_id, *, reason=None): - r = Route( - 'DELETE', - '/channels/{channel_id}/messages/{message_id}', - channel_id=channel_id, - message_id=message_id - ) - return self.request(r, reason=reason) - - def delete_messages(self, channel_id, message_ids, *, reason=None): - r = Route('POST', '/channels/{channel_id}/messages/bulk-delete', channel_id=channel_id) - payload = { - 'messages': message_ids - } - return self.request(r, json=payload, reason=reason) - - def edit_message(self, channel_id, message_id, *, params: MultipartParameters): - r = Route('PATCH', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, message_id=message_id) - if params.files: - return self.request(r, files=params.files, form=params.multipart) - else: - return self.request(r, json=params.payload) - - def add_reaction(self, channel_id, message_id, emoji): - r = Route( - 'PUT', - '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me', - channel_id=channel_id, - message_id=message_id, - emoji=emoji - ) - return self.request(r) - - def remove_reaction(self, channel_id, message_id, emoji, member_id): - r = Route( - 'DELETE', - '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/{member_id}', - channel_id=channel_id, - message_id=message_id, - member_id=member_id, - emoji=emoji - ) - return self.request(r) - - def remove_own_reaction(self, channel_id, message_id, emoji): - r = Route( - 'DELETE', - '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me', - channel_id=channel_id, - message_id=message_id, - emoji=emoji - ) - return self.request(r) - - def get_reaction_users(self, channel_id, message_id, emoji, limit, reaction_type, after=None): - r = Route( - 'GET', - '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}', - channel_id=channel_id, - message_id=message_id, - emoji=emoji - ) - - params = {'limit': limit} - if after: - params['after'] = after - if reaction_type != 0: - params['type'] = type - return self.request(r, params=params) - - def clear_reactions(self, channel_id, message_id): - r = Route( - 'DELETE', - '/channels/{channel_id}/messages/{message_id}/reactions', - channel_id=channel_id, - message_id=message_id - ) - - return self.request(r) - - def clear_single_reaction(self, channel_id, message_id, emoji): - r = Route( - 'DELETE', - '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}', - channel_id=channel_id, - message_id=message_id, - emoji=emoji - ) - return self.request(r) - - def get_message(self, channel_id, message_id): - r = Route('GET', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, message_id=message_id) - return self.request(r) - - def get_channel(self, channel_id): - r = Route('GET', '/channels/{channel_id}', channel_id=channel_id) - return self.request(r) - - def logs_from(self, channel_id, limit, before=None, after=None, around=None): - params = { - 'limit': limit - } - - if before is not None: - params['before'] = before - if after is not None: - params['after'] = after - if around is not None: - params['around'] = around - return self.request(Route('GET', '/channels/{channel_id}/messages', channel_id=channel_id), params=params) - - def publish_message(self, channel_id, message_id): - return self.request( - Route( - 'POST', - '/channels/{channel_id}/messages/{message_id}/crosspost', - channel_id=channel_id, - message_id=message_id - ) - ) - - def pin_message(self, channel_id, message_id, reason=None): - return self.request( - Route( - 'PUT', - '/channels/{channel_id}/pins/{message_id}', - channel_id=channel_id, - message_id=message_id - ), - reason=reason - ) - - def unpin_message(self, channel_id, message_id, reason=None): - return self.request( - Route( - 'DELETE', - '/channels/{channel_id}/pins/{message_id}', - channel_id=channel_id, - message_id=message_id - ), - reason=reason - ) - - def pins_from(self, channel_id): - return self.request(Route('GET', '/channels/{channel_id}/pins', channel_id=channel_id)) - - # Thread management - def create_thread( - self, - channel_id: int, - *, - payload: Dict[str, Any], - message_id: Optional[int] = None, - reason=None - ): - if message_id: - r = Route( - 'POST', - '/channels/{channel_id}/messages/{message_id}/threads', - channel_id=channel_id, - message_id=message_id - ) - else: - r = Route('POST', '/channels/{channel_id}/threads', channel_id=channel_id) - return self.request(r, json=payload, reason=reason) - - def create_forum_post(self, channel_id, *, params: MultipartParameters, reason: Optional[str] = None): - r = Route('POST', '/channels/{channel_id}/threads', channel_id=channel_id) - query_params = {'use_nested_fields': True} - if params.files: - return self.request(r, files=params.files, form=params.multipart, params=query_params, reason=reason) - else: - return self.request(r, json=params.payload, params=query_params, reason=reason) - - def add_thread_member(self, channel_id, member_id='@me'): - r = Route( - 'PUT', - '/channels/{channel_id}/thread-members/{member_id}', - channel_id=channel_id, - member_id=member_id - ) - return self.request(r) - - def remove_thread_member(self, channel_id, member_id='@me'): - r = Route( - 'DELETE', - '/channels/{channel_id}/thread-members/{member_id}', - channel_id=channel_id, - member_id=member_id - ) - return self.request(r) - - def list_thread_members( - self, - channel_id: int, - with_member: bool = False, - *, - limit: int = 100, - after: Optional[int] = None - ): - query_params = { - 'with_member': with_member, - 'limit': limit - } - if after: - query_params['after'] = after - return self.request( - Route('GET', '/channels/{channel_id}/thread-members', channel_id=channel_id), - params=query_params - ) - - def list_active_threads(self, guild_id: int): - return self.request( - Route('GET', '/guilds/{guild_id}/threads/active', guild_id=guild_id), - ) - - def list_archived_threads( - self, - channel_id: int, - type: Literal['private', 'public'], - joined_private: bool = False, - *, - before: Optional[int] = None, - limit: int = 100 - ): - if type not in ('public', 'private'): - raise ValueError('type must be public or private, not %s' % type) - if joined_private: - r = Route( - 'GET', - '/channels/{channel_id}/users/@me/threads/archived/private', channel_id=channel_id - ) - else: - r = Route( - 'GET', - '/channels/{channel_id}/threads/archived/{type}', channel_id=channel_id, type=type - ) - params = {'limit': limit} - if before: - params['before'] = before - return self.request(r, params=params) - - def create_post(self, channel_id: int, params: MultipartParameters, reason: Optional[str] = None): - r = Route('POST', '/channels/{channel_id}/threads', channel_id=channel_id) - if params.files: - return self.request(r, files=params.files, form=params.multipart) - else: - return self.request(r, json=params.payload, reason=reason) - - # Member management - def kick(self, user_id, guild_id, reason=None): - r = Route('DELETE', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) - return self.request(r, reason=reason) - - def ban(self, user_id, guild_id, delete_message_seconds, *, reason=None): - r = Route('PUT', '/guilds/{guild_id}/bans/{user_id}', guild_id=guild_id, user_id=user_id) - params = { - 'delete_message_seconds': delete_message_seconds - } - return self.request(r, params=params, reason=reason) - - def unban(self, user_id, guild_id, *, reason=None): - r = Route('DELETE', '/guilds/{guild_id}/bans/{user_id}', guild_id=guild_id, user_id=user_id) - return self.request(r, reason=reason) - - def guild_voice_state(self, user_id, guild_id, *, mute=None, deafen=None, reason=None): - r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) - payload = {} - if mute is not None: - payload['mute'] = mute - - if deafen is not None: - payload['deaf'] = deafen - - return self.request(r, json=payload, reason=reason) - - def edit_profile(self, payload): - return self.request(Route('PATCH', '/users/@me'), json=payload) - - def change_my_nickname(self, guild_id, nickname, *, reason=None): - r = Route('PATCH', '/guilds/{guild_id}/members/@me/nick', guild_id=guild_id) - payload = { - 'nick': nickname - } - return self.request(r, json=payload, reason=reason) - - def change_nickname(self, guild_id, user_id, nickname, *, reason=None): - r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) - payload = { - 'nick': nickname - } - return self.request(r, json=payload, reason=reason) - - def edit_my_voice_state(self, guild_id, payload): - r = Route('PATCH', '/guilds/{guild_id}/voice-states/@me', guild_id=guild_id) - return self.request(r, json=payload) - - def edit_voice_state(self, guild_id, user_id, payload): - r = Route('PATCH', '/guilds/{guild_id}/voice-states/{user_id}', guild_id=guild_id, user_id=user_id) - return self.request(r, json=payload) - - def edit_member(self, guild_id, user_id, *, reason=None, **fields): - r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) - return self.request(r, json=fields, reason=reason) - - # Channel management - - def edit_channel(self, channel_id, *, reason=None, **options): - r = Route('PATCH', '/channels/{channel_id}', channel_id=channel_id) - valid_keys = ( - 'name', - 'parent_id', - 'topic', - 'template', - 'icon_emoji', - 'bitrate', - 'nsfw', - 'flags', - 'auto_archive_duration', - 'default_auto_archive_duration', - 'user_limit', - 'position', - 'permission_overwrites', - 'rate_limit_per_user', - 'default_reaction_emoji', - 'default_thread_rate_limit_per_user', - 'default_forum_layout', - 'default_sort_order', - 'available_tags', - 'applied_tags', - 'locked', - 'archived', - 'type', - 'rtc_region', - ) - payload = { - k: v for k, v in options.items() if k in valid_keys - } - return self.request(r, reason=reason, json=payload) - - def bulk_channel_update(self, guild_id, data, *, reason=None): - r = Route('PATCH', '/guilds/{guild_id}/channels', guild_id=guild_id) - return self.request(r, json=data, reason=reason) - - def create_channel(self, guild_id, channel_type, *, reason=None, **options): - payload = { - 'type': channel_type - } - valid_keys = ( - 'name', - 'parent_id', - 'topic', - 'template', - 'icon_emoji', - 'bitrate', - 'nsfw', - 'flags', - 'default_auto_archive_duration', - 'user_limit', - 'position', - 'permission_overwrites', - 'rate_limit_per_user', - 'default_reaction_emoji', - 'default_thread_rate_limit_per_user', - 'default_forum_layout', - 'default_sort_order', - 'available_tags', - 'rtc_region', - ) - payload.update( - { - k: v for k, v in options.items() if k in valid_keys and v is not None - } - ) - - return self.request( - Route( - 'POST', - '/guilds/{guild_id}/channels', - guild_id=guild_id - ), - json=payload, - reason=reason - ) - - def delete_channel(self, channel_id, *, reason=None): - return self.request(Route('DELETE', '/channels/{channel_id}', channel_id=channel_id), reason=reason) - - # Stage instance management - - def create_stage_instance( - self, - channel_id, - topic, - privacy_level, - send_start_notification, - *, - reason: Optional[str] = None - ): - payload = { - 'channel_id': channel_id, - 'topic': topic, - 'privacy_level': privacy_level.value, - 'send_start_notification': send_start_notification, - - } - return self.request(Route('POST', '/stage-instances'), json=payload, reason=reason) - - def get_stage_instance(self, channel_id): - return self.request(Route('GET', '/stage-instances/{channel_id}', channel_id=channel_id)) - - def edit_stage_instance(self, channel_id, topic, privacy_level=None, *, reason=None): - r = Route('PATCH', '/stage-instances/{channel_id}', channel_id=channel_id) - payload = { - 'topic': topic - } - if privacy_level is not None: - payload['privacy_level'] = privacy_level.value - return self.request(r, json=payload, reason=reason) - - def delete_stage_instance(self, channel_id, *, reason=None): - return self.request(Route('DELETE', '/stage-instances/{channel_id}', channel_id=channel_id), reason=reason) - - # TODO: Add/remove this when we got a statement from Discord why bots can't set the channel status - # def set_voice_channel_status( - # self, - # channel_id: int, - # status: Optional[str], - # reason: Optional[str] = None - # ): - # return self.request( - # Route('PUT', '/channels/{channel_id}/voice-status', channel_id=channel_id), - # json={'status': status}, - # reason=reason - # ) - - # Webhook management - - def create_webhook(self, channel_id, *, name, avatar=None, reason=None): - payload = { - 'name': name - } - if avatar is not None: - payload['avatar'] = avatar - - r = Route('POST', '/channels/{channel_id}/webhooks', channel_id=channel_id) - return self.request(r, json=payload, reason=reason) - - 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)) - - def follow_webhook(self, channel_id, webhook_channel_id, reason=None): - payload = { - 'webhook_channel_id': str(webhook_channel_id) - } - return self.request( - Route( - 'POST', - '/channels/{channel_id}/followers', - channel_id=channel_id - ), - json=payload, - reason=reason - ) - - # Guild management - - def get_guilds(self, limit, before=None, after=None): - params = { - 'limit': limit - } - - if before: - params['before'] = before - if after: - params['after'] = after - - return self.request(Route('GET', '/users/@me/guilds'), params=params) - - def leave_guild(self, guild_id): - return self.request(Route('DELETE', '/users/@me/guilds/{guild_id}', guild_id=guild_id)) - - def get_guild(self, guild_id): - return self.request(Route('GET', '/guilds/{guild_id}', guild_id=guild_id)) - - def delete_guild(self, guild_id): - return self.request(Route('DELETE', '/guilds/{guild_id}', guild_id=guild_id)) - - def create_guild(self, name, region, icon): - payload = { - 'name': name, - 'icon': icon, - 'region': region - } - - return self.request(Route('POST', '/guilds'), json=payload) - - def edit_guild(self, guild_id, *, reason=None, **fields): - return self.request(Route('PATCH', '/guilds/{guild_id}', guild_id=guild_id), json=fields, reason=reason) - - def edit_guild_incident_actions(self, guild_id, *, reason=None, **fields): - r = Route('PUT', '/guilds/{guild_id}/incident-actions', guild_id=guild_id) - return self.request(r, json=fields, reason=reason) - - def get_welcome_screen(self, guild_id): - return self.request(Route('GET', '/guilds/{guild_id}/welcome-screen', guild_id=guild_id)) - - def edit_welcome_screen(self, guild_id, reason, **fields): - r = Route('PATCH', '/guilds/{guild_id}/welcome-screen', guild_id=guild_id) - return self.request(r, json=fields, reason=reason) - - def get_template(self, code): - return self.request(Route('GET', '/guilds/templates/{code}', code=code)) - - def guild_templates(self, guild_id): - return self.request(Route('GET', '/guilds/{guild_id}/templates', guild_id=guild_id)) - - def create_template(self, guild_id, payload): - return self.request(Route('POST', '/guilds/{guild_id}/templates', guild_id=guild_id), json=payload) - - def sync_template(self, guild_id, code): - return self.request(Route('PUT', '/guilds/{guild_id}/templates/{code}', guild_id=guild_id, code=code)) - - def edit_template(self, guild_id, code, payload): - valid_keys = ( - 'name', - 'description', - ) - payload = { - k: v for k, v in payload.items() if k in valid_keys - } - return self.request( - Route( - 'PATCH', - '/guilds/{guild_id}/templates/{code}', - guild_id=guild_id, - code=code - ), - json=payload - ) - - def delete_template(self, guild_id, code): - return self.request(Route('DELETE', '/guilds/{guild_id}/templates/{code}', guild_id=guild_id, code=code)) - - def create_from_template(self, code, name, region, icon): - payload = { - 'name': name, - 'icon': icon, - 'region': region - } - return self.request(Route('POST', '/guilds/templates/{code}', code=code), json=payload) - - def get_bans(self, guild_id, limit: int, after: int = None, before: int = None): - params = { - 'limit': limit - } - if after: - params['after'] = after - if before: - params['before'] = before - return self.request(Route('GET', '/guilds/{guild_id}/bans', guild_id=guild_id), params=params) - - def get_ban(self, user_id, guild_id): - return self.request(Route('GET', '/guilds/{guild_id}/bans/{user_id}', guild_id=guild_id, user_id=user_id)) - - def get_vanity_code(self, guild_id): - return self.request(Route('GET', '/guilds/{guild_id}/vanity-url', guild_id=guild_id)) - - def change_vanity_code(self, guild_id, code, *, reason=None): - payload = {'code': code} - return self.request( - Route( - 'PATCH', - '/guilds/{guild_id}/vanity-url', - guild_id=guild_id - ), - json=payload, - reason=reason - ) - - def get_all_guild_channels(self, guild_id): - return self.request(Route('GET', '/guilds/{guild_id}/channels', guild_id=guild_id)) - - def get_members(self, guild_id, limit, after): - params = { - 'limit': limit, - } - if after: - params['after'] = after - - r = Route('GET', '/guilds/{guild_id}/members', guild_id=guild_id) - return self.request(r, params=params) - - def get_member(self, guild_id, member_id): - return self.request( - Route('GET', '/guilds/{guild_id}/members/{member_id}', guild_id=guild_id, member_id=member_id) - ) - - def prune_members(self, guild_id, days, compute_prune_count, roles, *, reason=None): - payload = { - 'days': days, - 'compute_prune_count': 'true' if compute_prune_count else 'false' - } - if roles: - payload['include_roles'] = ', '.join(roles) - - return self.request(Route('POST', '/guilds/{guild_id}/prune', guild_id=guild_id), json=payload, reason=reason) - - def estimate_pruned_members(self, guild_id, days, roles): - params = { - 'days': days - } - if roles: - params['include_roles'] = ', '.join(roles) - - return self.request(Route('GET', '/guilds/{guild_id}/prune', guild_id=guild_id), params=params) - - def get_all_custom_emojis(self, guild_id): - return self.request(Route('GET', '/guilds/{guild_id}/emojis', guild_id=guild_id)) - - def get_custom_emoji(self, guild_id, emoji_id): - return self.request(Route('GET', '/guilds/{guild_id}/emojis/{emoji_id}', guild_id=guild_id, emoji_id=emoji_id)) - - def create_custom_emoji(self, guild_id, name, image, *, roles=None, reason=None): - payload = { - 'name': name, - 'image': image, - 'roles': roles or [] - } - - r = Route('POST', '/guilds/{guild_id}/emojis', guild_id=guild_id) - return self.request(r, json=payload, reason=reason) - - def delete_custom_emoji(self, guild_id, emoji_id, *, reason=None): - r = Route('DELETE', '/guilds/{guild_id}/emojis/{emoji_id}', guild_id=guild_id, emoji_id=emoji_id) - return self.request(r, reason=reason) - - def edit_custom_emoji(self, guild_id, emoji_id, *, name, roles=None, reason=None): - payload = { - 'name': name, - 'roles': roles or [] - } - r = Route('PATCH', '/guilds/{guild_id}/emojis/{emoji_id}', guild_id=guild_id, emoji_id=emoji_id) - return self.request(r, json=payload, reason=reason) - - def create_guild_sticker(self, guild_id, file, reason=None, **fields): - r = Route('POST', '/guilds/{guild_id}/stickers', guild_id=guild_id) - initial_bytes = file.fp.read(16) - - try: - mime_type = utils._get_mime_type_for_image(initial_bytes) - except InvalidArgument: - if initial_bytes.startswith(b'{'): - mime_type = 'application/json' - else: - mime_type = 'application/octet-stream' - finally: - file.reset() - - form = [ - { - 'name': 'file', - 'value': file.fp, - 'filename': file.filename, - 'content_type': mime_type - } - ] - for k, v in fields.items(): - if v is not None: - form.append( - { - 'name': k, - 'value': str(v) - } - ) - - return self.request(r, form=form, files=[file], reason=reason) - - def edit_guild_sticker(self, guild_id, sticker_id, data, reason=None): - r = Route('PATCH', '/guilds/{guild_id}/stickers/{sticker_id}', guild_id=guild_id, sticker_id=sticker_id) - return self.request(r, json=data, reason=reason) - - def delete_guild_sticker(self, guild_id, sticker_id, reason=None): - r = Route('DELETE', '/guilds/{guild_id}/stickers/{sticker_id}', guild_id=guild_id, sticker_id=sticker_id) - return self.request(r, reason=reason) - - def get_all_integrations(self, guild_id): - r = Route('GET', '/guilds/{guild_id}/integrations', guild_id=guild_id) - - return self.request(r) - - def create_integration(self, guild_id, type, id): - payload = { - 'type': type, - 'id': id - } - - r = Route('POST', '/guilds/{guild_id}/integrations', guild_id=guild_id) - return self.request(r, json=payload) - - def edit_integration(self, guild_id, integration_id, **payload): - r = Route( - 'PATCH', - '/guilds/{guild_id}/integrations/{integration_id}', - guild_id=guild_id, - integration_id=integration_id - ) - - return self.request(r, json=payload) - - def sync_integration(self, guild_id, integration_id): - r = Route( - 'POST', - '/guilds/{guild_id}/integrations/{integration_id}/sync', - guild_id=guild_id, - integration_id=integration_id - ) - - return self.request(r) - - def delete_integration(self, guild_id, integration_id): - r = Route( - 'DELETE', - '/guilds/{guild_id}/integrations/{integration_id}', - guild_id=guild_id, - integration_id=integration_id - ) - - return self.request(r) - - def get_audit_logs(self, guild_id, limit=100, before=None, after=None, user_id=None, action_type=None): - params = {'limit': limit} - if before: - params['before'] = before - if after: - params['after'] = after - if user_id: - params['user_id'] = user_id - if action_type: - params['action_type'] = action_type - - r = Route('GET', '/guilds/{guild_id}/audit-logs', guild_id=guild_id) - return self.request(r, params=params) - - def get_widget(self, guild_id): - return self.request(Route('GET', '/guilds/{guild_id}/widget.json', guild_id=guild_id)) - - # Invite management - - def create_invite(self, channel_id, *, reason=None, **options): - r = Route('POST', '/channels/{channel_id}/invites', channel_id=channel_id) - payload = { - 'max_age': options.get('max_age', 86400), - 'max_uses': options.get('max_uses', 0), - 'temporary': options.get('temporary', False), - 'unique': options.get('unique', False), - 'target_type': options.get('target_type', None), - 'target_user_id': options.get('target_user_id', None), - 'target_application_id': options.get('target_application_id', None) - } - - return self.request(r, reason=reason, json=payload) - - def get_invite(self, invite_id, *, with_counts=True, with_expiration=True, event_id=None): - params = { - 'with_counts': int(with_counts), - 'with_expiration': int(with_expiration), - } - if event_id: - params['guild_scheduled_event_id'] = str(event_id) - return self.request(Route('GET', '/invites/{invite_id}', invite_id=invite_id), params=params) - - def invites_from(self, guild_id): - return self.request(Route('GET', '/guilds/{guild_id}/invites', guild_id=guild_id)) - - def invites_from_channel(self, channel_id): - return self.request(Route('GET', '/channels/{channel_id}/invites', channel_id=channel_id)) - - def delete_invite(self, invite_id, *, reason=None): - return self.request(Route('DELETE', '/invites/{invite_id}', invite_id=invite_id), reason=reason) - - # Event management - - def get_guild_event(self, guild_id, event_id, with_user_count=True): - with_user_count = str(with_user_count).lower() - r = Route( - 'GET', '/guilds/{guild_id}/scheduled-events/{event_id}?with_user_count={with_user_count}', - guild_id=guild_id, event_id=event_id, with_user_count=with_user_count - ) - return self.request(r) - - def get_guild_events(self, guild_id, with_user_count=True): - r = Route( - 'GET', '/guilds/{guild_id}/scheduled-events/{event_id}?with_user_count={with_user_count}', - guild_id=guild_id, with_user_count=with_user_count - ) - return self.request(r) - - def get_guild_event_users(self, guild_id, event_id, limit=100, before=None, after=None, with_member=False): - url = '/guilds/{guild_id}/scheduled-events/{event_id}/users?limit={limit}' - if before: - url += f'&before={before}' - elif after: - url += f'&after={after}' - if with_member: - url += '&with_member=true' - return self.request(Route('GET', url, guild_id=guild_id, event_id=event_id, limit=limit)) - - def create_guild_event(self, guild_id, fields, *, reason=None): - r = Route('POST', '/guilds/{guild_id}/scheduled-events', guild_id=guild_id) - return self.request(r, json=fields, reason=reason) - - def edit_guild_event(self, guild_id, event_id, *, reason=None, **fields): - valid_keys = ( - 'name', - 'description', - 'entity_type', - 'privacy_level', - 'entity_metadata', - 'channel_id', - 'scheduled_start_time', - 'scheduled_end_time', - 'status', - 'image' - ) - payload = { - k: v for k, v in fields.items() if k in valid_keys - } - r = Route('PATCH', '/guilds/{guild_id}/scheduled-events/{event_id}', guild_id=guild_id, event_id=event_id) - return self.request(r, json=payload, reason=reason) - - def delete_guild_event(self, guild_id, event_id, *, reason=None): - r = Route('DELETE', '/guilds/{guild_id}/scheduled-events/{event_id}', guild_id=guild_id, event_id=event_id) - return self.request(r, reason=reason) - - # Role management - - def get_roles(self, guild_id): - return self.request(Route('GET', '/guilds/{guild_id}/roles', guild_id=guild_id)) - - def edit_role(self, guild_id, role_id, *, reason=None, **fields): - r = Route('PATCH', '/guilds/{guild_id}/roles/{role_id}', guild_id=guild_id, role_id=role_id) - valid_keys = ('name', 'permissions', 'color', 'hoist', 'mentionable', 'icon') - payload = { - k: v for k, v in fields.items() if k in valid_keys - } - return self.request(r, json=payload, reason=reason) - - def delete_role(self, guild_id, role_id, *, reason=None): - r = Route('DELETE', '/guilds/{guild_id}/roles/{role_id}', guild_id=guild_id, role_id=role_id) - return self.request(r, reason=reason) - - def replace_roles(self, user_id, guild_id, role_ids, *, reason=None): - return self.edit_member(guild_id=guild_id, user_id=user_id, roles=role_ids, reason=reason) - - def create_role(self, guild_id, *, reason=None, fields): - r = Route('POST', '/guilds/{guild_id}/roles', guild_id=guild_id) - return self.request(r, json=fields, reason=reason) - - def move_role_position(self, guild_id, positions, *, reason=None): - r = Route('PATCH', '/guilds/{guild_id}/roles', guild_id=guild_id) - return self.request(r, json=positions, reason=reason) - - def add_role(self, guild_id, user_id, role_id, *, reason=None): - r = Route( - 'PUT', - '/guilds/{guild_id}/members/{user_id}/roles/{role_id}', - guild_id=guild_id, - user_id=user_id, - role_id=role_id - ) - return self.request(r, reason=reason) - - def remove_role(self, guild_id, user_id, role_id, *, reason=None): - r = Route( - 'DELETE', - '/guilds/{guild_id}/members/{user_id}/roles/{role_id}', - guild_id=guild_id, - user_id=user_id, - role_id=role_id - ) - return self.request(r, reason=reason) - - def edit_channel_permissions(self, channel_id, target, allow, deny, type, *, reason=None): - payload = { - 'id': target, - 'allow': allow, - 'deny': deny, - 'type': type - } - r = Route('PUT', '/channels/{channel_id}/permissions/{target}', channel_id=channel_id, target=target) - return self.request(r, json=payload, reason=reason) - - def delete_channel_permissions(self, channel_id, target, *, reason=None): - r = Route('DELETE', '/channels/{channel_id}/permissions/{target}', channel_id=channel_id, target=target) - return self.request(r, reason=reason) - - # Voice management - - 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) - - # application-command's management - def get_application_commands(self, application_id, command_id=None, guild_id=None): - if guild_id: - url = '/applications/{application_id}/guilds/{guild_id}/commands' - if command_id: - url += f'/{command_id}' - r = Route( - 'GET', - f'{url}?with_localizations=true', - application_id=application_id, - guild_id=guild_id, - command_id=command_id - ) - else: - r = Route('GET', f'{url}?with_localizations=true', application_id=application_id, guild_id=guild_id) - else: - url = '/applications/{application_id}/commands' - if command_id: - url += f'/{command_id}' - r = Route('GET', f'{url}?with_localizations=true', application_id=application_id, command_id=command_id) - else: - r = Route('GET', f'{url}?with_localizations=true', application_id=application_id) - return self.request(r) - - def create_application_command(self, application_id, data, guild_id=None): - if guild_id: - r = Route( - 'POST', - '/applications/{application_id}/guilds/{guild_id}/commands', - application_id=application_id, - guild_id=guild_id - ) - else: - r = Route('POST', '/applications/{application_id}/commands', application_id=application_id) - return self.request(r, json=data) - - def edit_application_command(self, application_id, command_id, data, guild_id=None): - if guild_id: - r = Route('PATCH', '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}', - application_id=application_id, guild_id=guild_id, command_id=command_id - ) - else: - r = Route('PATCH', '/applications/{application_id}/commands/{command_id}', - application_id=application_id, command_id=command_id - ) - return self.request(r, json=data) - - def delete_application_command(self, application_id, command_id, guild_id=None): - if guild_id: - r = Route('DELETE', '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}', - application_id=application_id, guild_id=guild_id, command_id=command_id - ) - else: - r = Route('DELETE', '/applications/{application_id}/commands/{command_id}', - application_id=application_id, command_id=command_id - ) - return self.request(r) - - def bulk_overwrite_application_commands(self, application_id, data, guild_id=None): - if guild_id: - r = Route('PUT', '/applications/{application_id}/guilds/{guild_id}/commands', - application_id=application_id, guild_id=guild_id - ) - else: - r = Route('PUT', '/applications/{application_id}/commands', application_id=application_id) - return self.request(r, json=data) - - def get_guild_application_command_permissions(self, application_id, guild_id, command_id=None): - if command_id: - r = Route('GET', '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}/permissions', - application_id=application_id, guild_id=guild_id, command_id=command_id - ) - else: - r = Route('GET', '/applications/{application_id}/guilds/{guild_id}/commands/permissions', - application_id=application_id, guild_id=guild_id - ) - return self.request(r) - - def edit_application_command_permissions(self, application_id, guild_id, command_id, data): - r = Route( - 'PUT', - '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}/permissions', - application_id=application_id, - guild_id=guild_id, - command_id=command_id - ) - return self.request(r, json=data) - - # Interaction management - def post_initial_response(self, interaction_id: int, token: str, data: Dict[str, Any]): - return self.request(Route("POST", f'/interactions/{interaction_id}/{token}/callback'), json=data) - - def send_interaction_response( - self, - interaction_id: int, - token: str, - params: MultipartParameters, - ): - r = Route('POST', f"/interactions/{interaction_id}/{token}/callback") - if params.files: - return self.request(r, files=params.files, form=params.multipart) - else: - return self.request(r, json=params.payload) - - def edit_original_interaction_response( - self, - token: str, - application_id: int, - params: MultipartParameters - ): - r = Route('PATCH', f'/webhooks/{application_id}/{token}/messages/@original') - if params.files: - return self.request(r, files=params.files, form=params.multipart) - else: - return self.request(r, json=params.payload) - - def send_followup( - self, - token: str, - application_id: int, - params: MultipartParameters, - ): - r = Route('POST', f'/webhooks/{application_id}/{token}') - if params.files: - return self.request(r, files=params.files, form=params.multipart) - else: - return self.request(r, json=params.payload) - - def edit_followup( - self, - application_id: int, - token: str, - message_id: int, - params: MultipartParameters - ): - r = Route('PATCH', f'/webhooks/{application_id}/{token}/messages/{message_id}') - if params.files: - return self.request(r, files=params.files, form=params.multipart) - else: - return self.request(r, json=params.payload) - - def get_original_interaction_response(self, token: str, application_id: int): - r = Route('GET', f'/webhooks/{application_id}/{token}/messages/@original') - return self.request(r) - - def get_followup_message(self, token: str, application_id: int, message_id: int): - r = Route('GET', f'/webhooks/{application_id}/{token}/messages/{message_id}') - return self.request(r) - - def delete_interaction_response(self, token: str, application_id: int, message_id: int = '@original'): - r = Route('DELETE', f'/webhooks/{application_id}/{token}/messages/{message_id}') - return self.request(r) - - def send_autocomplete_callback(self, token: str, interaction_id: int, choices: list): - r = Route('POST', f'/interactions/{interaction_id}/{token}/callback') - data = {'data': {'choices': choices}, 'type': 8} - return self.request(r, json=data) - - # AutoMod management - def get_automod_rules(self, guild_id: int): - return self.request(Route('GET', '/guilds/{guild_id}/auto-moderation/rules', guild_id=guild_id)) - - def get_automod_rule(self, guild_id: int, rule_id: int): - return self.request( - Route('GET', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id) - ) - - def create_automod_rule(self, guild_id: int, data: dict, reason: str = None): - r = Route('POST', '/guilds/{guild_id}/auto-moderation/rules', guild_id=guild_id) - return self.request(r, json=data, reason=reason) - - def edit_automod_rule(self, guild_id: int, rule_id: int, fields: Dict[str, Any], reason: Optional[str] = None): - r = Route('PATCH', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id) - return self.request(r, json=fields, reason=reason) - - def delete_automod_rule(self, guild_id: int, rule_id: int, reason: str = None): - r = Route('DELETE', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id) - return self.request(r, reason=reason) - - # Onboarding management - def get_guild_onboarding(self, guild_id: int) -> Response[guild.Onboarding]: - return self.request(Route('GET', '/guilds/{guild_id}/onboarding', guild_id=guild_id)) - - def edit_guild_onboarding( - self, - guild_id: int, - *, - prompts: Optional[List[guild.OnboardingPrompt]] = None, - default_channel_ids: Optional[SnowflakeID] = None, - enabled: Optional[bool] = None, - mode: Optional[guild.OnboardingMode] = None, - reason: Optional[str] = None - ) -> Response[guild.Onboarding]: - payload = {} - - if prompts is not None: - payload['prompts'] = prompts - - if default_channel_ids is not None: - payload['default_channel_ids'] = default_channel_ids - - if enabled is not None: - payload['enabled'] = enabled - - if mode is not None: - payload['mode'] = mode - - return self.request( - Route('PUT', '/guilds/{guild_id}/onboarding', guild_id=guild_id), - json=payload, - reason=reason - ) - - # Misc - def application_info(self): - return self.request(Route('GET', '/oauth2/applications/@me')) - - def list_entitlements( - self, - application_id: int, - *, - limit: int = 100, - user_id: int = MISSING, - guild_id: int = MISSING, - sku_ids: List[int] = MISSING, - after: int = MISSING, - before: int = MISSING, - exclude_ended: int = False - ) -> Response[List[monetization.Entitlement]]: - params = {'limit': limit} - - if user_id is not MISSING: - params['user_id'] = str(user_id) - if guild_id is not MISSING: # FIXME: Can both be passed at the same time? Consider using elif instead - params['guild_id'] = str(guild_id) - if sku_ids is not MISSING: - params['sku_ids'] = [str(s) for s in sku_ids] - if after is not MISSING: - params['after'] = str(after) - if before is not MISSING: # FIXME: Can both be passed at the same time? Consider using elif instead - params['before'] = str(before) - if exclude_ended is not False: # TODO: what is the api default value? - params['exclude_ended'] = str(exclude_ended) - - r = Route('GET', '/applications/{application_id}/entitlements', application_id=application_id) - return self.request(r, json=params) - - def create_test_entitlement( - self, - application_id: int, - *, - sku_id: int, - owner_id: int, - owner_type: int - ) -> Response[monetization.TestEntitlement]: - payload = { - 'sku_id': sku_id, - 'owner_id': owner_id, - 'owner_type': owner_type - } - r = Route('POST', '/applications/{application_id}/entitlements', application_id=application_id) - return self.request(r, json=payload) - - def delete_test_entitlement(self, application_id: int, entitlement_id: int) -> Response[None]: - r = Route( - 'DELETE', - '/applications/{application_id}/entitlements/{entitlement_id}', - application_id=application_id, - entitlement_id=entitlement_id - ) - return self.request(r) - - def consume_entitlement(self, application_id: int, entitlement_id: int) -> Response[None]: - r = Route( - 'POST', - '/applications/{application_id}/entitlements/{entitlement_id}/consume', - application_id=application_id, - entitlement_id=entitlement_id - ) - return self.request(r) - - async def get_gateway(self, *, encoding='json', v=10, zlib=True): - try: - data = await self.request(Route('GET', '/gateway')) - except HTTPException as exc: - raise GatewayNotFound() from exc - if zlib: - value = '{0}?encoding={1}&v={2}&compress=zlib-stream' - else: - value = '{0}?encoding={1}&v={2}' - return value.format(data['url'], encoding, v) - - async def get_bot_gateway(self, *, encoding='json', v=10, zlib=True): - try: - data = await self.request(Route('GET', '/gateway/bot')) - except HTTPException as exc: - raise GatewayNotFound() from exc - - if zlib: - value = '{0}?encoding={1}&v={2}&compress=zlib-stream' - else: - value = '{0}?encoding={1}&v={2}' - return data['shards'], value.format(data['url'], encoding, v) - - def get_voice_regions(self): - return self.request(Route('GET', '/voice/regions')) - - def get_user(self, user_id: int): - return self.request(Route('GET', '/users/{user_id}', user_id=user_id)) - - def get_user_profile(self, user_id: int): - return self.request(Route('GET', '/users/{user_id}/profile', user_id=user_id)) - - def get_all_nitro_stickers(self): - return self.request(Route('GET', '/sticker-packs')) +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz & (c) 2021-present mccoderpy + +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. +""" +from __future__ import annotations + +from typing import ( + Any, + Coroutine, + Dict, + List, + Optional, + Sequence, + TYPE_CHECKING, + TypeVar, + Union, +) +from typing_extensions import Literal + +import asyncio +import json +import logging +import sys +from urllib.parse import quote as _uriquote +import weakref + +import aiohttp + +from .errors import HTTPException, Forbidden, NotFound, LoginFailure, DiscordServerError, GatewayNotFound, InvalidArgument +from .file import File +from .enums import Locale +from .gateway import DiscordClientWebSocketResponse +from .mentions import AllowedMentions +from .components import ActionRow, Button, BaseSelect +from . import __version__, utils + + +if TYPE_CHECKING: + from .flags import MessageFlags + from enums import InteractionCallbackType + from .embeds import Embed + from .message import Attachment, MessageReference + from .types import ( + guild, + monetization, + ) + from .types.snowflake import SnowflakeID + from .utils import SnowflakeList + + T = TypeVar('T') + Response = Coroutine[Any, Any, T] + + +MISSING = utils.MISSING + + +log = logging.getLogger(__name__) + + +async def json_or_text(response): + text = await response.text(encoding='utf-8') + try: + if 'application/json' in response.headers['content-type']: + return json.loads(text) + except KeyError: + # Thanks Cloudflare + pass + + return text + + +class MultipartParameters: + def __init__( + self, + payload: Optional[Dict[str, Any]] = None, + multipart: Optional[List[Dict[str, Any]]] = None, + files: Optional[Sequence[File]] = None + ): + self.payload: Optional[Dict[str, Any]] = payload + self.multipart: Optional[List[Dict[str, Any]]] = multipart + self.files: Optional[Sequence[File]] = files + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.files: + for file in self.files: + file.close() + + +def handle_message_parameters( + content: Optional[str] = MISSING, + *, + username: str = MISSING, + avatar_url: Any = MISSING, + tts: bool = False, + nonce: Optional[Union[int, str]] = None, + flags: MessageFlags = MISSING, + file: Optional[File] = MISSING, + files: Sequence[File] = MISSING, + embed: Optional[Embed] = MISSING, + embeds: Sequence[Embed] = MISSING, + attachments: Sequence[Union[Attachment, File]] = MISSING, + components: List[Union[ActionRow, List[Union[Button, BaseSelect]]]] = MISSING, + allowed_mentions: Optional[AllowedMentions] = MISSING, + message_reference: Optional[MessageReference] = MISSING, + stickers: Optional[SnowflakeList] = MISSING, + previous_allowed_mentions: Optional[AllowedMentions] = None, + mention_author: Optional[bool] = None, + thread_name: str = MISSING, + channel_payload: Dict[str, Any] = MISSING +) -> MultipartParameters: + """ + Helper function to handle message parameters. + """ + payload: Dict[str, Any] = {} + + if embed is not MISSING or embeds is not MISSING: + _embeds = [] + if embed not in {MISSING, None}: + _embeds.append(embed.to_dict()) + if embeds is not MISSING and embeds is not None: + _embeds.extend([e.to_dict() for e in embeds]) + if len(_embeds) > 10: + raise TypeError(f"Only can send up to 10 embeds per message; got {len(embeds)}") + payload['embeds'] = _embeds + + if content is not MISSING: + if content is None: + payload['content'] = None + else: + payload['content'] = str(content) + + if components is not MISSING: + if components is None: + payload['components'] = [] + else: + _components = [] + for component in (list(components) if not isinstance(components, list) else components): + if isinstance(component, (Button, BaseSelect)): + _components.extend(ActionRow(component).to_dict()) + elif isinstance(component, ActionRow): + _components.extend(component.to_dict()) + elif isinstance(component, list): + _components.extend( + ActionRow(*[obj for obj in component]).to_dict() + ) + if len(_components) > 5: + raise TypeError(f"Only can send up to 5 ActionRows per message; got {len(_components)}") + payload['components'] = _components + + if nonce is not None: + payload['nonce'] = str(nonce) + + if message_reference is not MISSING: + payload['message_reference'] = message_reference + + if stickers is not MISSING: + if stickers is None: + payload['sticker_ids'] = [] + else: + payload['sticker_ids'] = stickers + + payload['tts'] = tts + if avatar_url: + payload['avatar_url'] = str(avatar_url) + if username: + payload['username'] = username + + if flags is not MISSING: + payload['flags'] = flags.value + + if thread_name is not MISSING: + payload['thread_name'] = thread_name + + if allowed_mentions: + if previous_allowed_mentions is not None: + payload['allowed_mentions'] = previous_allowed_mentions.merge(allowed_mentions).to_dict() + else: + payload['allowed_mentions'] = allowed_mentions.to_dict() + elif previous_allowed_mentions is not None: + payload['allowed_mentions'] = previous_allowed_mentions.to_dict() + + if mention_author is not None: + if 'allowed_mentions' not in payload: + payload['allowed_mentions'] = AllowedMentions().to_dict() + payload['allowed_mentions']['replied_user'] = mention_author + + if file is not MISSING: + if files is not MISSING and files is not None: + files = [file, *files] + else: + files = [file] + + if attachments is MISSING: + attachments = files + else: + files = [a for a in attachments if isinstance(a, File)] + + if attachments is not MISSING: + file_index = 0 + attachments_payload = [] + for attachment in attachments: + if isinstance(attachment, File): + attachments_payload.append(attachment.to_dict(file_index)) + file_index += 1 + else: + attachments_payload.append(attachment.to_dict()) # type: ignore + payload['attachments'] = attachments_payload + + if channel_payload is not MISSING: + payload = { + 'message': payload, + } + payload.update(channel_payload) + + multipart = [] + if files: + multipart.append({'name': 'payload_json', 'value': utils.to_json(payload)}) + payload = None + for index, file in enumerate(files): + multipart.append( + { + 'name': f'files[{index}]', + 'value': file.fp, + 'filename': file.filename, + 'content_type': 'application/octet-stream' + } + ) + + return MultipartParameters(payload=payload, multipart=multipart, files=files) + + +def handle_interaction_message_parameters( + *, + type: InteractionCallbackType, + content: Optional[str] = MISSING, + tts: bool = False, + nonce: Optional[Union[int, str]] = None, + flags: MessageFlags = MISSING, + file: Optional[File] = MISSING, + files: Sequence[File] = MISSING, + embed: Optional[Embed] = MISSING, + embeds: Sequence[Embed] = MISSING, + attachments: Sequence[Union[Attachment, File]] = MISSING, + components: List[Union[ActionRow, List[Union[Button, BaseSelect]]]] = MISSING, + allowed_mentions: Optional[AllowedMentions] = MISSING, + message_reference: Optional[MessageReference] = MISSING, + stickers: Optional[SnowflakeList] = MISSING, + previous_allowed_mentions: Optional[AllowedMentions] = None, + mention_author: Optional[bool] = None +) -> MultipartParameters: + """ + Helper function to handle message parameters for interaction responses. + """ + payload: Dict[str, Any] = {} + + if embed is not MISSING or embeds is not MISSING: + _embeds = [] + if embed not in {MISSING, None}: + _embeds.append(embed.to_dict()) + if embeds is not MISSING and embeds is not None: + _embeds.extend([e.to_dict() for e in embeds]) + if len(_embeds) > 10: + raise TypeError(f"Only can send up to 10 embeds per message; got {len(embeds)}") + payload['embeds'] = _embeds + + if content is not MISSING: + if content is None: + payload['content'] = None + else: + payload['content'] = str(content) + + if components is not MISSING: + if components is None: + payload['components'] = [] + else: + _components = [] + for component in ([components] if not isinstance(components, list) else components): + if isinstance(component, (Button, BaseSelect)): + _components.extend(ActionRow(component).to_dict()) + elif isinstance(component, ActionRow): + _components.extend(component.to_dict()) + elif isinstance(component, list): + _components.extend( + ActionRow(*[obj for obj in component]).to_dict() + ) + if len(_components) > 5: + raise TypeError(f"Only can send up to 5 ActionRows per message; got {len(_components)}") + payload['components'] = _components + + if nonce is not None: + payload['nonce'] = str(nonce) + + if message_reference is not MISSING: + payload['message_reference'] = message_reference.to_message_reference_dict() + + if stickers is not MISSING: + if stickers is None: + payload['sticker_ids'] = [] + else: + payload['sticker_ids'] = stickers + + payload['tts'] = tts + + if flags is not MISSING: + payload['flags'] = flags.value + + if allowed_mentions: + if previous_allowed_mentions is not None: + payload['allowed_mentions'] = previous_allowed_mentions.merge(allowed_mentions).to_dict() + else: + payload['allowed_mentions'] = allowed_mentions.to_dict() + elif previous_allowed_mentions is not None: + payload['allowed_mentions'] = previous_allowed_mentions.to_dict() + + if mention_author is not None: + if 'allowed_mentions' not in payload: + payload['allowed_mentions'] = AllowedMentions().to_dict() + payload['allowed_mentions']['replied_user'] = mention_author + + if file is not MISSING: + if files is not MISSING and files is not None: + files = [file, *files] + else: + files = [file] + + if attachments is MISSING: + attachments = files + else: + files = [a for a in attachments if isinstance(a, File)] + + if attachments is not MISSING: + file_index = 0 + attachments_payload = [] + for attachment in attachments: + if isinstance(attachment, File): + attachments_payload.append(attachment.to_dict(file_index)) + file_index += 1 + else: + attachments_payload.append(attachment.to_dict()) # type: ignore + payload['attachments'] = attachments_payload + + multipart = [] + payload = {'type': int(type), 'data': payload} + if files: + multipart.append({'name': 'payload_json', 'value': utils.to_json(payload)}) + payload = None + for index, file in enumerate(files): + multipart.append( + { + 'name': f'files[{index}]', + 'value': file.fp, + 'filename': file.filename, + 'content_type': 'application/octet-stream' + } + ) + + return MultipartParameters(payload=payload, multipart=multipart, files=files) + + +class Route: + BASE = 'https://discord.com/api/v10' + + def __init__(self, method, path, **parameters): + self.path = path + self.method = method + url = (self.BASE + self.path) + if parameters: + self.url = url.format(**{k: _uriquote(v) if isinstance(v, str) else v for k, v in parameters.items()}) + else: + self.url = url + + # major parameters: + self.channel_id = parameters.get('channel_id') + self.guild_id = parameters.get('guild_id') + + @property + def bucket(self): + # the bucket is just method + path w/ major parameters + return '{0.channel_id}:{0.guild_id}:{0.path}'.format(self) + + +class MaybeUnlock: + def __init__(self, lock): + self.lock = lock + self._unlock = True + + def __enter__(self): + return self + + def defer(self): + self._unlock = False + + def __exit__(self, type, value, traceback): + if self._unlock: + self.lock.release() + + +# For some reason, the Discord voice websocket expects this header to be +# completely lowercase while aiohttp respects spec and does it as case-insensitive + +aiohttp.hdrs.WEBSOCKET = 'websocket' + + +class HTTPClient: + """Represents an HTTP client sending HTTP requests to the Discord API.""" + + SUCCESS_LOG = '{method} {url} has received {text}' + REQUEST_LOG = '{method} {url} with {json} has returned {status}' + + def __init__( + self, + connector=None, + *, + proxy: Optional[str] = None, + proxy_auth: Optional[aiohttp.BasicAuth] = None, + loop: Optional[asyncio.AbstractEventLoop] = None, + unsync_clock: bool = True, + api_version: int = 10, + api_error_locale: Optional[Locale] = 'en-US' + ): + self.loop = asyncio.get_event_loop() if loop is None else loop + self.connector = connector + self.__session: aiohttp.ClientSession = None # filled in static_login + self._locks = weakref.WeakValueDictionary() + self._global_over = asyncio.Event() + self._global_over.set() + self.token = None + self.proxy = proxy + self.proxy_auth = proxy_auth + self.use_clock = not unsync_clock + self.api_version = api_version + self.api_error_locale = str(api_error_locale) + Route.BASE = f'https://discord.com/api/v{api_version}' + + user_agent = 'DiscordBot (https://github.com/mccoderpy/discord.py-message-components {0}) Python/{1[0]}.{1[1]} aiohttp/{2}' + self.user_agent = user_agent.format(__version__, sys.version_info, aiohttp.__version__) + + def recreate(self): + if self.__session.closed: + self.__session = aiohttp.ClientSession( + connector=self.connector, + ws_response_class=DiscordClientWebSocketResponse + ) + + async def ws_connect(self, url, *, compress=0): + kwargs = { + 'proxy_auth': self.proxy_auth, + 'proxy': self.proxy, + 'max_msg_size': 0, + 'timeout': 30.0, + 'autoclose': False, + 'headers': { + 'User-Agent': self.user_agent, + 'X-Discord-Locale': self.api_error_locale + }, + 'compress': compress + } + return await self.__session.ws_connect(url, **kwargs) + + async def request(self, route, *, files=None, form=None, **kwargs): + bucket = route.bucket + method = route.method + url = route.url + + lock = self._locks.get(bucket) + if lock is None: + lock = asyncio.Lock() + if bucket is not None: + self._locks[bucket] = lock + + # header creation + headers = kwargs.get('headers', {}) + headers.update({ + 'User-Agent': self.user_agent, + 'X-Discord-Locale': self.api_error_locale + }) + + if self.token is not None: + headers['Authorization'] = 'Bot ' + self.token + # some checking if it's a JSON request + if 'json' in kwargs: + headers['Content-Type'] = 'application/json' + kwargs['data'] = utils.to_json(kwargs.pop('json')) + + if 'content_type' in kwargs: + headers['Content-Type'] = kwargs.pop('content_type') + + try: + reason = kwargs.pop('reason') + except KeyError: + pass + else: + if reason: + headers['X-Audit-Log-Reason'] = _uriquote(reason, safe='/ ') + + kwargs['headers'] = headers + + # Proxy support + if self.proxy is not None: + kwargs['proxy'] = self.proxy + if self.proxy_auth is not None: + kwargs['proxy_auth'] = self.proxy_auth + + if not self._global_over.is_set(): + # wait until the global lock is complete + await self._global_over.wait() + + await lock.acquire() + with MaybeUnlock(lock) as maybe_lock: + for tries in range(5): + if files: + for f in files: + f.reset(seek=tries) + + if form: + form_data = aiohttp.FormData(quote_fields=False) + for params in form: + form_data.add_field(**params) + kwargs['data'] = form_data + + try: + async with self.__session.request(method, url, **kwargs) as r: + log.debug('%s %s with %s has returned %s', method, url, kwargs.get('data'), r.status) + + # even errors have text involved in them so this is safe to call + data = await json_or_text(r) + + # check if we have rate limit header information + remaining = r.headers.get('X-Ratelimit-Remaining') + if remaining == '0' and r.status != 429: + # we've depleted our current bucket + delta = utils._parse_ratelimit_header(r, use_clock=self.use_clock) + log.debug('A rate limit bucket has been exhausted (bucket: %s, retry: %s).', bucket, delta) + maybe_lock.defer() + self.loop.call_later(delta, lock.release) + + # the request was successful so just return the text/json + if 300 > r.status >= 200: + log.debug('%s %s has received %s', method, url, data) + return data + + # we are being rate limited + if r.status == 429: + if not r.headers.get('Via'): + # Banned by Cloudflare more than likely. + raise HTTPException(r, data) + + fmt = 'We are being rate limited. Retrying in %.2f seconds. Handled under the bucket "%s"' + + # sleep a bit + retry_after = data['retry_after'] + log.warning(fmt, retry_after, bucket) + + # check if it's a global rate limit + is_global = data.get('global', False) + if is_global: + log.warning('Global rate limit has been hit. Retrying in %.2f seconds.', retry_after) + self._global_over.clear() + + await asyncio.sleep(retry_after) + log.debug('Done sleeping for the rate limit. Retrying...') + + # release the global lock now that the + # global rate limit has passed + if is_global: + self._global_over.set() + log.debug('Global rate limit is now over.') + + continue + + # we've received a 500 or 502, unconditional retry + if r.status in {500, 502}: + await asyncio.sleep(1 + tries * 2) + continue + + # the usual error cases + if r.status == 403: + raise Forbidden(r, data) + elif r.status == 404: + raise NotFound(r, data) + elif r.status == 503: + raise DiscordServerError(r, data) + else: + raise HTTPException(r, data) + + # This is handling exceptions from the request + except OSError as e: + # Connection reset by peer + if tries < 4 and e.errno in (54, 10054): + continue + raise + + # We've run out of retries, raise. + if r.status >= 500: + raise DiscordServerError(r, data) + + raise HTTPException(r, data) + + async def get_from_cdn(self, url): + async with self.__session.get(url) as resp: + if resp.status == 200: + return await resp.read() + elif resp.status == 404: + raise NotFound(resp, 'asset not found') + elif resp.status == 403: + raise Forbidden(resp, 'cannot retrieve asset') + else: + raise HTTPException(resp, 'failed to get asset') + + # state management + + async def close(self): + if self.__session: + await self.__session.close() + await asyncio.sleep(0.025) # wait for the connection to be released + + def _token(self, token): + self.token = token + self._ack_token = None + + # login management + + async def static_login(self, token): + # Necessary to get aiohttp to stop complaining about _session creation + self.__session = aiohttp.ClientSession(connector=self.connector, ws_response_class=DiscordClientWebSocketResponse) + old_token = self.token + self._token(token) + + try: + data = await self.request(Route('GET', '/users/@me')) + except HTTPException as exc: + self._token(old_token) + if exc.response.status == 401: + raise LoginFailure('Improper token has been passed.') from exc + raise + + return data + + def logout(self): + return self.request(Route('POST', '/auth/logout')) + + # Group functionality + + def start_group(self, user_id, recipients): + payload = { + 'recipients': recipients + } + + return self.request(Route('POST', '/users/{user_id}/channels', user_id=user_id), json=payload) + + def leave_group(self, channel_id): + return self.request(Route('DELETE', '/channels/{channel_id}', channel_id=channel_id)) + + def add_group_recipient(self, channel_id, user_id): + r = Route('PUT', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id) + return self.request(r) + + def remove_group_recipient(self, channel_id, user_id): + r = Route('DELETE', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id) + return self.request(r) + + def edit_group(self, channel_id, **options): + valid_keys = ('name', 'icon') + payload = { + k: v for k, v in options.items() if k in valid_keys + } + + return self.request(Route('PATCH', '/channels/{channel_id}', channel_id=channel_id), json=payload) + + def convert_group(self, channel_id): + return self.request(Route('POST', '/channels/{channel_id}/convert', channel_id=channel_id)) + + # Message management + + def start_private_message(self, user_id): + payload = { + 'recipient_id': user_id + } + + return self.request(Route('POST', '/users/@me/channels'), json=payload) + + def send_message(self, channel_id, *, params: MultipartParameters): + r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id) + if params.files: + return self.request(r, files=params.files, form=params.multipart) + else: + return self.request(r, json=params.payload) + + def send_voice_message(self, channel_id, *, params: MultipartParameters, x_super_properties: str): + r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id) + return self.request(r, files=params.files, form=params.multipart, headers={'x-super-properties': x_super_properties}) + + def send_typing(self, channel_id): + return self.request(Route('POST', '/channels/{channel_id}/typing', channel_id=channel_id)) + + def delete_message(self, channel_id, message_id, *, reason=None): + r = Route( + 'DELETE', + '/channels/{channel_id}/messages/{message_id}', + channel_id=channel_id, + message_id=message_id + ) + return self.request(r, reason=reason) + + def delete_messages(self, channel_id, message_ids, *, reason=None): + r = Route('POST', '/channels/{channel_id}/messages/bulk-delete', channel_id=channel_id) + payload = { + 'messages': message_ids + } + return self.request(r, json=payload, reason=reason) + + def edit_message(self, channel_id, message_id, *, params: MultipartParameters): + r = Route('PATCH', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, message_id=message_id) + if params.files: + return self.request(r, files=params.files, form=params.multipart) + else: + return self.request(r, json=params.payload) + + def add_reaction(self, channel_id, message_id, emoji): + r = Route( + 'PUT', + '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me', + channel_id=channel_id, + message_id=message_id, + emoji=emoji + ) + return self.request(r) + + def remove_reaction(self, channel_id, message_id, emoji, member_id): + r = Route( + 'DELETE', + '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/{member_id}', + channel_id=channel_id, + message_id=message_id, + member_id=member_id, + emoji=emoji + ) + return self.request(r) + + def remove_own_reaction(self, channel_id, message_id, emoji): + r = Route( + 'DELETE', + '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}/@me', + channel_id=channel_id, + message_id=message_id, + emoji=emoji + ) + return self.request(r) + + def get_reaction_users(self, channel_id, message_id, emoji, limit, reaction_type, after=None): + r = Route( + 'GET', + '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}', + channel_id=channel_id, + message_id=message_id, + emoji=emoji + ) + + params = {'limit': limit} + if after: + params['after'] = after + if reaction_type != 0: + params['type'] = type + return self.request(r, params=params) + + def clear_reactions(self, channel_id, message_id): + r = Route( + 'DELETE', + '/channels/{channel_id}/messages/{message_id}/reactions', + channel_id=channel_id, + message_id=message_id + ) + + return self.request(r) + + def clear_single_reaction(self, channel_id, message_id, emoji): + r = Route( + 'DELETE', + '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}', + channel_id=channel_id, + message_id=message_id, + emoji=emoji + ) + return self.request(r) + + def get_message(self, channel_id, message_id): + r = Route('GET', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, message_id=message_id) + return self.request(r) + + def get_channel(self, channel_id): + r = Route('GET', '/channels/{channel_id}', channel_id=channel_id) + return self.request(r) + + def logs_from(self, channel_id, limit, before=None, after=None, around=None): + params = { + 'limit': limit + } + + if before is not None: + params['before'] = before + if after is not None: + params['after'] = after + if around is not None: + params['around'] = around + return self.request(Route('GET', '/channels/{channel_id}/messages', channel_id=channel_id), params=params) + + def publish_message(self, channel_id, message_id): + return self.request( + Route( + 'POST', + '/channels/{channel_id}/messages/{message_id}/crosspost', + channel_id=channel_id, + message_id=message_id + ) + ) + + def pin_message(self, channel_id, message_id, reason=None): + return self.request( + Route( + 'PUT', + '/channels/{channel_id}/pins/{message_id}', + channel_id=channel_id, + message_id=message_id + ), + reason=reason + ) + + def unpin_message(self, channel_id, message_id, reason=None): + return self.request( + Route( + 'DELETE', + '/channels/{channel_id}/pins/{message_id}', + channel_id=channel_id, + message_id=message_id + ), + reason=reason + ) + + def pins_from(self, channel_id): + return self.request(Route('GET', '/channels/{channel_id}/pins', channel_id=channel_id)) + + # Thread management + def create_thread( + self, + channel_id: int, + *, + payload: Dict[str, Any], + message_id: Optional[int] = None, + reason=None + ): + if message_id: + r = Route( + 'POST', + '/channels/{channel_id}/messages/{message_id}/threads', + channel_id=channel_id, + message_id=message_id + ) + else: + r = Route('POST', '/channels/{channel_id}/threads', channel_id=channel_id) + return self.request(r, json=payload, reason=reason) + + def create_forum_post(self, channel_id, *, params: MultipartParameters, reason: Optional[str] = None): + r = Route('POST', '/channels/{channel_id}/threads', channel_id=channel_id) + query_params = {'use_nested_fields': True} + if params.files: + return self.request(r, files=params.files, form=params.multipart, params=query_params, reason=reason) + else: + return self.request(r, json=params.payload, params=query_params, reason=reason) + + def add_thread_member(self, channel_id, member_id='@me'): + r = Route( + 'PUT', + '/channels/{channel_id}/thread-members/{member_id}', + channel_id=channel_id, + member_id=member_id + ) + return self.request(r) + + def remove_thread_member(self, channel_id, member_id='@me'): + r = Route( + 'DELETE', + '/channels/{channel_id}/thread-members/{member_id}', + channel_id=channel_id, + member_id=member_id + ) + return self.request(r) + + def list_thread_members( + self, + channel_id: int, + with_member: bool = False, + *, + limit: int = 100, + after: Optional[int] = None + ): + query_params = { + 'with_member': with_member, + 'limit': limit + } + if after: + query_params['after'] = after + return self.request( + Route('GET', '/channels/{channel_id}/thread-members', channel_id=channel_id), + params=query_params + ) + + def list_active_threads(self, guild_id: int): + return self.request( + Route('GET', '/guilds/{guild_id}/threads/active', guild_id=guild_id), + ) + + def list_archived_threads( + self, + channel_id: int, + type: Literal['private', 'public'], + joined_private: bool = False, + *, + before: Optional[int] = None, + limit: int = 100 + ): + if type not in ('public', 'private'): + raise ValueError('type must be public or private, not %s' % type) + if joined_private: + r = Route( + 'GET', + '/channels/{channel_id}/users/@me/threads/archived/private', channel_id=channel_id + ) + else: + r = Route( + 'GET', + '/channels/{channel_id}/threads/archived/{type}', channel_id=channel_id, type=type + ) + params = {'limit': limit} + if before: + params['before'] = before + return self.request(r, params=params) + + def create_post(self, channel_id: int, params: MultipartParameters, reason: Optional[str] = None): + r = Route('POST', '/channels/{channel_id}/threads', channel_id=channel_id) + if params.files: + return self.request(r, files=params.files, form=params.multipart) + else: + return self.request(r, json=params.payload, reason=reason) + + # Member management + def kick(self, user_id, guild_id, reason=None): + r = Route('DELETE', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) + return self.request(r, reason=reason) + + def ban(self, user_id, guild_id, delete_message_seconds, *, reason=None): + r = Route('PUT', '/guilds/{guild_id}/bans/{user_id}', guild_id=guild_id, user_id=user_id) + params = { + 'delete_message_seconds': delete_message_seconds + } + return self.request(r, params=params, reason=reason) + + def unban(self, user_id, guild_id, *, reason=None): + r = Route('DELETE', '/guilds/{guild_id}/bans/{user_id}', guild_id=guild_id, user_id=user_id) + return self.request(r, reason=reason) + + def guild_voice_state(self, user_id, guild_id, *, mute=None, deafen=None, reason=None): + r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) + payload = {} + if mute is not None: + payload['mute'] = mute + + if deafen is not None: + payload['deaf'] = deafen + + return self.request(r, json=payload, reason=reason) + + def edit_profile(self, payload): + return self.request(Route('PATCH', '/users/@me'), json=payload) + + def change_my_nickname(self, guild_id, nickname, *, reason=None): + r = Route('PATCH', '/guilds/{guild_id}/members/@me/nick', guild_id=guild_id) + payload = { + 'nick': nickname + } + return self.request(r, json=payload, reason=reason) + + def change_nickname(self, guild_id, user_id, nickname, *, reason=None): + r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) + payload = { + 'nick': nickname + } + return self.request(r, json=payload, reason=reason) + + def edit_my_voice_state(self, guild_id, payload): + r = Route('PATCH', '/guilds/{guild_id}/voice-states/@me', guild_id=guild_id) + return self.request(r, json=payload) + + def edit_voice_state(self, guild_id, user_id, payload): + r = Route('PATCH', '/guilds/{guild_id}/voice-states/{user_id}', guild_id=guild_id, user_id=user_id) + return self.request(r, json=payload) + + def edit_member(self, guild_id, user_id, *, reason=None, **fields): + r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) + return self.request(r, json=fields, reason=reason) + + # Channel management + + def edit_channel(self, channel_id, *, reason=None, **options): + r = Route('PATCH', '/channels/{channel_id}', channel_id=channel_id) + valid_keys = ( + 'name', + 'parent_id', + 'topic', + 'template', + 'icon_emoji', + 'bitrate', + 'nsfw', + 'flags', + 'auto_archive_duration', + 'default_auto_archive_duration', + 'user_limit', + 'position', + 'permission_overwrites', + 'rate_limit_per_user', + 'default_reaction_emoji', + 'default_thread_rate_limit_per_user', + 'default_forum_layout', + 'default_sort_order', + 'available_tags', + 'applied_tags', + 'locked', + 'archived', + 'type', + 'rtc_region', + ) + payload = { + k: v for k, v in options.items() if k in valid_keys + } + return self.request(r, reason=reason, json=payload) + + def bulk_channel_update(self, guild_id, data, *, reason=None): + r = Route('PATCH', '/guilds/{guild_id}/channels', guild_id=guild_id) + return self.request(r, json=data, reason=reason) + + def create_channel(self, guild_id, channel_type, *, reason=None, **options): + payload = { + 'type': channel_type + } + valid_keys = ( + 'name', + 'parent_id', + 'topic', + 'template', + 'icon_emoji', + 'bitrate', + 'nsfw', + 'flags', + 'default_auto_archive_duration', + 'user_limit', + 'position', + 'permission_overwrites', + 'rate_limit_per_user', + 'default_reaction_emoji', + 'default_thread_rate_limit_per_user', + 'default_forum_layout', + 'default_sort_order', + 'available_tags', + 'rtc_region', + ) + payload.update( + { + k: v for k, v in options.items() if k in valid_keys and v is not None + } + ) + + return self.request( + Route( + 'POST', + '/guilds/{guild_id}/channels', + guild_id=guild_id + ), + json=payload, + reason=reason + ) + + def delete_channel(self, channel_id, *, reason=None): + return self.request(Route('DELETE', '/channels/{channel_id}', channel_id=channel_id), reason=reason) + + # Stage instance management + + def create_stage_instance( + self, + channel_id, + topic, + privacy_level, + send_start_notification, + *, + reason: Optional[str] = None + ): + payload = { + 'channel_id': channel_id, + 'topic': topic, + 'privacy_level': privacy_level.value, + 'send_start_notification': send_start_notification, + + } + return self.request(Route('POST', '/stage-instances'), json=payload, reason=reason) + + def get_stage_instance(self, channel_id): + return self.request(Route('GET', '/stage-instances/{channel_id}', channel_id=channel_id)) + + def edit_stage_instance(self, channel_id, topic, privacy_level=None, *, reason=None): + r = Route('PATCH', '/stage-instances/{channel_id}', channel_id=channel_id) + payload = { + 'topic': topic + } + if privacy_level is not None: + payload['privacy_level'] = privacy_level.value + return self.request(r, json=payload, reason=reason) + + def delete_stage_instance(self, channel_id, *, reason=None): + return self.request(Route('DELETE', '/stage-instances/{channel_id}', channel_id=channel_id), reason=reason) + + # TODO: Add/remove this when we got a statement from Discord why bots can't set the channel status + # def set_voice_channel_status( + # self, + # channel_id: int, + # status: Optional[str], + # reason: Optional[str] = None + # ): + # return self.request( + # Route('PUT', '/channels/{channel_id}/voice-status', channel_id=channel_id), + # json={'status': status}, + # reason=reason + # ) + + # Webhook management + + def create_webhook(self, channel_id, *, name, avatar=None, reason=None): + payload = { + 'name': name + } + if avatar is not None: + payload['avatar'] = avatar + + r = Route('POST', '/channels/{channel_id}/webhooks', channel_id=channel_id) + return self.request(r, json=payload, reason=reason) + + 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)) + + def follow_webhook(self, channel_id, webhook_channel_id, reason=None): + payload = { + 'webhook_channel_id': str(webhook_channel_id) + } + return self.request( + Route( + 'POST', + '/channels/{channel_id}/followers', + channel_id=channel_id + ), + json=payload, + reason=reason + ) + + # Guild management + + def create_soundboard_sound(self, guild_id, volume=1.0, emoji_id=None, emoji_name=None, *, name, sound): + payload = { + "name": name, + "sound": sound, + "volume": volume, + "emoji_id": emoji_id, + "emoji_name": emoji_name + } + + return self.request(Route("POST", "/guilds/{guild_id}/soundboard-sounds", guild_id=guild_id), json=payload) + + def update_soundboard_sound(self, guild_id, sound_id, emoji_id=None, emoji_name=None, *, name, volume): + payload = { + "name": name, + "volume": volume, + "emoji_id": emoji_id, + "emoji_name": emoji_name + } + + return self.request(Route("PATCH", "/guilds/{guild_id}/soundboard-sounds/{sound_id}", guild_id=guild_id, sound_id=sound_id), json=payload) + + def delete_soundboard_sound(self, guild_id, sound_id): + return self.request(Route("DELETE", "/guilds/{guild_id}/soundboard-sounds/{sound_id}", guild_id=guild_id, sound_id=sound_id)) + + def get_soundboard_sound(self, guild_id, sound_id): + return self.request(Route("GET", "/guilds/{guild_id}/soundboard-sounds/{sound_id}", guild_id=guild_id, sound_id=sound_id)) + + def all_soundboard_sounds(self, guild_id): + return self.request(Route("GET", "/guilds/{guild_id}/soundboard-sounds", guild_id=guild_id)) + + def default_soundboard_sounds(self): + return self.request(Route("GET", "/soundboard-default-sounds")) + + def get_guilds(self, limit, before=None, after=None): + params = { + 'limit': limit + } + + if before: + params['before'] = before + if after: + params['after'] = after + + return self.request(Route('GET', '/users/@me/guilds'), params=params) + + def leave_guild(self, guild_id): + return self.request(Route('DELETE', '/users/@me/guilds/{guild_id}', guild_id=guild_id)) + + def get_guild(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}', guild_id=guild_id)) + + def delete_guild(self, guild_id): + return self.request(Route('DELETE', '/guilds/{guild_id}', guild_id=guild_id)) + + def create_guild(self, name, region, icon): + payload = { + 'name': name, + 'icon': icon, + 'region': region + } + + return self.request(Route('POST', '/guilds'), json=payload) + + def edit_guild(self, guild_id, *, reason=None, **fields): + return self.request(Route('PATCH', '/guilds/{guild_id}', guild_id=guild_id), json=fields, reason=reason) + + def edit_guild_incident_actions(self, guild_id, *, reason=None, **fields): + r = Route('PUT', '/guilds/{guild_id}/incident-actions', guild_id=guild_id) + return self.request(r, json=fields, reason=reason) + + def get_welcome_screen(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/welcome-screen', guild_id=guild_id)) + + def edit_welcome_screen(self, guild_id, reason, **fields): + r = Route('PATCH', '/guilds/{guild_id}/welcome-screen', guild_id=guild_id) + return self.request(r, json=fields, reason=reason) + + def get_template(self, code): + return self.request(Route('GET', '/guilds/templates/{code}', code=code)) + + def guild_templates(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/templates', guild_id=guild_id)) + + def create_template(self, guild_id, payload): + return self.request(Route('POST', '/guilds/{guild_id}/templates', guild_id=guild_id), json=payload) + + def sync_template(self, guild_id, code): + return self.request(Route('PUT', '/guilds/{guild_id}/templates/{code}', guild_id=guild_id, code=code)) + + def edit_template(self, guild_id, code, payload): + valid_keys = ( + 'name', + 'description', + ) + payload = { + k: v for k, v in payload.items() if k in valid_keys + } + return self.request( + Route( + 'PATCH', + '/guilds/{guild_id}/templates/{code}', + guild_id=guild_id, + code=code + ), + json=payload + ) + + def delete_template(self, guild_id, code): + return self.request(Route('DELETE', '/guilds/{guild_id}/templates/{code}', guild_id=guild_id, code=code)) + + def create_from_template(self, code, name, region, icon): + payload = { + 'name': name, + 'icon': icon, + 'region': region + } + return self.request(Route('POST', '/guilds/templates/{code}', code=code), json=payload) + + def get_bans(self, guild_id, limit: int, after: int = None, before: int = None): + params = { + 'limit': limit + } + if after: + params['after'] = after + if before: + params['before'] = before + return self.request(Route('GET', '/guilds/{guild_id}/bans', guild_id=guild_id), params=params) + + def get_ban(self, user_id, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/bans/{user_id}', guild_id=guild_id, user_id=user_id)) + + def get_vanity_code(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/vanity-url', guild_id=guild_id)) + + def change_vanity_code(self, guild_id, code, *, reason=None): + payload = {'code': code} + return self.request( + Route( + 'PATCH', + '/guilds/{guild_id}/vanity-url', + guild_id=guild_id + ), + json=payload, + reason=reason + ) + + def get_all_guild_channels(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/channels', guild_id=guild_id)) + + def get_members(self, guild_id, limit, after): + params = { + 'limit': limit, + } + if after: + params['after'] = after + + r = Route('GET', '/guilds/{guild_id}/members', guild_id=guild_id) + return self.request(r, params=params) + + def get_member(self, guild_id, member_id): + return self.request( + Route('GET', '/guilds/{guild_id}/members/{member_id}', guild_id=guild_id, member_id=member_id) + ) + + def prune_members(self, guild_id, days, compute_prune_count, roles, *, reason=None): + payload = { + 'days': days, + 'compute_prune_count': 'true' if compute_prune_count else 'false' + } + if roles: + payload['include_roles'] = ', '.join(roles) + + return self.request(Route('POST', '/guilds/{guild_id}/prune', guild_id=guild_id), json=payload, reason=reason) + + def estimate_pruned_members(self, guild_id, days, roles): + params = { + 'days': days + } + if roles: + params['include_roles'] = ', '.join(roles) + + return self.request(Route('GET', '/guilds/{guild_id}/prune', guild_id=guild_id), params=params) + + def get_all_custom_emojis(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/emojis', guild_id=guild_id)) + + def get_custom_emoji(self, guild_id, emoji_id): + return self.request(Route('GET', '/guilds/{guild_id}/emojis/{emoji_id}', guild_id=guild_id, emoji_id=emoji_id)) + + def create_custom_emoji(self, guild_id, name, image, *, roles=None, reason=None): + payload = { + 'name': name, + 'image': image, + 'roles': roles or [] + } + + r = Route('POST', '/guilds/{guild_id}/emojis', guild_id=guild_id) + return self.request(r, json=payload, reason=reason) + + def delete_custom_emoji(self, guild_id, emoji_id, *, reason=None): + r = Route('DELETE', '/guilds/{guild_id}/emojis/{emoji_id}', guild_id=guild_id, emoji_id=emoji_id) + return self.request(r, reason=reason) + + def edit_custom_emoji(self, guild_id, emoji_id, *, name, roles=None, reason=None): + payload = { + 'name': name, + 'roles': roles or [] + } + r = Route('PATCH', '/guilds/{guild_id}/emojis/{emoji_id}', guild_id=guild_id, emoji_id=emoji_id) + return self.request(r, json=payload, reason=reason) + + def create_guild_sticker(self, guild_id, file, reason=None, **fields): + r = Route('POST', '/guilds/{guild_id}/stickers', guild_id=guild_id) + initial_bytes = file.fp.read(16) + + try: + mime_type = utils._get_mime_type_for_image(initial_bytes) + except InvalidArgument: + if initial_bytes.startswith(b'{'): + mime_type = 'application/json' + else: + mime_type = 'application/octet-stream' + finally: + file.reset() + + form = [ + { + 'name': 'file', + 'value': file.fp, + 'filename': file.filename, + 'content_type': mime_type + } + ] + for k, v in fields.items(): + if v is not None: + form.append( + { + 'name': k, + 'value': str(v) + } + ) + + return self.request(r, form=form, files=[file], reason=reason) + + def edit_guild_sticker(self, guild_id, sticker_id, data, reason=None): + r = Route('PATCH', '/guilds/{guild_id}/stickers/{sticker_id}', guild_id=guild_id, sticker_id=sticker_id) + return self.request(r, json=data, reason=reason) + + def delete_guild_sticker(self, guild_id, sticker_id, reason=None): + r = Route('DELETE', '/guilds/{guild_id}/stickers/{sticker_id}', guild_id=guild_id, sticker_id=sticker_id) + return self.request(r, reason=reason) + + def get_all_integrations(self, guild_id): + r = Route('GET', '/guilds/{guild_id}/integrations', guild_id=guild_id) + + return self.request(r) + + def create_integration(self, guild_id, type, id): + payload = { + 'type': type, + 'id': id + } + + r = Route('POST', '/guilds/{guild_id}/integrations', guild_id=guild_id) + return self.request(r, json=payload) + + def edit_integration(self, guild_id, integration_id, **payload): + r = Route( + 'PATCH', + '/guilds/{guild_id}/integrations/{integration_id}', + guild_id=guild_id, + integration_id=integration_id + ) + + return self.request(r, json=payload) + + def sync_integration(self, guild_id, integration_id): + r = Route( + 'POST', + '/guilds/{guild_id}/integrations/{integration_id}/sync', + guild_id=guild_id, + integration_id=integration_id + ) + + return self.request(r) + + def delete_integration(self, guild_id, integration_id): + r = Route( + 'DELETE', + '/guilds/{guild_id}/integrations/{integration_id}', + guild_id=guild_id, + integration_id=integration_id + ) + + return self.request(r) + + def get_audit_logs(self, guild_id, limit=100, before=None, after=None, user_id=None, action_type=None): + params = {'limit': limit} + if before: + params['before'] = before + if after: + params['after'] = after + if user_id: + params['user_id'] = user_id + if action_type: + params['action_type'] = action_type + + r = Route('GET', '/guilds/{guild_id}/audit-logs', guild_id=guild_id) + return self.request(r, params=params) + + def get_widget(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/widget.json', guild_id=guild_id)) + + # Invite management + + def create_invite(self, channel_id, *, reason=None, **options): + r = Route('POST', '/channels/{channel_id}/invites', channel_id=channel_id) + payload = { + 'max_age': options.get('max_age', 86400), + 'max_uses': options.get('max_uses', 0), + 'temporary': options.get('temporary', False), + 'unique': options.get('unique', False), + 'target_type': options.get('target_type', None), + 'target_user_id': options.get('target_user_id', None), + 'target_application_id': options.get('target_application_id', None) + } + + return self.request(r, reason=reason, json=payload) + + def get_invite(self, invite_id, *, with_counts=True, with_expiration=True, event_id=None): + params = { + 'with_counts': int(with_counts), + 'with_expiration': int(with_expiration), + } + if event_id: + params['guild_scheduled_event_id'] = str(event_id) + return self.request(Route('GET', '/invites/{invite_id}', invite_id=invite_id), params=params) + + def invites_from(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/invites', guild_id=guild_id)) + + def invites_from_channel(self, channel_id): + return self.request(Route('GET', '/channels/{channel_id}/invites', channel_id=channel_id)) + + def delete_invite(self, invite_id, *, reason=None): + return self.request(Route('DELETE', '/invites/{invite_id}', invite_id=invite_id), reason=reason) + + # Event management + + def get_guild_event(self, guild_id, event_id, with_user_count=True): + with_user_count = str(with_user_count).lower() + r = Route( + 'GET', '/guilds/{guild_id}/scheduled-events/{event_id}?with_user_count={with_user_count}', + guild_id=guild_id, event_id=event_id, with_user_count=with_user_count + ) + return self.request(r) + + def get_guild_events(self, guild_id, with_user_count=True): + r = Route( + 'GET', '/guilds/{guild_id}/scheduled-events/{event_id}?with_user_count={with_user_count}', + guild_id=guild_id, with_user_count=with_user_count + ) + return self.request(r) + + def get_guild_event_users(self, guild_id, event_id, limit=100, before=None, after=None, with_member=False): + url = '/guilds/{guild_id}/scheduled-events/{event_id}/users?limit={limit}' + if before: + url += f'&before={before}' + elif after: + url += f'&after={after}' + if with_member: + url += '&with_member=true' + return self.request(Route('GET', url, guild_id=guild_id, event_id=event_id, limit=limit)) + + def create_guild_event(self, guild_id, fields, *, reason=None): + r = Route('POST', '/guilds/{guild_id}/scheduled-events', guild_id=guild_id) + return self.request(r, json=fields, reason=reason) + + def edit_guild_event(self, guild_id, event_id, *, reason=None, **fields): + valid_keys = ( + 'name', + 'description', + 'entity_type', + 'privacy_level', + 'entity_metadata', + 'channel_id', + 'scheduled_start_time', + 'scheduled_end_time', + 'status', + 'image' + ) + payload = { + k: v for k, v in fields.items() if k in valid_keys + } + r = Route('PATCH', '/guilds/{guild_id}/scheduled-events/{event_id}', guild_id=guild_id, event_id=event_id) + return self.request(r, json=payload, reason=reason) + + def delete_guild_event(self, guild_id, event_id, *, reason=None): + r = Route('DELETE', '/guilds/{guild_id}/scheduled-events/{event_id}', guild_id=guild_id, event_id=event_id) + return self.request(r, reason=reason) + + # Role management + + def get_roles(self, guild_id): + return self.request(Route('GET', '/guilds/{guild_id}/roles', guild_id=guild_id)) + + def edit_role(self, guild_id, role_id, *, reason=None, **fields): + r = Route('PATCH', '/guilds/{guild_id}/roles/{role_id}', guild_id=guild_id, role_id=role_id) + valid_keys = ('name', 'permissions', 'color', 'hoist', 'mentionable', 'icon') + payload = { + k: v for k, v in fields.items() if k in valid_keys + } + return self.request(r, json=payload, reason=reason) + + def delete_role(self, guild_id, role_id, *, reason=None): + r = Route('DELETE', '/guilds/{guild_id}/roles/{role_id}', guild_id=guild_id, role_id=role_id) + return self.request(r, reason=reason) + + def replace_roles(self, user_id, guild_id, role_ids, *, reason=None): + return self.edit_member(guild_id=guild_id, user_id=user_id, roles=role_ids, reason=reason) + + def create_role(self, guild_id, *, reason=None, fields): + r = Route('POST', '/guilds/{guild_id}/roles', guild_id=guild_id) + return self.request(r, json=fields, reason=reason) + + def move_role_position(self, guild_id, positions, *, reason=None): + r = Route('PATCH', '/guilds/{guild_id}/roles', guild_id=guild_id) + return self.request(r, json=positions, reason=reason) + + def add_role(self, guild_id, user_id, role_id, *, reason=None): + r = Route( + 'PUT', + '/guilds/{guild_id}/members/{user_id}/roles/{role_id}', + guild_id=guild_id, + user_id=user_id, + role_id=role_id + ) + return self.request(r, reason=reason) + + def remove_role(self, guild_id, user_id, role_id, *, reason=None): + r = Route( + 'DELETE', + '/guilds/{guild_id}/members/{user_id}/roles/{role_id}', + guild_id=guild_id, + user_id=user_id, + role_id=role_id + ) + return self.request(r, reason=reason) + + def edit_channel_permissions(self, channel_id, target, allow, deny, type, *, reason=None): + payload = { + 'id': target, + 'allow': allow, + 'deny': deny, + 'type': type + } + r = Route('PUT', '/channels/{channel_id}/permissions/{target}', channel_id=channel_id, target=target) + return self.request(r, json=payload, reason=reason) + + def delete_channel_permissions(self, channel_id, target, *, reason=None): + r = Route('DELETE', '/channels/{channel_id}/permissions/{target}', channel_id=channel_id, target=target) + return self.request(r, reason=reason) + + # Voice management + + 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) + + # application-command's management + def get_application_commands(self, application_id, command_id=None, guild_id=None): + if guild_id: + url = '/applications/{application_id}/guilds/{guild_id}/commands' + if command_id: + url += f'/{command_id}' + r = Route( + 'GET', + f'{url}?with_localizations=true', + application_id=application_id, + guild_id=guild_id, + command_id=command_id + ) + else: + r = Route('GET', f'{url}?with_localizations=true', application_id=application_id, guild_id=guild_id) + else: + url = '/applications/{application_id}/commands' + if command_id: + url += f'/{command_id}' + r = Route('GET', f'{url}?with_localizations=true', application_id=application_id, command_id=command_id) + else: + r = Route('GET', f'{url}?with_localizations=true', application_id=application_id) + return self.request(r) + + def create_application_command(self, application_id, data, guild_id=None): + if guild_id: + r = Route( + 'POST', + '/applications/{application_id}/guilds/{guild_id}/commands', + application_id=application_id, + guild_id=guild_id + ) + else: + r = Route('POST', '/applications/{application_id}/commands', application_id=application_id) + return self.request(r, json=data) + + def edit_application_command(self, application_id, command_id, data, guild_id=None): + if guild_id: + r = Route('PATCH', '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}', + application_id=application_id, guild_id=guild_id, command_id=command_id + ) + else: + r = Route('PATCH', '/applications/{application_id}/commands/{command_id}', + application_id=application_id, command_id=command_id + ) + return self.request(r, json=data) + + def delete_application_command(self, application_id, command_id, guild_id=None): + if guild_id: + r = Route('DELETE', '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}', + application_id=application_id, guild_id=guild_id, command_id=command_id + ) + else: + r = Route('DELETE', '/applications/{application_id}/commands/{command_id}', + application_id=application_id, command_id=command_id + ) + return self.request(r) + + def bulk_overwrite_application_commands(self, application_id, data, guild_id=None): + if guild_id: + r = Route('PUT', '/applications/{application_id}/guilds/{guild_id}/commands', + application_id=application_id, guild_id=guild_id + ) + else: + r = Route('PUT', '/applications/{application_id}/commands', application_id=application_id) + return self.request(r, json=data) + + def get_guild_application_command_permissions(self, application_id, guild_id, command_id=None): + if command_id: + r = Route('GET', '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}/permissions', + application_id=application_id, guild_id=guild_id, command_id=command_id + ) + else: + r = Route('GET', '/applications/{application_id}/guilds/{guild_id}/commands/permissions', + application_id=application_id, guild_id=guild_id + ) + return self.request(r) + + def edit_application_command_permissions(self, application_id, guild_id, command_id, data): + r = Route( + 'PUT', + '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}/permissions', + application_id=application_id, + guild_id=guild_id, + command_id=command_id + ) + return self.request(r, json=data) + + # Interaction management + def post_initial_response(self, interaction_id: int, token: str, data: Dict[str, Any]): + return self.request(Route("POST", f'/interactions/{interaction_id}/{token}/callback'), json=data) + + def send_interaction_response( + self, + interaction_id: int, + token: str, + params: MultipartParameters, + ): + r = Route('POST', f"/interactions/{interaction_id}/{token}/callback") + if params.files: + return self.request(r, files=params.files, form=params.multipart) + else: + return self.request(r, json=params.payload) + + def edit_original_interaction_response( + self, + token: str, + application_id: int, + params: MultipartParameters + ): + r = Route('PATCH', f'/webhooks/{application_id}/{token}/messages/@original') + if params.files: + return self.request(r, files=params.files, form=params.multipart) + else: + return self.request(r, json=params.payload) + + def send_followup( + self, + token: str, + application_id: int, + params: MultipartParameters, + ): + r = Route('POST', f'/webhooks/{application_id}/{token}') + if params.files: + return self.request(r, files=params.files, form=params.multipart) + else: + return self.request(r, json=params.payload) + + def edit_followup( + self, + application_id: int, + token: str, + message_id: int, + params: MultipartParameters + ): + r = Route('PATCH', f'/webhooks/{application_id}/{token}/messages/{message_id}') + if params.files: + return self.request(r, files=params.files, form=params.multipart) + else: + return self.request(r, json=params.payload) + + def get_original_interaction_response(self, token: str, application_id: int): + r = Route('GET', f'/webhooks/{application_id}/{token}/messages/@original') + return self.request(r) + + def get_followup_message(self, token: str, application_id: int, message_id: int): + r = Route('GET', f'/webhooks/{application_id}/{token}/messages/{message_id}') + return self.request(r) + + def delete_interaction_response(self, token: str, application_id: int, message_id: int = '@original'): + r = Route('DELETE', f'/webhooks/{application_id}/{token}/messages/{message_id}') + return self.request(r) + + def send_autocomplete_callback(self, token: str, interaction_id: int, choices: list): + r = Route('POST', f'/interactions/{interaction_id}/{token}/callback') + data = {'data': {'choices': choices}, 'type': 8} + return self.request(r, json=data) + + # AutoMod management + def get_automod_rules(self, guild_id: int): + return self.request(Route('GET', '/guilds/{guild_id}/auto-moderation/rules', guild_id=guild_id)) + + def get_automod_rule(self, guild_id: int, rule_id: int): + return self.request( + Route('GET', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id) + ) + + def create_automod_rule(self, guild_id: int, data: dict, reason: str = None): + r = Route('POST', '/guilds/{guild_id}/auto-moderation/rules', guild_id=guild_id) + return self.request(r, json=data, reason=reason) + + def edit_automod_rule(self, guild_id: int, rule_id: int, fields: Dict[str, Any], reason: Optional[str] = None): + r = Route('PATCH', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id) + return self.request(r, json=fields, reason=reason) + + def delete_automod_rule(self, guild_id: int, rule_id: int, reason: str = None): + r = Route('DELETE', '/guilds/{guild_id}/auto-moderation/rules/{rule_id}', guild_id=guild_id, rule_id=rule_id) + return self.request(r, reason=reason) + + # Onboarding management + def get_guild_onboarding(self, guild_id: int) -> Response[guild.Onboarding]: + return self.request(Route('GET', '/guilds/{guild_id}/onboarding', guild_id=guild_id)) + + def edit_guild_onboarding( + self, + guild_id: int, + *, + prompts: Optional[List[guild.OnboardingPrompt]] = None, + default_channel_ids: Optional[SnowflakeID] = None, + enabled: Optional[bool] = None, + mode: Optional[guild.OnboardingMode] = None, + reason: Optional[str] = None + ) -> Response[guild.Onboarding]: + payload = {} + + if prompts is not None: + payload['prompts'] = prompts + + if default_channel_ids is not None: + payload['default_channel_ids'] = default_channel_ids + + if enabled is not None: + payload['enabled'] = enabled + + if mode is not None: + payload['mode'] = mode + + return self.request( + Route('PUT', '/guilds/{guild_id}/onboarding', guild_id=guild_id), + json=payload, + reason=reason + ) + + # Misc + def application_info(self): + return self.request(Route('GET', '/oauth2/applications/@me')) + + def list_entitlements( + self, + application_id: int, + *, + limit: int = 100, + user_id: int = MISSING, + guild_id: int = MISSING, + sku_ids: List[int] = MISSING, + after: int = MISSING, + before: int = MISSING, + exclude_ended: int = False + ) -> Response[List[monetization.Entitlement]]: + params = {'limit': limit} + + if user_id is not MISSING: + params['user_id'] = str(user_id) + if guild_id is not MISSING: # FIXME: Can both be passed at the same time? Consider using elif instead + params['guild_id'] = str(guild_id) + if sku_ids is not MISSING: + params['sku_ids'] = [str(s) for s in sku_ids] + if after is not MISSING: + params['after'] = str(after) + if before is not MISSING: # FIXME: Can both be passed at the same time? Consider using elif instead + params['before'] = str(before) + if exclude_ended is not False: # TODO: what is the api default value? + params['exclude_ended'] = str(exclude_ended) + + r = Route('GET', '/applications/{application_id}/entitlements', application_id=application_id) + return self.request(r, json=params) + + def create_test_entitlement( + self, + application_id: int, + *, + sku_id: int, + owner_id: int, + owner_type: int + ) -> Response[monetization.TestEntitlement]: + payload = { + 'sku_id': sku_id, + 'owner_id': owner_id, + 'owner_type': owner_type + } + r = Route('POST', '/applications/{application_id}/entitlements', application_id=application_id) + return self.request(r, json=payload) + + def delete_test_entitlement(self, application_id: int, entitlement_id: int) -> Response[None]: + r = Route( + 'DELETE', + '/applications/{application_id}/entitlements/{entitlement_id}', + application_id=application_id, + entitlement_id=entitlement_id + ) + return self.request(r) + + def consume_entitlement(self, application_id: int, entitlement_id: int) -> Response[None]: + r = Route( + 'POST', + '/applications/{application_id}/entitlements/{entitlement_id}/consume', + application_id=application_id, + entitlement_id=entitlement_id + ) + return self.request(r) + + async def get_gateway(self, *, encoding='json', v=10, zlib=True): + try: + data = await self.request(Route('GET', '/gateway')) + except HTTPException as exc: + raise GatewayNotFound() from exc + if zlib: + value = '{0}?encoding={1}&v={2}&compress=zlib-stream' + else: + value = '{0}?encoding={1}&v={2}' + return value.format(data['url'], encoding, v) + + async def get_bot_gateway(self, *, encoding='json', v=10, zlib=True): + try: + data = await self.request(Route('GET', '/gateway/bot')) + except HTTPException as exc: + raise GatewayNotFound() from exc + + if zlib: + value = '{0}?encoding={1}&v={2}&compress=zlib-stream' + else: + value = '{0}?encoding={1}&v={2}' + return data['shards'], value.format(data['url'], encoding, v) + + def get_voice_regions(self): + return self.request(Route('GET', '/voice/regions')) + + def get_user(self, user_id: int): + return self.request(Route('GET', '/users/{user_id}', user_id=user_id)) + + def get_user_profile(self, user_id: int): + return self.request(Route('GET', '/users/{user_id}/profile', user_id=user_id)) + + def get_all_nitro_stickers(self): + return self.request(Route('GET', '/sticker-packs')) \ No newline at end of file diff --git a/discord/soundboard.py b/discord/soundboard.py new file mode 100644 index 00000000..e20ce9b5 --- /dev/null +++ b/discord/soundboard.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from pydub import AudioSegment +from typing import Optional, Union +import io +import os +import base64 +import mimetypes +from pathlib import Path +import random + +from .mixins import Hashable +from .abc import Snowflake +from .utils import get as utils_get, snowflake_time + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from datetime import datetime + from .state import ConnectionState + from .guild import Guild + +__all__ = ( + 'SoundboardSound', +) + +class SoundboardSound(Hashable): + """Represents a Soundboard Sound. + + .. versionadded:: 1.7.5.4 + + .. container:: operations + + .. describe:: str(x) + + Returns the name of the SoundboardSounds. + + .. describe:: x == y + + Checks if the Soundboard Sound is equal to another Soundboard Sound. + + .. describe:: hash(x) + + Enables use in sets or as a dictionary key. + + Attributes + ---------- + name: :class:`str` + The sound's name. + sound_id: :class:`int` + The id of the sound. + volume: :class:`float` + The volume of the sound. + emoji_id: :class:`int` + The id for the sound's emoji. + emoji_name: :class:`str` + The name for the sound's emoji. + guild_id: :class:`int` + The id of the guild which this sound's belongs to. + available: :class:`bool` + Whether this guild sound can be used + user: :class:`User` + The user that uploaded the guild sound + """ + + __slots__ = ('sound_id', 'name', 'volume', 'emoji_id', 'emoji_name', 'guild', 'guild_id', 'available', 'user', '_state') + + if TYPE_CHECKING: + name: str + sound_id: SnowflakeID + volume: float + emoji_id: NotRequired[Optional[SnowflakeID]] + emoji_name: NotRequired[Optional[str]] + guild_id: NotRequired[int] + available: bool + user: NotRequired[User] + + def __init__(self, *, guild: Guild, state: ConnectionState, data): + self.guild = guild + self._state = state + self._from_data(data) + # TODO: add Cache + + def __repr__(self): + return f"" + + def __str__(self): + return self.name + + def __eq__(self, other): + return isinstance(other, SoundboardSound) and self.id == other.id + + def __hash__(self): + return hash((self.name, self.sound_id)) + + @property + def created_at(self) -> datetime: + """:class:`datetime.datetime`: Returns the sound's creation time in UTC as a naive datetime.""" + return snowflake_time(self.sound_id) + + @staticmethod + def _auto_trim(input_path: Union[str, bytes, io.IOBase, Path], max_duration_sec: float = 5, max_size_bytes: int = 512 * 1024) -> bytes: + if isinstance(input_path, str): + if input_path.startswith("data:"): + try: + b64_data = input_path.split(",", 1)[1] + input_path = base64.b64decode(b64_data) + except Exception as e: + raise ValueError(f"Invalid base64 data URI: {e}") + elif os.path.exists(input_path): + input_path = Path(input_path) + else: + try: + input_path = base64.b64decode(input_path) + except Exception: + raise ValueError("Invalid base64 string or path") + + if isinstance(input_path, Path): + audio = AudioSegment.from_file(str(input_path)) + elif isinstance(input_path, bytes): + audio = AudioSegment.from_file(io.BytesIO(input_path)) + elif isinstance(input_path, io.IOBase): + input_path.seek(0) + audio = AudioSegment.from_file(input_path) + else: + raise TypeError("Unsupported input type") + + duration_ms = len(audio) + max_duration_ms = int(max_duration_sec * 1000) + + if duration_ms > max_duration_ms: + start = random.randint(0, duration_ms - max_duration_ms) + audio = audio[start:start + max_duration_ms] + else: + audio = audio[:max_duration_ms] + + buffer = io.BytesIO() + audio.export(buffer, format="mp3", parameters=["-t", "5", "-write_xing", "0"]) + return buffer.getvalue() + + @staticmethod + def _encode_sound(sound: Union[str, bytes, io.IOBase, Path]) -> str: + raw: bytes + mime_type: str = "audio/ogg" + sound_duration: float + + if isinstance(sound, bytes): + raw = sound + + elif isinstance(sound, io.IOBase): + raw = sound.read() + + elif isinstance(sound, Path): + file_size = os.path.getsize(sound) + if file_size > 512 * 1024: + raise ValueError("The audio file exceeds the maximum file size of 512 KB") + + with open(sound, 'rb') as f: + raw = f.read() + + try: + audio = AudioSegment.from_file(sound) + sound_duration = audio.duration_seconds + except Exception as e: + raise ValueError(f"Error loading the audio file: {e}") + + if sound_duration > 5.2: + raise ValueError("The audio file exceeds the maximum duration of 5.2 seconds") + + mime_type, _ = mimetypes.guess_type(sound) + if mime_type is None: + mime_type = "audio/ogg" + elif sound.suffix == ".mp3": + mime_type = "audio/mpeg" + + elif isinstance(sound, str): + if sound.startswith("data:"): + return sound + try: + base64.b64decode(sound, validate=True) + return f"data:audio/ogg;base64,{sound}" + except Exception: + raise ValueError("Invalid Base64-String") + + else: + raise ValueError("Invalid sound type") + + encoded = base64.b64encode(raw).decode("utf-8") + return f"data:{mime_type};base64,{encoded}" + + def _from_data(self, data): + self.name = data['name'] + self.sound_id = int(data['sound_id']) + self.volume = float(data['volume']) + + self.emoji_id = int(data['emoji_id']) if data.get('emoji_id') else None + self.emoji_name = data.get('emoji_name') + + self.guild_id = self.guild.id + + self.available = data.get('available', True) + + user = data.get('user') + if user: + self.user = self._state.get_user(int(user['id'])) + else: + self.user = None + + @classmethod + def _from_list(cls, guild, state, data_list): + return [cls(guild=guild, state=state, data=data) for data in data_list] + From 4826b7e7ebcae828bc62ee37d34c25d720f62b0a Mon Sep 17 00:00:00 2001 From: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> Date: Fri, 9 May 2025 18:30:38 +0200 Subject: [PATCH 03/21] Add files via upload Signed-off-by: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> From 35b94f677390ea27c1aba3a1f01d8bb8b51b9024 Mon Sep 17 00:00:00 2001 From: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> Date: Fri, 9 May 2025 18:31:23 +0200 Subject: [PATCH 04/21] Add files via upload Signed-off-by: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> --- discord/types/guild.py | 727 +++++++++++++++++++++-------------------- 1 file changed, 369 insertions(+), 358 deletions(-) diff --git a/discord/types/guild.py b/discord/types/guild.py index 4ff147ca..b96fce82 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -1,358 +1,369 @@ -# -*- coding: utf-8 -*- - -""" -The MIT License (MIT) - -Copyright (c) 2021-present mccoderpy - -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. -""" -from __future__ import annotations - -from typing import ( - Type, - List, - Optional -) - -from typing_extensions import ( - Final, - Literal, - NotRequired, - TypedDict -) - -from .channel import GuildChannel -from .emoji import BaseEmoji, PartialEmoji -from .snowflake import SnowflakeID, SnowflakeList -from .sticker import GuildSticker -from .user import User, Member - -__all__ = ( - 'PermissionFlag', - 'UnavailableGuild', - 'PartialGuild', - 'Guild', - 'GuildPreview', - 'GuildWidget', - 'GuildWidgetSettings', - 'Role', - 'RoleTag', - 'OnboardingPrompt', - 'OnboardingPromptOption', - 'Onboarding', - 'WelcomeScreen', - 'WelcomeScreenChannel', - 'ScheduledEventEntityMetadata', - 'ScheduledEvent', -) - -DefaultMessageNotificationLevel = Literal[0, 1] -ExplicitContentFilterLevel = Literal[0, 1, 2] -MFALevel = Literal[0, 1] -AfkTimeout = Literal[60, 300, 900, 1800, 3600] -VerificationLevel = Literal[0, 1, 2, 3, 4] -GuildNSFWLevel = Literal[0, 1, 2, 3] -PremiumTier = Literal[0, 1, 2, 3] -OnlineStatus = Literal['online', 'idle', 'dnd', 'offline'] -GuildFeature: Final[Type[str]] = Literal[ - 'ANIMATED_BANNER', - 'ANIMATED_ICON', - 'APPLICATION_COMMAND_PERMISSIONS_V2', - 'AUTO_MODERATION', - 'BANNER', - 'COMMERCE', - 'COMMUNITY', - 'DISCOVERABLE', - 'ENABLED_DISCOVERABLE_BEFORE', - 'FORCE_RELAY', - 'RELAY_ENABLED', - 'INVITE_SPLASH', - 'MEMBER_VERIFICATION_GATE_ENABLED', - 'MORE_EMOJI', - 'NEWS', - 'PARTNERED', - 'VERIFIED', - 'VANITY_URL', - 'VIP_REGIONS', - 'WELCOME_SCREEN_ENABLED', - 'DISCOVERY_DISABLED', - 'PREVIEW_ENABLED', - 'MORE_STICKERS', - 'MONETIZATION_ENABLED', - 'TICKETING_ENABLED', - 'HUB', - 'LINKED_TO_HUB', - 'HAS_DIRECTORY_ENTRY', - 'THREE_DAY_THREAD_ARCHIVE', - 'SEVEN_DAY_THREAD_ARCHIVE', - 'PRIVATE_THREADS', - 'THREADS_ENABLED', - 'ROLE_ICONS', - 'INTERNAL_EMPLOYEE_ONLY', - 'PREMIUM_TIER_3_OVERRIDE', - 'FEATUREABLE', - 'MEMBER_PROFILES' - 'APPEALABLE', - 'ROLE_SUBSCRIPTIONS_ENABLED', - 'ROLE_SUBSCRIPTIONS_ENABLED_FOR_PURCHASE', -] -PermissionFlag: Final[Type[str]] = Literal[ - 'create_instant_invite', - 'kick_members', - 'ban_members', - 'administrator', - 'manage_channels', - 'manage_guild', - 'add_reactions', - 'view_audit_log', - 'priority_speaker', - 'stream', - 'read_messages', - 'send_messages', - 'send_tts_messages', - 'manage_messages', - 'embed_links', - 'attach_files', - 'read_message_history', - 'mention_everyone', - 'external_emojis', - 'view_guild_insights', - 'connect', - 'speak', - 'mute_members', - 'deafen_members', - 'move_members', - 'use_voice_activation', - 'change_nickname', - 'manage_nicknames', - 'manage_roles', - 'manage_webhooks', - 'manage_expressions', - 'create_expressions', - 'use_slash_commands', - 'request_to_speak', - 'manage_events', - 'create_events', - 'manage_threads', - 'create_public_threads', - 'create_private_threads', - 'use_external_stickers', - 'send_messages_in_threads', - 'start_embedded_activities', - 'moderate_members', - 'view_creator_monetization_analytics', - 'use_soundboard', - 'use_external_sounds', - 'send_voice_messages', - 'set_voice_channel_status', -] -ScheduledEventPrivacyLevel = Literal[2] -ScheduledEventStatus = Literal[1, 2, 3, 4] -ScheduledEntityType = Literal[1, 2, 3] -OnboardingMode = Literal[0, 1] -OnboardingPromptType = Literal[0, 1] - -class UnavailableGuild(TypedDict): - id: SnowflakeID - unavailable: bool - - -class PartialGuild(TypedDict): - id: SnowflakeID - name: str - features: List[str] - icon: NotRequired[str] - owner: NotRequired[bool] - permissions: NotRequired[str] - - -class RoleTag(TypedDict): - bot_id: NotRequired[SnowflakeID] - integration_id: NotRequired[SnowflakeID] - premium_subscriber: NotRequired[Literal[None]] - subscription_listing_id: NotRequired[SnowflakeID] - available_for_purchase: NotRequired[Literal[None]] - guild_connection: NotRequired[Literal[None]] - - -class Role(TypedDict): - id: SnowflakeID - name: str - color: int - hoist: bool - icon: NotRequired[Optional[str]] - unicode_emoji: NotRequired[Optional[str]] - position: int - permissions: str - mentionable: bool - tags: NotRequired[RoleTag] - - -class IncidentsData(TypedDict): - invites_disabled_until: Optional[str] - dms_disabled_until: Optional[str] - - -class Guild(UnavailableGuild): - name: str - icon: str - splash: Optional[str] - discovery_splash: Optional[str] - owner: NotRequired[bool] - owner_id: SnowflakeID - permissions: NotRequired[str] - afk_channel_id: Optional[SnowflakeID] - afk_timeout: AfkTimeout - member_count: NotRequired[int] - widget_enabled: NotRequired[bool] - widget_channel_id: NotRequired[Optional[SnowflakeID]] - verification_level: VerificationLevel - default_message_notifications: DefaultMessageNotificationLevel - explicit_content_filter: ExplicitContentFilterLevel - roles: List[Role] - emojis: List[BaseEmoji] - features: List[GuildFeature] - mfa_level: MFALevel - application_id: Optional[SnowflakeID] - system_channel_id: Optional[SnowflakeID] - system_channel_flags: int - rules_channel_id: Optional[SnowflakeID] - max_presences: NotRequired[Optional[int]] - max_members: NotRequired[int] - vanity_url_code: Optional[str] - description: Optional[str] - banner: Optional[str] - premium_tier: PremiumTier - premium_subscription_count: NotRequired[int] - preferred_locale: str - public_updates_channel_id: Optional[SnowflakeID] - safety_alerts_channel_id: Optional[SnowflakeID] - max_video_channel_users: NotRequired[int] - approximate_member_count: NotRequired[int] - approximate_presence_count: NotRequired[int] - welcome_screen: NotRequired[WelcomeScreen] - nsfw_level: GuildNSFWLevel - stickers: NotRequired[List[GuildSticker]] - premium_progress_bar_enabled: bool - incidents_data: NotRequired[IncidentsData] - members: NotRequired[List[Member]] - - -class WelcomeScreenChannel(TypedDict): - channel_id: SnowflakeID - description: str - emoji_id: Optional[SnowflakeID] - emoji_name: Optional[str] - - -class WelcomeScreen(TypedDict): - description: str - welcome_channels: List[WelcomeScreenChannel] - - -class GuildPreview(TypedDict): - id: SnowflakeID - name: str - icon: Optional[str] - splash: Optional[str] - discovery_splash: Optional[str] - emojis: List[BaseEmoji] - features: List[GuildFeature] - approximate_member_count: int - approximate_presence_count: int - description: Optional[str] - stickers: List[GuildSticker] - - -class GuildWidgetSettings(TypedDict): - enabled: bool - channel_id: Optional[SnowflakeID] - - -class GuildWidgetUser(TypedDict): - id: SnowflakeID - username: str - discriminator: Literal['0000'] - avatar: Literal[None] - status: OnlineStatus - bot: bool - avatar_url: str - - -class OnboardingPromptOption(TypedDict): - id: SnowflakeID - channel_ids: SnowflakeList - role_ids: SnowflakeList - emoji: PartialEmoji - title: str - description: Optional[str] - - -class OnboardingPrompt(TypedDict): - id: SnowflakeID - type: OnboardingPromptType - options: List[OnboardingPromptOption] - title: str - single_select: bool - required: bool - in_onboarding: bool - - -class Onboarding(TypedDict): - guild_id: SnowflakeID - prompts: List[OnboardingPrompt] - default_channel_ids: SnowflakeList - enabled: bool - mode: OnboardingMode - # There are some other fields for storing the seen prompts of the current user - # and their selected options, but as bots can't use onboarding itself, we don't them - - -class GuildWidget(TypedDict): - id: SnowflakeID - name: str - instant_invite: Optional[str] - channels: List[GuildChannel] - members: List[GuildWidgetUser] - presence_count: int - - -class ScheduledEventEntityMetadata(TypedDict, total=False): - location: str - - -class ScheduledEvent(TypedDict): - id: SnowflakeID - guild_id: SnowflakeID - name: str - channel_id: Optional[SnowflakeID] - creator_id: NotRequired[Optional[SnowflakeID]] - description: NotRequired[Optional[str]] - scheduled_start_time: str - scheduled_end_time: Optional[str] - privacy_level: ScheduledEventPrivacyLevel - status: ScheduledEventStatus - entity_type: ScheduledEntityType - entity_id: Optional[SnowflakeID] - entity_metadata: Optional[ScheduledEventEntityMetadata] - creator: NotRequired[User] - user_count: NotRequired[int] - image: NotRequired[Optional[str]] - broadcast_to_directory_channels: NotRequired[bool] \ No newline at end of file +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2021-present mccoderpy + +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. +""" +from __future__ import annotations + +from typing import ( + Type, + List, + Optional +) + +from typing_extensions import ( + Final, + Literal, + NotRequired, + TypedDict +) + +from .channel import GuildChannel +from .emoji import BaseEmoji, PartialEmoji +from .snowflake import SnowflakeID, SnowflakeList +from .sticker import GuildSticker +from .user import User, Member + +__all__ = ( + 'PermissionFlag', + 'UnavailableGuild', + 'PartialGuild', + 'Guild', + 'GuildPreview', + 'GuildWidget', + 'GuildWidgetSettings', + 'Role', + 'RoleTag', + 'OnboardingPrompt', + 'OnboardingPromptOption', + 'Onboarding', + 'WelcomeScreen', + 'WelcomeScreenChannel', + 'ScheduledEventEntityMetadata', + 'ScheduledEvent', + 'SoundboardSound' +) + +DefaultMessageNotificationLevel = Literal[0, 1] +ExplicitContentFilterLevel = Literal[0, 1, 2] +MFALevel = Literal[0, 1] +AfkTimeout = Literal[60, 300, 900, 1800, 3600] +VerificationLevel = Literal[0, 1, 2, 3, 4] +GuildNSFWLevel = Literal[0, 1, 2, 3] +PremiumTier = Literal[0, 1, 2, 3] +OnlineStatus = Literal['online', 'idle', 'dnd', 'offline'] +GuildFeature: Final[Type[str]] = Literal[ + 'ANIMATED_BANNER', + 'ANIMATED_ICON', + 'APPLICATION_COMMAND_PERMISSIONS_V2', + 'AUTO_MODERATION', + 'BANNER', + 'COMMERCE', + 'COMMUNITY', + 'DISCOVERABLE', + 'ENABLED_DISCOVERABLE_BEFORE', + 'FORCE_RELAY', + 'RELAY_ENABLED', + 'INVITE_SPLASH', + 'MEMBER_VERIFICATION_GATE_ENABLED', + 'MORE_EMOJI', + 'NEWS', + 'PARTNERED', + 'VERIFIED', + 'VANITY_URL', + 'VIP_REGIONS', + 'WELCOME_SCREEN_ENABLED', + 'DISCOVERY_DISABLED', + 'PREVIEW_ENABLED', + 'MORE_STICKERS', + 'MONETIZATION_ENABLED', + 'TICKETING_ENABLED', + 'HUB', + 'LINKED_TO_HUB', + 'HAS_DIRECTORY_ENTRY', + 'THREE_DAY_THREAD_ARCHIVE', + 'SEVEN_DAY_THREAD_ARCHIVE', + 'PRIVATE_THREADS', + 'THREADS_ENABLED', + 'ROLE_ICONS', + 'INTERNAL_EMPLOYEE_ONLY', + 'PREMIUM_TIER_3_OVERRIDE', + 'FEATUREABLE', + 'MEMBER_PROFILES' + 'APPEALABLE', + 'ROLE_SUBSCRIPTIONS_ENABLED', + 'ROLE_SUBSCRIPTIONS_ENABLED_FOR_PURCHASE', +] +PermissionFlag: Final[Type[str]] = Literal[ + 'create_instant_invite', + 'kick_members', + 'ban_members', + 'administrator', + 'manage_channels', + 'manage_guild', + 'add_reactions', + 'view_audit_log', + 'priority_speaker', + 'stream', + 'read_messages', + 'send_messages', + 'send_tts_messages', + 'manage_messages', + 'embed_links', + 'attach_files', + 'read_message_history', + 'mention_everyone', + 'external_emojis', + 'view_guild_insights', + 'connect', + 'speak', + 'mute_members', + 'deafen_members', + 'move_members', + 'use_voice_activation', + 'change_nickname', + 'manage_nicknames', + 'manage_roles', + 'manage_webhooks', + 'manage_expressions', + 'create_expressions', + 'use_slash_commands', + 'request_to_speak', + 'manage_events', + 'create_events', + 'manage_threads', + 'create_public_threads', + 'create_private_threads', + 'use_external_stickers', + 'send_messages_in_threads', + 'start_embedded_activities', + 'moderate_members', + 'view_creator_monetization_analytics', + 'use_soundboard', + 'use_external_sounds', + 'send_voice_messages', + 'set_voice_channel_status', +] +ScheduledEventPrivacyLevel = Literal[2] +ScheduledEventStatus = Literal[1, 2, 3, 4] +ScheduledEntityType = Literal[1, 2, 3] +OnboardingMode = Literal[0, 1] +OnboardingPromptType = Literal[0, 1] + +class UnavailableGuild(TypedDict): + id: SnowflakeID + unavailable: bool + + +class PartialGuild(TypedDict): + id: SnowflakeID + name: str + features: List[str] + icon: NotRequired[str] + owner: NotRequired[bool] + permissions: NotRequired[str] + + +class RoleTag(TypedDict): + bot_id: NotRequired[SnowflakeID] + integration_id: NotRequired[SnowflakeID] + premium_subscriber: NotRequired[Literal[None]] + subscription_listing_id: NotRequired[SnowflakeID] + available_for_purchase: NotRequired[Literal[None]] + guild_connection: NotRequired[Literal[None]] + + +class Role(TypedDict): + id: SnowflakeID + name: str + color: int + hoist: bool + icon: NotRequired[Optional[str]] + unicode_emoji: NotRequired[Optional[str]] + position: int + permissions: str + mentionable: bool + tags: NotRequired[RoleTag] + + +class IncidentsData(TypedDict): + invites_disabled_until: Optional[str] + dms_disabled_until: Optional[str] + + +class Guild(UnavailableGuild): + name: str + icon: str + splash: Optional[str] + discovery_splash: Optional[str] + owner: NotRequired[bool] + owner_id: SnowflakeID + permissions: NotRequired[str] + afk_channel_id: Optional[SnowflakeID] + afk_timeout: AfkTimeout + member_count: NotRequired[int] + widget_enabled: NotRequired[bool] + widget_channel_id: NotRequired[Optional[SnowflakeID]] + verification_level: VerificationLevel + default_message_notifications: DefaultMessageNotificationLevel + explicit_content_filter: ExplicitContentFilterLevel + roles: List[Role] + emojis: List[BaseEmoji] + features: List[GuildFeature] + mfa_level: MFALevel + application_id: Optional[SnowflakeID] + system_channel_id: Optional[SnowflakeID] + system_channel_flags: int + rules_channel_id: Optional[SnowflakeID] + max_presences: NotRequired[Optional[int]] + max_members: NotRequired[int] + vanity_url_code: Optional[str] + description: Optional[str] + banner: Optional[str] + premium_tier: PremiumTier + premium_subscription_count: NotRequired[int] + preferred_locale: str + public_updates_channel_id: Optional[SnowflakeID] + safety_alerts_channel_id: Optional[SnowflakeID] + max_video_channel_users: NotRequired[int] + approximate_member_count: NotRequired[int] + approximate_presence_count: NotRequired[int] + welcome_screen: NotRequired[WelcomeScreen] + nsfw_level: GuildNSFWLevel + stickers: NotRequired[List[GuildSticker]] + premium_progress_bar_enabled: bool + incidents_data: NotRequired[IncidentsData] + members: NotRequired[List[Member]] + + +class WelcomeScreenChannel(TypedDict): + channel_id: SnowflakeID + description: str + emoji_id: Optional[SnowflakeID] + emoji_name: Optional[str] + + +class WelcomeScreen(TypedDict): + description: str + welcome_channels: List[WelcomeScreenChannel] + + +class GuildPreview(TypedDict): + id: SnowflakeID + name: str + icon: Optional[str] + splash: Optional[str] + discovery_splash: Optional[str] + emojis: List[BaseEmoji] + features: List[GuildFeature] + approximate_member_count: int + approximate_presence_count: int + description: Optional[str] + stickers: List[GuildSticker] + + +class GuildWidgetSettings(TypedDict): + enabled: bool + channel_id: Optional[SnowflakeID] + + +class GuildWidgetUser(TypedDict): + id: SnowflakeID + username: str + discriminator: Literal['0000'] + avatar: Literal[None] + status: OnlineStatus + bot: bool + avatar_url: str + + +class OnboardingPromptOption(TypedDict): + id: SnowflakeID + channel_ids: SnowflakeList + role_ids: SnowflakeList + emoji: PartialEmoji + title: str + description: Optional[str] + + +class OnboardingPrompt(TypedDict): + id: SnowflakeID + type: OnboardingPromptType + options: List[OnboardingPromptOption] + title: str + single_select: bool + required: bool + in_onboarding: bool + + +class Onboarding(TypedDict): + guild_id: SnowflakeID + prompts: List[OnboardingPrompt] + default_channel_ids: SnowflakeList + enabled: bool + mode: OnboardingMode + # There are some other fields for storing the seen prompts of the current user + # and their selected options, but as bots can't use onboarding itself, we don't them + + +class GuildWidget(TypedDict): + id: SnowflakeID + name: str + instant_invite: Optional[str] + channels: List[GuildChannel] + members: List[GuildWidgetUser] + presence_count: int + + +class ScheduledEventEntityMetadata(TypedDict, total=False): + location: str + + +class ScheduledEvent(TypedDict): + id: SnowflakeID + guild_id: SnowflakeID + name: str + channel_id: Optional[SnowflakeID] + creator_id: NotRequired[Optional[SnowflakeID]] + description: NotRequired[Optional[str]] + scheduled_start_time: str + scheduled_end_time: Optional[str] + privacy_level: ScheduledEventPrivacyLevel + status: ScheduledEventStatus + entity_type: ScheduledEntityType + entity_id: Optional[SnowflakeID] + entity_metadata: Optional[ScheduledEventEntityMetadata] + creator: NotRequired[User] + user_count: NotRequired[int] + image: NotRequired[Optional[str]] + broadcast_to_directory_channels: NotRequired[bool] + +class SoundboardSound(TypedDict): + name: str + sound_id: SnowflakeID + volume: float + emoji_id: NotRequired[Optional[SnowflakeID]] + emoji_name: NotRequired[Optional[str]] + guild_id: NotRequired[int] + available: bool + user: NotRequired[User] From 6369c74ec88df7682be6b3de36cf149dd1c29ff4 Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 18:34:40 +0200 Subject: [PATCH 05/21] add import --- docs/requirements.txt | 1 + requirements.txt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 5c9af9f9..533c9478 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -11,3 +11,4 @@ typing-extensions aiohttp>=3.9.1,<4 colorama color-pprint +pydub diff --git a/requirements.txt b/requirements.txt index 47b03688..1e8a8f8e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ attrs multidict idna color-pprint>=0.0.3 -colorama \ No newline at end of file +colorama +pydub \ No newline at end of file From 4dd988624a9b1b31842f2c1404099a9d9ac4b389 Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 18:42:52 +0200 Subject: [PATCH 06/21] Add SoundboardSounds class + Gateway events --- discord/__init__.py | 1 + discord/client.py | 41 +++++++ discord/gateway.py | 41 ++++--- discord/guild.py | 246 +++++++++++++++++++++++++++++++++++++++ discord/http.py | 33 ++++++ discord/soundboard.py | 213 +++++++++++++++++++++++++++++++++ discord/types/guild.py | 13 ++- discord/types/message.py | 22 ++-- 8 files changed, 585 insertions(+), 25 deletions(-) create mode 100644 discord/soundboard.py diff --git a/discord/__init__.py b/discord/__init__.py index fc7bf3c3..9e08e8d3 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -66,6 +66,7 @@ from .sticker import Sticker, GuildSticker, StickerPack from .scheduled_event import GuildScheduledEvent from .monetization import * +from .soundboard import * MISSING = utils.MISSING diff --git a/discord/client.py b/discord/client.py index b1fbcdfa..19a9017c 100644 --- a/discord/client.py +++ b/discord/client.py @@ -81,6 +81,7 @@ from .iterators import GuildIterator, EntitlementIterator from .appinfo import AppInfo from .application_commands import * +from .soundboard import SoundboardSound if TYPE_CHECKING: import datetime @@ -467,12 +468,14 @@ async def _run_event(self, coro: Coro, event_name: str, *args, **kwargs): def _schedule_event(self, coro: Coro, event_name: str, *args, **kwargs) -> _ClientEventTask: wrapped = self._run_event(coro, event_name, *args, **kwargs) + #print(coro, event_name, *args, **kwargs) # Schedules the task return _ClientEventTask(original_coro=coro, event_name=event_name, coro=wrapped, loop=self.loop) def dispatch(self, event: str, *args, **kwargs) -> None: log.debug('Dispatching event %s', event) method = 'on_' + event + #print(method) listeners = self._listeners.get(event) if listeners: @@ -744,6 +747,44 @@ async def request_offline_members(self, *guilds): for guild in guilds: await self._connection.chunk_guild(guild) + async def fetch_soundboard_sounds(self, guild_id): + """|coro| + + Requests all soundboard sounds for the given guilds. + + This method retrieves the list of soundboard sounds from the Discord API for each guild ID provided. + + .. note:: + + You must have the :attr:`~Permissions.manage_guild_expressions` permission + in each guild to retrieve its soundboard sounds. + + Parameters + ---------- + guild_ids: List[:class:`int`] + A list of guild IDs to fetch soundboard sounds from. + + Raises + ------- + HTTPException + Retrieving soundboard sounds failed. + NotFound + One of the provided guilds does not exist or is inaccessible. + Forbidden + Missing permissions to view soundboard sounds in one or more guilds. + + Returns + ------- + Dict[:class:`int`, List[:class:`SoundboardSound`]] + A dictionary mapping each guild ID to a list of its soundboard sounds. + """ + guild = self.get_guild(guild_id) + + data = await self.http.all_soundboard_sounds(guild_id) + data = data["items"] + return SoundboardSound._from_list(guild=guild, state=self._connection, data_list=data) + #await self.ws.request_soundboard_sounds(guild_ids) + # hooks async def _call_before_identify_hook(self, shard_id, *, initial=False): diff --git a/discord/gateway.py b/discord/gateway.py index 9fc18479..caaf23ee 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -268,25 +268,28 @@ class DiscordWebSocket: a connection issue. GUILD_SYNC Send only. Requests a guild sync. + REQUEST_SOUNDBOARD_SOUNDs + Send only. Used to request soundboard sounds for a list of guilds. gateway The gateway we are currently connected to. token The authentication token for discord. """ - DISPATCH = 0 - HEARTBEAT = 1 - IDENTIFY = 2 - PRESENCE = 3 - VOICE_STATE = 4 - VOICE_PING = 5 - RESUME = 6 - RECONNECT = 7 - REQUEST_MEMBERS = 8 - INVALIDATE_SESSION = 9 - HELLO = 10 - HEARTBEAT_ACK = 11 - GUILD_SYNC = 12 + DISPATCH = 0 + HEARTBEAT = 1 + IDENTIFY = 2 + PRESENCE = 3 + VOICE_STATE = 4 + VOICE_PING = 5 + RESUME = 6 + RECONNECT = 7 + REQUEST_MEMBERS = 8 + INVALIDATE_SESSION = 9 + HELLO = 10 + HEARTBEAT_ACK = 11 + GUILD_SYNC = 12 + REQUEST_SOUNDBOARD_SOUNDs = 31 def __init__(self, socket, *, loop): self.socket = socket @@ -718,6 +721,18 @@ async def voice_state(self, guild_id, channel_id, self_mute=False, self_deaf=Fal log.debug('Updating our voice state to %s.', payload) await self.send_as_json(payload) + async def request_soundboard_sounds(self, guild_ids): + if not isinstance(guild_ids, list): + raise TypeError("guild_ids has to be a list.") + + payload = { + 'op': self.REQUEST_SOUNDBOARD_SOUNDs, + 'd': { + 'guild_ids': guild_ids + } + } + await self.send_as_json(payload) + async def close(self, code=4000): if self._keep_alive: self._keep_alive.stop() diff --git a/discord/guild.py b/discord/guild.py index 23271a79..14e9f624 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -42,6 +42,7 @@ TYPE_CHECKING ) from typing_extensions import Literal +import base64 if TYPE_CHECKING: from os import PathLike @@ -93,6 +94,7 @@ from .flags import SystemChannelFlags from .integrations import _integration_factory, Integration from .sticker import GuildSticker +from .soundboard import SoundboardSound from .automod import AutoModRule, AutoModTriggerMetadata, AutoModAction from .application_commands import SlashCommand, MessageCommand, UserCommand, Localizations @@ -1301,6 +1303,250 @@ def pred(m: Member): return utils.find(pred, members) + async def create_soundboard_sound(self, + name: str, + sound: Union[str, bytes, io.IOBase, Path], + volume: Optional[float] = 1.0, + emoji_id: Optional[int] = None, + emoji_name: Optional[str] = None, + auto_trim: bool = False) -> SoundboardSound: + """|coro| + + Creates a new soundboard sound in the guild. + + Requires the ``CREATE_GUILD_EXPRESSIONS`` permission. + Triggers a Guild Soundboard Sound Create Gateway event. + Supports the ``X-Audit-Log-Reason`` header. + + Soundboard sounds must be a valid MP3 or OGG file, + with a maximum size of 512 KB and a maximum duration of 5.2 seconds. + + Examples + ---------- + + :class:`bytes` Rawdata: :: + + with open("sound.mp3", "rb") as f: + sound_bytes = f.read() + + sound = await guild.create_soundboard_sound(name="sound", sound=sound_bytes) + + :class:`str` Base64 encoded: :: + + b64 = base64.b64encode(b"RIFF...").decode("utf-8") + encoded = await guild.create_soundboard_sound(name="sound", sound=b64) + + or with prefix :: + + b64 = base64.b64encode(b"RIFF...").decode("utf-8") + encoded = await guild.create_soundboard_sound(name="sound", sound=f"data:audio/ogg;base64,{b64}") + + :class:`io.IOBase`: :: + + with open("sound.ogg", "rb") as f: + sound = await guild.create_soundboard_sound(name="sound", sound=f) + + Parameters + ---------- + name: :class:`str` + The name of the soundboard sound (2–32 characters). + sound: Union[:class:`str`, :class:`bytes`, :class:`io.IOBase`, :class:`pathlib.Path`] + The MP3 or OGG sound data. Can be a file path, raw bytes, or file-like object. + volume: Optional[:class:`float`] + The volume level of the sound (0.0 to 1.0). Defaults to 1.0. + emoji_id: Optional[:class:`int`] + The ID of a custom emoji to associate with the sound. + emoji_name: Optional[:class:`str`] + The Unicode character of a standard emoji to associate with the sound. + auto_trim: Optional[:class:`bool`] + Takes a random point from the audio material that is max. 5.2 seconds long. + + Raises + ------ + discord.Forbidden + You don't have permission to create this sound. + discord.HTTPException + The creation failed. + ValueError + One of the fields is invalid or the sound exceeds the size/duration limits. + + Returns + ------- + :class:`SoundboardSound` + The created SoundboardSound object. + """ + + if not (2 <= len(name) <= 32): + raise ValueError("Soundboard name must be between 2 and 32 characters.") + + if auto_trim: + sound_trim = SoundboardSound._auto_trim(sound) + _sound = SoundboardSound._encode_sound(sound_trim) + else: + _sound = SoundboardSound._encode_sound(sound) + + data = await self._state.http.create_soundboard_sound(guild_id=self.id, name=name, sound=_sound, volume=volume, emoji_id=emoji_id, emoji_name=emoji_name) + return SoundboardSound(guild=self, state=self._state, data=data) + + async def update_soundboard_sound(self, + name: str, + sound_id: int, + volume: Optional[float] = 1.0, + emoji_id: Optional[int] = None, + emoji_name: Optional[str] = None) -> SoundboardSound: + """|coro| + + Updates an existing soundboard sound in the guild. + + For sounds created by the current user, requires either the ``CREATE_GUILD_EXPRESSIONS`` + or ``MANAGE_GUILD_EXPRESSIONS`` permission. For other sounds, requires the + ``MANAGE_GUILD_EXPRESSIONS`` permission. + + All parameters are optional except ``sound_id``. + Triggers a Guild Soundboard Sound Update Gateway event. + Supports the ``X-Audit-Log-Reason`` header. + + Parameters + ---------- + name: Optional[:class:`str`] + The new name of the sound (2–32 characters). + sound_id: :class:`int` + The ID of the soundboard sound to update. + volume: Optional[:class:`float`] + The volume level of the sound (0.0 to 1.0). + emoji_id: Optional[:class:`int`] + The ID of the emoji to associate with the sound. + emoji_name: Optional[:class:`str`] + The name of the emoji to associate with the sound. + + Raises + ------ + discord.Forbidden + You don't have permission to modify this sound. + discord.HTTPException + The modification failed. + ValueError + One of the fields is invalid or out of range. + + Returns + ------- + :class:`SoundboardSound` + The updated soundboard sound object. + """ + + if not (2 <= len(name) <= 32): + raise ValueError("Soundboard name must be between 2 and 32 characters.") + + data = await self._state.http.update_soundboard_sound(guild_id=self.id, sound_id =sound_id, name=name, volume=volume, emoji_id=emoji_id, emoji_name=emoji_name) + return SoundboardSound(guild=self, state=self._state, data=data) + + async def delete_soundboard_sound(self, sound_id: int) -> SoundboardSound: + """|coro| + + Deletes a soundboard sound from the guild. + + For sounds created by the current user, requires either the ``CREATE_GUILD_EXPRESSIONS`` + or ``MANAGE_GUILD_EXPRESSIONS`` permission. For other sounds, requires the + ``MANAGE_GUILD_EXPRESSIONS`` permission. + + This action triggers a Guild Soundboard Sound Delete Gateway event. + + This endpoint supports the ``X-Audit-Log-Reason`` header. + + Parameters + ---------- + sound_id: :class:`int` + The ID of the soundboard sound to delete. + + Raises + ------ + discord.Forbidden + You don't have permission to delete this sound. + discord.HTTPException + Deleting the sound failed. + + Returns + ------- + :class:`SoundboardSound` + The deleted sound_id. + """ + + await self._state.http.delete_soundboard_sound(guild_id=self.id, sound_id=sound_id) + + async def get_soundboard_sound(self, sound_id: int) -> SoundboardSound: + """|coro| + + Retrieves a specific soundboard sound by its ID. + + Includes the user field if the bot has the ``CREATE_GUILD_EXPRESSIONS`` or ``MANAGE_GUILD_EXPRESSIONS`` permission. + + Parameters + ---------- + sound_id: :class:`int` + The ID of the sound to retrieve. + + Raises + ------ + discord.Forbidden + You do not have permission to fetch this sound. + discord.NotFound + The sound with the given ID does not exist. + discord.HTTPException + Fetching the sound failed. + + Returns + ------- + :class:`SoundboardSound` + The retrieved SoundboardSound object. + """ + + data = await self._state.http.get_soundboard_sound(guild_id=self.id, sound_id=sound_id) + return SoundboardSound(guild=self, state=self._state, data=data) + + async def all_soundboard_sound(self) -> SoundboardSound: + """|coro| + + Retrieves a list of all soundboard sounds in the guild. + + If the bot has either the ``CREATE_GUILD_EXPRESSIONS`` or ``MANAGE_GUILD_EXPRESSIONS`` permission, + the returned sounds will include user-related metadata. + + Raises + ------ + discord.Forbidden + You do not have permission to fetch the soundboard sounds. + discord.HTTPException + Fetching the soundboard sounds failed. + + Returns + ------- + List[:class:`SoundboardSound`] + A list of soundboard sounds available in the guild. + """ + + data = await self._state.http.all_soundboard_sounds(guild_id=self.id) + data = data["items"] + return SoundboardSound._from_list(guild=self, state=self._state, data_list=data) + + async def default_soundboard_sounds(self) -> SoundboardSound: + """|coro| + + Returns the default global soundboard sounds available to all users. + + Raises + ------ + discord.HTTPException + Fetching the sounds failed. + + Returns + ------- + List[:class:`SoundboardSound`] + A list of default SoundboardSound objects. + """ + + data = await self._state.http.default_soundboard_sounds() + return SoundboardSound._from_list(guild=self, state=self._state, data_list=data) + def _create_channel( self, name: str, diff --git a/discord/http.py b/discord/http.py index ee23847a..957207a9 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1190,6 +1190,39 @@ def follow_webhook(self, channel_id, webhook_channel_id, reason=None): # Guild management + def create_soundboard_sound(self, guild_id, volume=1.0, emoji_id=None, emoji_name=None, *, name, sound): + payload = { + "name": name, + "sound": sound, + "volume": volume, + "emoji_id": emoji_id, + "emoji_name": emoji_name + } + + return self.request(Route("POST", "/guilds/{guild_id}/soundboard-sounds", guild_id=guild_id), json=payload) + + def update_soundboard_sound(self, guild_id, sound_id, emoji_id=None, emoji_name=None, *, name, volume): + payload = { + "name": name, + "volume": volume, + "emoji_id": emoji_id, + "emoji_name": emoji_name + } + + return self.request(Route("PATCH", "/guilds/{guild_id}/soundboard-sounds/{sound_id}", guild_id=guild_id, sound_id=sound_id), json=payload) + + def delete_soundboard_sound(self, guild_id, sound_id): + return self.request(Route("DELETE", "/guilds/{guild_id}/soundboard-sounds/{sound_id}", guild_id=guild_id, sound_id=sound_id)) + + def get_soundboard_sound(self, guild_id, sound_id): + return self.request(Route("GET", "/guilds/{guild_id}/soundboard-sounds/{sound_id}", guild_id=guild_id, sound_id=sound_id)) + + def all_soundboard_sounds(self, guild_id): + return self.request(Route("GET", "/guilds/{guild_id}/soundboard-sounds", guild_id=guild_id)) + + def default_soundboard_sounds(self): + return self.request(Route("GET", "/soundboard-default-sounds")) + def get_guilds(self, limit, before=None, after=None): params = { 'limit': limit diff --git a/discord/soundboard.py b/discord/soundboard.py new file mode 100644 index 00000000..1d7fc320 --- /dev/null +++ b/discord/soundboard.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from pydub import AudioSegment +from typing import Optional, Union +import io +import os +import base64 +import mimetypes +from pathlib import Path +import random + +from .mixins import Hashable +from .abc import Snowflake +from .utils import get as utils_get, snowflake_time + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from datetime import datetime + from .state import ConnectionState + from .guild import Guild + +__all__ = ( + 'SoundboardSound', +) + +class SoundboardSound(Hashable): + """Represents a Soundboard Sound. + + .. versionadded:: 1.7.5.4 + + .. container:: operations + + .. describe:: str(x) + + Returns the name of the SoundboardSounds. + + .. describe:: x == y + + Checks if the Soundboard Sound is equal to another Soundboard Sound. + + .. describe:: hash(x) + + Enables use in sets or as a dictionary key. + + Attributes + ---------- + name: :class:`str` + The sound's name. + sound_id: :class:`int` + The id of the sound. + volume: :class:`float` + The volume of the sound. + emoji_id: :class:`int` + The id for the sound's emoji. + emoji_name: :class:`str` + The name for the sound's emoji. + guild_id: :class:`int` + The id of the guild which this sound's belongs to. + available: :class:`bool` + Whether this guild sound can be used + user: :class:`User` + The user that uploaded the guild sound + """ + + __slots__ = ('sound_id', 'name', 'volume', 'emoji_id', 'emoji_name', 'guild', 'guild_id', 'available', 'user', '_state') + + if TYPE_CHECKING: + name: str + sound_id: SnowflakeID + volume: float + emoji_id: NotRequired[Optional[SnowflakeID]] + emoji_name: NotRequired[Optional[str]] + guild_id: NotRequired[int] + available: bool + user: NotRequired[User] + + def __init__(self, *, guild: Guild, state: ConnectionState, data): + self.guild = guild + self._state = state + self._from_data(data) + # TODO: add Cache + + def __repr__(self): + return f"" + + def __str__(self): + return self.name + + def __eq__(self, other): + return isinstance(other, SoundboardSound) and self.id == other.id + + def __hash__(self): + return hash((self.name, self.sound_id)) + + @property + def created_at(self) -> datetime: + """:class:`datetime.datetime`: Returns the sound's creation time in UTC as a naive datetime.""" + return snowflake_time(self.sound_id) + + @staticmethod + def _auto_trim(input_path: Union[str, bytes, io.IOBase, Path], max_duration_sec: float = 5, max_size_bytes: int = 512 * 1024) -> bytes: + if isinstance(input_path, str): + if input_path.startswith("data:"): + try: + b64_data = input_path.split(",", 1)[1] + input_path = base64.b64decode(b64_data) + except Exception as e: + raise ValueError(f"Invalid base64 data URI: {e}") + elif os.path.exists(input_path): + input_path = Path(input_path) + else: + try: + input_path = base64.b64decode(input_path) + except Exception: + raise ValueError("Invalid base64 string or path") + + if isinstance(input_path, Path): + audio = AudioSegment.from_file(str(input_path)) + elif isinstance(input_path, bytes): + audio = AudioSegment.from_file(io.BytesIO(input_path)) + elif isinstance(input_path, io.IOBase): + input_path.seek(0) + audio = AudioSegment.from_file(input_path) + else: + raise TypeError("Unsupported input type") + + duration_ms = len(audio) + max_duration_ms = int(max_duration_sec * 1000) + + if duration_ms > max_duration_ms: + start = random.randint(0, duration_ms - max_duration_ms) + audio = audio[start:start + max_duration_ms] + else: + audio = audio[:max_duration_ms] + + buffer = io.BytesIO() + audio.export(buffer, format="mp3", parameters=["-t", "5", "-write_xing", "0"]) + return buffer.getvalue() + + @staticmethod + def _encode_sound(sound: Union[str, bytes, io.IOBase, Path]) -> str: + raw: bytes + mime_type: str = "audio/ogg" + sound_duration: float + + if isinstance(sound, bytes): + raw = sound + + elif isinstance(sound, io.IOBase): + raw = sound.read() + + elif isinstance(sound, Path): + file_size = os.path.getsize(sound) + if file_size > 512 * 1024: + raise ValueError("The audio file exceeds the maximum file size of 512 KB") + + with open(sound, 'rb') as f: + raw = f.read() + + try: + audio = AudioSegment.from_file(sound) + sound_duration = audio.duration_seconds + except Exception as e: + raise ValueError(f"Error loading the audio file: {e}") + + if sound_duration > 5.2: + raise ValueError("The audio file exceeds the maximum duration of 5.2 seconds") + + mime_type, _ = mimetypes.guess_type(sound) + if mime_type is None: + mime_type = "audio/ogg" + elif sound.suffix == ".mp3": + mime_type = "audio/mpeg" + + elif isinstance(sound, str): + if sound.startswith("data:"): + return sound + try: + base64.b64decode(sound, validate=True) + return f"data:audio/ogg;base64,{sound}" + except Exception: + raise ValueError("Invalid Base64-String") + + else: + raise ValueError("Invalid sound type") + + encoded = base64.b64encode(raw).decode("utf-8") + return f"data:{mime_type};base64,{encoded}" + + def _from_data(self, data): + self.name = data['name'] + self.sound_id = int(data['sound_id']) + self.volume = float(data['volume']) + + self.emoji_id = int(data['emoji_id']) if data.get('emoji_id') else None + self.emoji_name = data.get('emoji_name') + + self.guild_id = self.guild.id + + self.available = data.get('available', True) + + user = data.get('user') + if user: + self.user = self._state.get_user(int(user['id'])) + else: + self.user = None + + @classmethod + def _from_list(cls, guild, state, data_list): + return [cls(guild=guild, state=state, data=data) for data in data_list] + diff --git a/discord/types/guild.py b/discord/types/guild.py index 4ff147ca..fcec6a3d 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -61,6 +61,7 @@ 'WelcomeScreenChannel', 'ScheduledEventEntityMetadata', 'ScheduledEvent', + 'SoundboardSound' ) DefaultMessageNotificationLevel = Literal[0, 1] @@ -355,4 +356,14 @@ class ScheduledEvent(TypedDict): creator: NotRequired[User] user_count: NotRequired[int] image: NotRequired[Optional[str]] - broadcast_to_directory_channels: NotRequired[bool] \ No newline at end of file + broadcast_to_directory_channels: NotRequired[bool] + +class SoundboardSound(TypedDict): + name: str + sound_id: SnowflakeID + volume: float + emoji_id: NotRequired[Optional[SnowflakeID]] + emoji_name: NotRequired[Optional[str]] + guild_id: NotRequired[int] + available: bool + user: NotRequired[User] diff --git a/discord/types/message.py b/discord/types/message.py index 679e29b1..3d8e7004 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -71,16 +71,16 @@ TextInputStyle = Literal[1, 2] SelectDefaultValueType = Literal['user', 'role', 'channel'] MessageType = Literal[ - 0, # Default - 1, # Recipient Add - 2, # Recipient Remove - 3, # Call - 4, # Channel Name Change - 5, # Channel Icon Change - 6, # Channel Pin - 7, # Guild Member Join - 8, # User Premium Guild Subscription - 9, # User Premium Guild Subscription Tier 1 + 0, # Default + 1, # Recipient Add + 2, # Recipient Remove + 3, # Call + 4, # Channel Name Change + 5, # Channel Icon Change + 6, # Channel Pin + 7, # Guild Member Join + 8, # User Premium Guild Subscription + 9, # User Premium Guild Subscription Tier 1 10, # User Premium Guild Subscription Tier 2 11, # User Premium Guild Subscription Tier 3 12, # Channel Follow Add @@ -101,7 +101,7 @@ 28, # Stage end 29, # Stage speaker change 31, # Stage topic change - 32 # Guild application premium subscription + 32 # Guild application premium subscription ] EmbedType = Literal['rich', 'image', 'video', 'gifv', 'article', 'link'] MessageActivityType = Literal[1, 2, 3, 5] From 437af70d13bd6c90d67701f35557864649800efd Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 19:01:39 +0200 Subject: [PATCH 07/21] kryptographic change --- discord/soundboard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/soundboard.py b/discord/soundboard.py index 1d7fc320..904595a5 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -7,7 +7,7 @@ import base64 import mimetypes from pathlib import Path -import random +import secrets from .mixins import Hashable from .abc import Snowflake @@ -130,7 +130,7 @@ def _auto_trim(input_path: Union[str, bytes, io.IOBase, Path], max_duration_sec: max_duration_ms = int(max_duration_sec * 1000) if duration_ms > max_duration_ms: - start = random.randint(0, duration_ms - max_duration_ms) + start = secrets.randbelow(duration_ms - max_duration_ms + 1) audio = audio[start:start + max_duration_ms] else: audio = audio[:max_duration_ms] From 85754b863947517647f2e35bc2ad3d01bb742656 Mon Sep 17 00:00:00 2001 From: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> Date: Fri, 9 May 2025 19:14:27 +0200 Subject: [PATCH 08/21] Update requirements.txt Signed-off-by: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> --- docs/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index 5c9af9f9..533c9478 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -11,3 +11,4 @@ typing-extensions aiohttp>=3.9.1,<4 colorama color-pprint +pydub From 9f15265d6aa38e0ed29593e280cc01ef1cfbe657 Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 19:15:32 +0200 Subject: [PATCH 09/21] -- --- discord/Makefile | 20 ++++++++++++++++++++ discord/state.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 discord/Makefile diff --git a/discord/Makefile b/discord/Makefile new file mode 100644 index 00000000..d0c3cbf1 --- /dev/null +++ b/discord/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/discord/state.py b/discord/state.py index 60d62b23..15409e0d 100644 --- a/discord/state.py +++ b/discord/state.py @@ -62,6 +62,7 @@ from .automod import AutoModRule, AutoModActionPayload from .interactions import BaseInteraction from .monetization import Entitlement, Subscription +from .soundboard import SoundboardSound if TYPE_CHECKING: from .http import HTTPClient @@ -1359,6 +1360,50 @@ def parse_guild_integrations_update(self, data): else: log.debug('GUILD_INTEGRATIONS_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_soundboard_sounds(self, data): + guild = self._get_guild(int(data['guild_id'])) + soundboard_sounds = data["soundboard_sounds"] + if guild is not None: + self.dispatch('soundboard_sounds', soundboard_sounds, guild) + else: + log.debug('SOUNDBOARD_SOUNDS referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_soundboard_sounds_update(self, data): + print(f"parse_guild_soundboard_sounds_update: {data}") + guild = self._get_guild(int(data['guild_id'])) + sound = SoundboardSound(guild=guild, data=data, state=self) + + self.dispatch('soundboard_sounds_update', sound, guild) + + #log.debug('GUILD_SOUNDBOARD_SOUNDS_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_soundboard_sound_create(self, data): + print(f"parse_guild_soundboard_sound_create: {data}") + guild = self._get_guild(int(data['guild_id'])) + sound = SoundboardSound(guild=guild, data=data, state=self) + + self.dispatch('soundboard_create', sound, guild) + + #log.debug('GUILD_SOUNDBOARD_SOUND_CREATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_soundboard_sound_update(self, data): + print(f"parse_guild_soundboard_sound_update: {data}") + guild = self._get_guild(int(data['guild_id'])) + sound = SoundboardSound(guild=guild, data=data, state=self) + + self.dispatch('soundboard_update', sound, guild) + + #log.debug('GUILD_SOUNDBOARD_SOUND_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_soundboard_sound_delete(self, data): + print(f"parse_guild_soundboard_sound_delete: {data}") + guild = self._get_guild(int(data['guild_id'])) + #sound = SoundboardSound(guild=guild, data=data, state=self) + + self.dispatch('soundboard_delete', data["sound_id"], guild) + + #log.debug('GUILD_SOUNDBOARD_SOUND_DELETE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_webhooks_update(self, data): channel = self.get_channel(int(data['channel_id'])) if channel is not None: From b76bdb6ea76cd0550a20f4affcf1b9372769b4e3 Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 19:45:44 +0200 Subject: [PATCH 10/21] . --- discord/soundboard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/soundboard.py b/discord/soundboard.py index 904595a5..b95c57cc 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -9,6 +9,7 @@ from pathlib import Path import secrets + from .mixins import Hashable from .abc import Snowflake from .utils import get as utils_get, snowflake_time From e317ee00675b3ccc157f2773990932e3b37761c2 Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 19:52:07 +0200 Subject: [PATCH 11/21] . --- discord/application_commands.py | 2 +- discord/gateway.py | 41 ++++-- discord/guild.py | 246 ++++++++++++++++++++++++++++++++ discord/http.py | 33 +++++ discord/make.bat | 35 +++++ discord/soundboard.py | 5 +- discord/state.py | 3 + discord/types/guild.py | 13 +- 8 files changed, 360 insertions(+), 18 deletions(-) create mode 100644 discord/make.bat diff --git a/discord/application_commands.py b/discord/application_commands.py index e0fbe6a1..7856fff4 100644 --- a/discord/application_commands.py +++ b/discord/application_commands.py @@ -1937,7 +1937,7 @@ def __init__( if 32 < len(name) < 1: raise ValueError('The name of the Message-Command has to be 1-32 characters long, got %s.' % len(name)) super().__init__(3, name=name, name_localizations=name_localizations, - default_member_permissions=default_member_permissions, allow_dm=allow_dm, integration_types=integration_types, + default_member_permissions=default_member_permissions, allow_dm=allow_dm, integration_types=inntegration_types, contexts=contexts, **kwargs ) diff --git a/discord/gateway.py b/discord/gateway.py index 9fc18479..caaf23ee 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -268,25 +268,28 @@ class DiscordWebSocket: a connection issue. GUILD_SYNC Send only. Requests a guild sync. + REQUEST_SOUNDBOARD_SOUNDs + Send only. Used to request soundboard sounds for a list of guilds. gateway The gateway we are currently connected to. token The authentication token for discord. """ - DISPATCH = 0 - HEARTBEAT = 1 - IDENTIFY = 2 - PRESENCE = 3 - VOICE_STATE = 4 - VOICE_PING = 5 - RESUME = 6 - RECONNECT = 7 - REQUEST_MEMBERS = 8 - INVALIDATE_SESSION = 9 - HELLO = 10 - HEARTBEAT_ACK = 11 - GUILD_SYNC = 12 + DISPATCH = 0 + HEARTBEAT = 1 + IDENTIFY = 2 + PRESENCE = 3 + VOICE_STATE = 4 + VOICE_PING = 5 + RESUME = 6 + RECONNECT = 7 + REQUEST_MEMBERS = 8 + INVALIDATE_SESSION = 9 + HELLO = 10 + HEARTBEAT_ACK = 11 + GUILD_SYNC = 12 + REQUEST_SOUNDBOARD_SOUNDs = 31 def __init__(self, socket, *, loop): self.socket = socket @@ -718,6 +721,18 @@ async def voice_state(self, guild_id, channel_id, self_mute=False, self_deaf=Fal log.debug('Updating our voice state to %s.', payload) await self.send_as_json(payload) + async def request_soundboard_sounds(self, guild_ids): + if not isinstance(guild_ids, list): + raise TypeError("guild_ids has to be a list.") + + payload = { + 'op': self.REQUEST_SOUNDBOARD_SOUNDs, + 'd': { + 'guild_ids': guild_ids + } + } + await self.send_as_json(payload) + async def close(self, code=4000): if self._keep_alive: self._keep_alive.stop() diff --git a/discord/guild.py b/discord/guild.py index 23271a79..14e9f624 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -42,6 +42,7 @@ TYPE_CHECKING ) from typing_extensions import Literal +import base64 if TYPE_CHECKING: from os import PathLike @@ -93,6 +94,7 @@ from .flags import SystemChannelFlags from .integrations import _integration_factory, Integration from .sticker import GuildSticker +from .soundboard import SoundboardSound from .automod import AutoModRule, AutoModTriggerMetadata, AutoModAction from .application_commands import SlashCommand, MessageCommand, UserCommand, Localizations @@ -1301,6 +1303,250 @@ def pred(m: Member): return utils.find(pred, members) + async def create_soundboard_sound(self, + name: str, + sound: Union[str, bytes, io.IOBase, Path], + volume: Optional[float] = 1.0, + emoji_id: Optional[int] = None, + emoji_name: Optional[str] = None, + auto_trim: bool = False) -> SoundboardSound: + """|coro| + + Creates a new soundboard sound in the guild. + + Requires the ``CREATE_GUILD_EXPRESSIONS`` permission. + Triggers a Guild Soundboard Sound Create Gateway event. + Supports the ``X-Audit-Log-Reason`` header. + + Soundboard sounds must be a valid MP3 or OGG file, + with a maximum size of 512 KB and a maximum duration of 5.2 seconds. + + Examples + ---------- + + :class:`bytes` Rawdata: :: + + with open("sound.mp3", "rb") as f: + sound_bytes = f.read() + + sound = await guild.create_soundboard_sound(name="sound", sound=sound_bytes) + + :class:`str` Base64 encoded: :: + + b64 = base64.b64encode(b"RIFF...").decode("utf-8") + encoded = await guild.create_soundboard_sound(name="sound", sound=b64) + + or with prefix :: + + b64 = base64.b64encode(b"RIFF...").decode("utf-8") + encoded = await guild.create_soundboard_sound(name="sound", sound=f"data:audio/ogg;base64,{b64}") + + :class:`io.IOBase`: :: + + with open("sound.ogg", "rb") as f: + sound = await guild.create_soundboard_sound(name="sound", sound=f) + + Parameters + ---------- + name: :class:`str` + The name of the soundboard sound (2–32 characters). + sound: Union[:class:`str`, :class:`bytes`, :class:`io.IOBase`, :class:`pathlib.Path`] + The MP3 or OGG sound data. Can be a file path, raw bytes, or file-like object. + volume: Optional[:class:`float`] + The volume level of the sound (0.0 to 1.0). Defaults to 1.0. + emoji_id: Optional[:class:`int`] + The ID of a custom emoji to associate with the sound. + emoji_name: Optional[:class:`str`] + The Unicode character of a standard emoji to associate with the sound. + auto_trim: Optional[:class:`bool`] + Takes a random point from the audio material that is max. 5.2 seconds long. + + Raises + ------ + discord.Forbidden + You don't have permission to create this sound. + discord.HTTPException + The creation failed. + ValueError + One of the fields is invalid or the sound exceeds the size/duration limits. + + Returns + ------- + :class:`SoundboardSound` + The created SoundboardSound object. + """ + + if not (2 <= len(name) <= 32): + raise ValueError("Soundboard name must be between 2 and 32 characters.") + + if auto_trim: + sound_trim = SoundboardSound._auto_trim(sound) + _sound = SoundboardSound._encode_sound(sound_trim) + else: + _sound = SoundboardSound._encode_sound(sound) + + data = await self._state.http.create_soundboard_sound(guild_id=self.id, name=name, sound=_sound, volume=volume, emoji_id=emoji_id, emoji_name=emoji_name) + return SoundboardSound(guild=self, state=self._state, data=data) + + async def update_soundboard_sound(self, + name: str, + sound_id: int, + volume: Optional[float] = 1.0, + emoji_id: Optional[int] = None, + emoji_name: Optional[str] = None) -> SoundboardSound: + """|coro| + + Updates an existing soundboard sound in the guild. + + For sounds created by the current user, requires either the ``CREATE_GUILD_EXPRESSIONS`` + or ``MANAGE_GUILD_EXPRESSIONS`` permission. For other sounds, requires the + ``MANAGE_GUILD_EXPRESSIONS`` permission. + + All parameters are optional except ``sound_id``. + Triggers a Guild Soundboard Sound Update Gateway event. + Supports the ``X-Audit-Log-Reason`` header. + + Parameters + ---------- + name: Optional[:class:`str`] + The new name of the sound (2–32 characters). + sound_id: :class:`int` + The ID of the soundboard sound to update. + volume: Optional[:class:`float`] + The volume level of the sound (0.0 to 1.0). + emoji_id: Optional[:class:`int`] + The ID of the emoji to associate with the sound. + emoji_name: Optional[:class:`str`] + The name of the emoji to associate with the sound. + + Raises + ------ + discord.Forbidden + You don't have permission to modify this sound. + discord.HTTPException + The modification failed. + ValueError + One of the fields is invalid or out of range. + + Returns + ------- + :class:`SoundboardSound` + The updated soundboard sound object. + """ + + if not (2 <= len(name) <= 32): + raise ValueError("Soundboard name must be between 2 and 32 characters.") + + data = await self._state.http.update_soundboard_sound(guild_id=self.id, sound_id =sound_id, name=name, volume=volume, emoji_id=emoji_id, emoji_name=emoji_name) + return SoundboardSound(guild=self, state=self._state, data=data) + + async def delete_soundboard_sound(self, sound_id: int) -> SoundboardSound: + """|coro| + + Deletes a soundboard sound from the guild. + + For sounds created by the current user, requires either the ``CREATE_GUILD_EXPRESSIONS`` + or ``MANAGE_GUILD_EXPRESSIONS`` permission. For other sounds, requires the + ``MANAGE_GUILD_EXPRESSIONS`` permission. + + This action triggers a Guild Soundboard Sound Delete Gateway event. + + This endpoint supports the ``X-Audit-Log-Reason`` header. + + Parameters + ---------- + sound_id: :class:`int` + The ID of the soundboard sound to delete. + + Raises + ------ + discord.Forbidden + You don't have permission to delete this sound. + discord.HTTPException + Deleting the sound failed. + + Returns + ------- + :class:`SoundboardSound` + The deleted sound_id. + """ + + await self._state.http.delete_soundboard_sound(guild_id=self.id, sound_id=sound_id) + + async def get_soundboard_sound(self, sound_id: int) -> SoundboardSound: + """|coro| + + Retrieves a specific soundboard sound by its ID. + + Includes the user field if the bot has the ``CREATE_GUILD_EXPRESSIONS`` or ``MANAGE_GUILD_EXPRESSIONS`` permission. + + Parameters + ---------- + sound_id: :class:`int` + The ID of the sound to retrieve. + + Raises + ------ + discord.Forbidden + You do not have permission to fetch this sound. + discord.NotFound + The sound with the given ID does not exist. + discord.HTTPException + Fetching the sound failed. + + Returns + ------- + :class:`SoundboardSound` + The retrieved SoundboardSound object. + """ + + data = await self._state.http.get_soundboard_sound(guild_id=self.id, sound_id=sound_id) + return SoundboardSound(guild=self, state=self._state, data=data) + + async def all_soundboard_sound(self) -> SoundboardSound: + """|coro| + + Retrieves a list of all soundboard sounds in the guild. + + If the bot has either the ``CREATE_GUILD_EXPRESSIONS`` or ``MANAGE_GUILD_EXPRESSIONS`` permission, + the returned sounds will include user-related metadata. + + Raises + ------ + discord.Forbidden + You do not have permission to fetch the soundboard sounds. + discord.HTTPException + Fetching the soundboard sounds failed. + + Returns + ------- + List[:class:`SoundboardSound`] + A list of soundboard sounds available in the guild. + """ + + data = await self._state.http.all_soundboard_sounds(guild_id=self.id) + data = data["items"] + return SoundboardSound._from_list(guild=self, state=self._state, data_list=data) + + async def default_soundboard_sounds(self) -> SoundboardSound: + """|coro| + + Returns the default global soundboard sounds available to all users. + + Raises + ------ + discord.HTTPException + Fetching the sounds failed. + + Returns + ------- + List[:class:`SoundboardSound`] + A list of default SoundboardSound objects. + """ + + data = await self._state.http.default_soundboard_sounds() + return SoundboardSound._from_list(guild=self, state=self._state, data_list=data) + def _create_channel( self, name: str, diff --git a/discord/http.py b/discord/http.py index ee23847a..957207a9 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1190,6 +1190,39 @@ def follow_webhook(self, channel_id, webhook_channel_id, reason=None): # Guild management + def create_soundboard_sound(self, guild_id, volume=1.0, emoji_id=None, emoji_name=None, *, name, sound): + payload = { + "name": name, + "sound": sound, + "volume": volume, + "emoji_id": emoji_id, + "emoji_name": emoji_name + } + + return self.request(Route("POST", "/guilds/{guild_id}/soundboard-sounds", guild_id=guild_id), json=payload) + + def update_soundboard_sound(self, guild_id, sound_id, emoji_id=None, emoji_name=None, *, name, volume): + payload = { + "name": name, + "volume": volume, + "emoji_id": emoji_id, + "emoji_name": emoji_name + } + + return self.request(Route("PATCH", "/guilds/{guild_id}/soundboard-sounds/{sound_id}", guild_id=guild_id, sound_id=sound_id), json=payload) + + def delete_soundboard_sound(self, guild_id, sound_id): + return self.request(Route("DELETE", "/guilds/{guild_id}/soundboard-sounds/{sound_id}", guild_id=guild_id, sound_id=sound_id)) + + def get_soundboard_sound(self, guild_id, sound_id): + return self.request(Route("GET", "/guilds/{guild_id}/soundboard-sounds/{sound_id}", guild_id=guild_id, sound_id=sound_id)) + + def all_soundboard_sounds(self, guild_id): + return self.request(Route("GET", "/guilds/{guild_id}/soundboard-sounds", guild_id=guild_id)) + + def default_soundboard_sounds(self): + return self.request(Route("GET", "/soundboard-default-sounds")) + def get_guilds(self, limit, before=None, after=None): params = { 'limit': limit diff --git a/discord/make.bat b/discord/make.bat new file mode 100644 index 00000000..dc1312ab --- /dev/null +++ b/discord/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/discord/soundboard.py b/discord/soundboard.py index b95c57cc..1d7fc320 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -7,8 +7,7 @@ import base64 import mimetypes from pathlib import Path -import secrets - +import random from .mixins import Hashable from .abc import Snowflake @@ -131,7 +130,7 @@ def _auto_trim(input_path: Union[str, bytes, io.IOBase, Path], max_duration_sec: max_duration_ms = int(max_duration_sec * 1000) if duration_ms > max_duration_ms: - start = secrets.randbelow(duration_ms - max_duration_ms + 1) + start = random.randint(0, duration_ms - max_duration_ms) audio = audio[start:start + max_duration_ms] else: audio = audio[:max_duration_ms] diff --git a/discord/state.py b/discord/state.py index 15409e0d..ad21679f 100644 --- a/discord/state.py +++ b/discord/state.py @@ -25,6 +25,7 @@ """ from __future__ import annotations +import traceback import asyncio from collections import deque, OrderedDict import copy @@ -570,6 +571,8 @@ def parse_subscription_delete(self, data): def parse_message_create(self, data): channel, _ = self._get_guild_channel(data) message = Message(channel=channel, data=data, state=self) + #print("DEBUG: parse_message_create aufgerufen von:") + #traceback.print_stack() self.dispatch('message', message) if self._messages is not None: self._messages.append(message) diff --git a/discord/types/guild.py b/discord/types/guild.py index 4ff147ca..fcec6a3d 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -61,6 +61,7 @@ 'WelcomeScreenChannel', 'ScheduledEventEntityMetadata', 'ScheduledEvent', + 'SoundboardSound' ) DefaultMessageNotificationLevel = Literal[0, 1] @@ -355,4 +356,14 @@ class ScheduledEvent(TypedDict): creator: NotRequired[User] user_count: NotRequired[int] image: NotRequired[Optional[str]] - broadcast_to_directory_channels: NotRequired[bool] \ No newline at end of file + broadcast_to_directory_channels: NotRequired[bool] + +class SoundboardSound(TypedDict): + name: str + sound_id: SnowflakeID + volume: float + emoji_id: NotRequired[Optional[SnowflakeID]] + emoji_name: NotRequired[Optional[str]] + guild_id: NotRequired[int] + available: bool + user: NotRequired[User] From 837027526bd1e314bb5f9d79fe178f3e97c95481 Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 19:56:07 +0200 Subject: [PATCH 12/21] add kryptographic --- discord/soundboard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/soundboard.py b/discord/soundboard.py index 1d7fc320..904595a5 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -7,7 +7,7 @@ import base64 import mimetypes from pathlib import Path -import random +import secrets from .mixins import Hashable from .abc import Snowflake @@ -130,7 +130,7 @@ def _auto_trim(input_path: Union[str, bytes, io.IOBase, Path], max_duration_sec: max_duration_ms = int(max_duration_sec * 1000) if duration_ms > max_duration_ms: - start = random.randint(0, duration_ms - max_duration_ms) + start = secrets.randbelow(duration_ms - max_duration_ms + 1) audio = audio[start:start + max_duration_ms] else: audio = audio[:max_duration_ms] From c51cb27c6973f852f398289c09a208e0952b69ef Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 20:09:52 +0200 Subject: [PATCH 13/21] Updating return type SoundboardSound --- discord/guild.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 14e9f624..754c980c 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -1372,7 +1372,7 @@ async def create_soundboard_sound(self, Returns ------- - :class:`SoundboardSound` + :class:`~SoundboardSound` The created SoundboardSound object. """ @@ -1430,7 +1430,7 @@ async def update_soundboard_sound(self, Returns ------- - :class:`SoundboardSound` + :class:`~SoundboardSound` The updated soundboard sound object. """ @@ -1467,7 +1467,7 @@ async def delete_soundboard_sound(self, sound_id: int) -> SoundboardSound: Returns ------- - :class:`SoundboardSound` + :class:`~SoundboardSound` The deleted sound_id. """ @@ -1496,7 +1496,7 @@ async def get_soundboard_sound(self, sound_id: int) -> SoundboardSound: Returns ------- - :class:`SoundboardSound` + :class:`~SoundboardSound` The retrieved SoundboardSound object. """ @@ -1520,7 +1520,7 @@ async def all_soundboard_sound(self) -> SoundboardSound: Returns ------- - List[:class:`SoundboardSound`] + List[:class:`~SoundboardSound`] A list of soundboard sounds available in the guild. """ @@ -1540,7 +1540,7 @@ async def default_soundboard_sounds(self) -> SoundboardSound: Returns ------- - List[:class:`SoundboardSound`] + List[:class:`~SoundboardSound`] A list of default SoundboardSound objects. """ From 420f3a8228a80134f5b437dd1afa0024fc5c51df Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 20:22:25 +0200 Subject: [PATCH 14/21] Updating return type SoundboardSound --- discord/guild.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 754c980c..14e9f624 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -1372,7 +1372,7 @@ async def create_soundboard_sound(self, Returns ------- - :class:`~SoundboardSound` + :class:`SoundboardSound` The created SoundboardSound object. """ @@ -1430,7 +1430,7 @@ async def update_soundboard_sound(self, Returns ------- - :class:`~SoundboardSound` + :class:`SoundboardSound` The updated soundboard sound object. """ @@ -1467,7 +1467,7 @@ async def delete_soundboard_sound(self, sound_id: int) -> SoundboardSound: Returns ------- - :class:`~SoundboardSound` + :class:`SoundboardSound` The deleted sound_id. """ @@ -1496,7 +1496,7 @@ async def get_soundboard_sound(self, sound_id: int) -> SoundboardSound: Returns ------- - :class:`~SoundboardSound` + :class:`SoundboardSound` The retrieved SoundboardSound object. """ @@ -1520,7 +1520,7 @@ async def all_soundboard_sound(self) -> SoundboardSound: Returns ------- - List[:class:`~SoundboardSound`] + List[:class:`SoundboardSound`] A list of soundboard sounds available in the guild. """ @@ -1540,7 +1540,7 @@ async def default_soundboard_sounds(self) -> SoundboardSound: Returns ------- - List[:class:`~SoundboardSound`] + List[:class:`SoundboardSound`] A list of default SoundboardSound objects. """ From 8ec90eee5ee7c99658b1c00b94ccaa954c00fd90 Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 20:27:22 +0200 Subject: [PATCH 15/21] . --- discord/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/__init__.py b/discord/__init__.py index 9e08e8d3..8a2ee349 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -66,7 +66,7 @@ from .sticker import Sticker, GuildSticker, StickerPack from .scheduled_event import GuildScheduledEvent from .monetization import * -from .soundboard import * +from .soundboard import SoundboardSound MISSING = utils.MISSING From 676f14fe3c81a7cadd7ae8828e50faf72755ce01 Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 21:41:46 +0200 Subject: [PATCH 16/21] Add Documentation for SoundboardSound --- discord/__init__.py | 2 +- discord/state.py | 46 +++++++++++++++++++++++---------------------- docs/api/events.rst | 36 +++++++++++++++++++++++++++++++++++ docs/api/models.rst | 8 ++++++++ 4 files changed, 69 insertions(+), 23 deletions(-) diff --git a/discord/__init__.py b/discord/__init__.py index 8a2ee349..9e08e8d3 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -66,7 +66,7 @@ from .sticker import Sticker, GuildSticker, StickerPack from .scheduled_event import GuildScheduledEvent from .monetization import * -from .soundboard import SoundboardSound +from .soundboard import * MISSING = utils.MISSING diff --git a/discord/state.py b/discord/state.py index ad21679f..b65a01ea 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1363,49 +1363,51 @@ def parse_guild_integrations_update(self, data): else: log.debug('GUILD_INTEGRATIONS_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) - def parse_soundboard_sounds(self, data): - guild = self._get_guild(int(data['guild_id'])) - soundboard_sounds = data["soundboard_sounds"] - if guild is not None: - self.dispatch('soundboard_sounds', soundboard_sounds, guild) - else: - log.debug('SOUNDBOARD_SOUNDS referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + # def parse_soundboard_sounds(self, data): + # guild = self._get_guild(int(data['guild_id'])) + # soundboard_sounds = data["soundboard_sounds"] + # + # if guild is not None: + # self.dispatch('soundboard_sounds', soundboard_sounds, guild) + # else: + # log.debug('SOUNDBOARD_SOUNDS referencing an unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_soundboard_sounds_update(self, data): - print(f"parse_guild_soundboard_sounds_update: {data}") guild = self._get_guild(int(data['guild_id'])) sound = SoundboardSound(guild=guild, data=data, state=self) - self.dispatch('soundboard_sounds_update', sound, guild) - - #log.debug('GUILD_SOUNDBOARD_SOUNDS_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + if guild is not None: + self.dispatch('soundboard_sounds_update', sound, guild) + else: + log.debug('GUILD_SOUNDBOARD_SOUNDS_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_soundboard_sound_create(self, data): - print(f"parse_guild_soundboard_sound_create: {data}") guild = self._get_guild(int(data['guild_id'])) sound = SoundboardSound(guild=guild, data=data, state=self) - self.dispatch('soundboard_create', sound, guild) - - #log.debug('GUILD_SOUNDBOARD_SOUND_CREATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + if guild is not None: + self.dispatch('soundboard_create', sound, guild) + else: + log.debug('GUILD_SOUNDBOARD_SOUND_CREATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_soundboard_sound_update(self, data): - print(f"parse_guild_soundboard_sound_update: {data}") guild = self._get_guild(int(data['guild_id'])) sound = SoundboardSound(guild=guild, data=data, state=self) - self.dispatch('soundboard_update', sound, guild) - - #log.debug('GUILD_SOUNDBOARD_SOUND_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + if guild is not None: + self.dispatch('soundboard_update', sound, guild) + else: + log.debug('GUILD_SOUNDBOARD_SOUND_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_soundboard_sound_delete(self, data): print(f"parse_guild_soundboard_sound_delete: {data}") guild = self._get_guild(int(data['guild_id'])) #sound = SoundboardSound(guild=guild, data=data, state=self) - self.dispatch('soundboard_delete', data["sound_id"], guild) - - #log.debug('GUILD_SOUNDBOARD_SOUND_DELETE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + if guild is not None: + self.dispatch('soundboard_delete', data["sound_id"], guild) + else: + log.debug('GUILD_SOUNDBOARD_SOUND_DELETE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) def parse_webhooks_update(self, data): channel = self.get_channel(int(data['channel_id'])) diff --git a/docs/api/events.rst b/docs/api/events.rst index fe52c04f..18af63d8 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1029,3 +1029,39 @@ to handle it, which defaults to print a traceback and ignoring the exception. :type channel: :class:`VoiceChannel` :param payload: The payload containing info about the effect. :type payload: :class:`VoiceChannelEffectSendEvent` + +.. function:: on_soundboard_sounds_update(sounds, guild): + + Called when multiple guild soundboard sounds are updated. + + :param sounds: A list of sounds being updated. + :type sounds: list of :class:`SoundboardSound` + :param guild: The guild where the sounds were updated. + :type guild: :class:`Guild` + +.. function:: on_soundboard_create(sound, guild): + + Called when a user creates a soundboard sound. + + :param sound: The sound being created. + :type sound: :class:`SoundboardSound` + :param guild: The guild where the sound was created. + :type guild: :class:`Guild` + +.. function:: on_soundboard_update(sound, guild): + + Called when a user updated a soundboard sound. + + :param sound: The sound being updated. + :type sound: :class:`SoundboardSound` + :param guild: The guild where the sound was updated. + :type guild: :class:`Guild` + +.. function:: on_soundboard_delete(sound_id, guild): + + Called when a user deletes a soundboard sound. + + :param sound_id: The sound_id being deleted. + :type sound: :class:`SoundboardSound` + :param guild: The guild where the sound was deleted. + :type guild: :class:`Guild` diff --git a/docs/api/models.rst b/docs/api/models.rst index 7543bf79..5ce01624 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -514,6 +514,14 @@ Sticker :members: :exclude-members: pack, pack_id, sort_value +SoundboardSound +~~~~~~~~ + +.. attributetable:: SoundboardSound + +.. autoclass:: SoundboardSound() + :members: + VoiceRegionInfo ~~~~~~~~~~~~~~~~ From a35abc43a07ed5a0b19ed12ca3b26471d67422d5 Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 21:58:29 +0200 Subject: [PATCH 17/21] Add Documentation for SoundboardSound --- docs/api/events.rst | 72 ++++++++++++++++++++++----------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/docs/api/events.rst b/docs/api/events.rst index 18af63d8..6d93bc06 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -64,6 +64,42 @@ to handle it, which defaults to print a traceback and ignoring the exception. :param shard_id: The shard ID that has connected. :type shard_id: :class:`int` +.. function:: on_soundboard_sounds_update(sounds, guild): + + Called when multiple guild soundboard sounds are updated. + + :param sounds: A list of sounds being updated. + :type sounds: list of :class:`SoundboardSound` + :param guild: The guild where the sounds were updated. + :type guild: :class:`Guild` + +.. function:: on_soundboard_create(sound, guild): + + Called when a user creates a soundboard sound. + + :param sound: The sound being created. + :type sound: :class:`SoundboardSound` + :param guild: The guild where the sound was created. + :type guild: :class:`Guild` + +.. function:: on_soundboard_update(sound, guild): + + Called when a user updated a soundboard sound. + + :param sound: The sound being updated. + :type sound: :class:`SoundboardSound` + :param guild: The guild where the sound was updated. + :type guild: :class:`Guild` + +.. function:: on_soundboard_delete(sound_id, guild): + + Called when a user deletes a soundboard sound. + + :param sound_id: The sound_id being deleted. + :type sound: :class:`SoundboardSound` + :param guild: The guild where the sound was deleted. + :type guild: :class:`Guild` + .. function:: on_disconnect() Called when the client has disconnected from Discord, or a connection attempt to Discord has failed. @@ -1029,39 +1065,3 @@ to handle it, which defaults to print a traceback and ignoring the exception. :type channel: :class:`VoiceChannel` :param payload: The payload containing info about the effect. :type payload: :class:`VoiceChannelEffectSendEvent` - -.. function:: on_soundboard_sounds_update(sounds, guild): - - Called when multiple guild soundboard sounds are updated. - - :param sounds: A list of sounds being updated. - :type sounds: list of :class:`SoundboardSound` - :param guild: The guild where the sounds were updated. - :type guild: :class:`Guild` - -.. function:: on_soundboard_create(sound, guild): - - Called when a user creates a soundboard sound. - - :param sound: The sound being created. - :type sound: :class:`SoundboardSound` - :param guild: The guild where the sound was created. - :type guild: :class:`Guild` - -.. function:: on_soundboard_update(sound, guild): - - Called when a user updated a soundboard sound. - - :param sound: The sound being updated. - :type sound: :class:`SoundboardSound` - :param guild: The guild where the sound was updated. - :type guild: :class:`Guild` - -.. function:: on_soundboard_delete(sound_id, guild): - - Called when a user deletes a soundboard sound. - - :param sound_id: The sound_id being deleted. - :type sound: :class:`SoundboardSound` - :param guild: The guild where the sound was deleted. - :type guild: :class:`Guild` From 7906eee2babbd7c05881121c7cd04fc1db3e24a0 Mon Sep 17 00:00:00 2001 From: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> Date: Fri, 9 May 2025 22:27:21 +0200 Subject: [PATCH 18/21] Delete discord/make.bat Signed-off-by: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> --- discord/make.bat | 35 ----------------------------------- 1 file changed, 35 deletions(-) delete mode 100644 discord/make.bat diff --git a/discord/make.bat b/discord/make.bat deleted file mode 100644 index dc1312ab..00000000 --- a/discord/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=source -set BUILDDIR=build - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.https://www.sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "" goto help - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd From 4aa00fa71c158050a71973e0ee0219ac50d5aee4 Mon Sep 17 00:00:00 2001 From: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> Date: Fri, 9 May 2025 22:27:29 +0200 Subject: [PATCH 19/21] Delete discord/Makefile Signed-off-by: Cyber Frodo <57543710+Cyber-Frodo@users.noreply.github.com> --- discord/Makefile | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 discord/Makefile diff --git a/discord/Makefile b/discord/Makefile deleted file mode 100644 index d0c3cbf1..00000000 --- a/discord/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = source -BUILDDIR = build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) From 01ea1359fe18b00246285b1c2e3f80211503e669 Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 22:28:34 +0200 Subject: [PATCH 20/21] Fix Documentation for SoundboardSound events and on_voice_channel_effect_send --- docs/api/events.rst | 74 ++++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/docs/api/events.rst b/docs/api/events.rst index 6d93bc06..5fd21232 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -64,42 +64,6 @@ to handle it, which defaults to print a traceback and ignoring the exception. :param shard_id: The shard ID that has connected. :type shard_id: :class:`int` -.. function:: on_soundboard_sounds_update(sounds, guild): - - Called when multiple guild soundboard sounds are updated. - - :param sounds: A list of sounds being updated. - :type sounds: list of :class:`SoundboardSound` - :param guild: The guild where the sounds were updated. - :type guild: :class:`Guild` - -.. function:: on_soundboard_create(sound, guild): - - Called when a user creates a soundboard sound. - - :param sound: The sound being created. - :type sound: :class:`SoundboardSound` - :param guild: The guild where the sound was created. - :type guild: :class:`Guild` - -.. function:: on_soundboard_update(sound, guild): - - Called when a user updated a soundboard sound. - - :param sound: The sound being updated. - :type sound: :class:`SoundboardSound` - :param guild: The guild where the sound was updated. - :type guild: :class:`Guild` - -.. function:: on_soundboard_delete(sound_id, guild): - - Called when a user deletes a soundboard sound. - - :param sound_id: The sound_id being deleted. - :type sound: :class:`SoundboardSound` - :param guild: The guild where the sound was deleted. - :type guild: :class:`Guild` - .. function:: on_disconnect() Called when the client has disconnected from Discord, or a connection attempt to Discord has failed. @@ -1057,7 +1021,7 @@ to handle it, which defaults to print a traceback and ignoring the exception. :param stage_instance: The stage instance that was deleted. :type stage_instance: :class:`StageInstance` -.. function:: on_voice_channel_effect_send(channel, payload): +.. function:: on_voice_channel_effect_send(channel, payload) Called when a user uses a voice effect in a :class:`VoiceChannel`. @@ -1065,3 +1029,39 @@ to handle it, which defaults to print a traceback and ignoring the exception. :type channel: :class:`VoiceChannel` :param payload: The payload containing info about the effect. :type payload: :class:`VoiceChannelEffectSendEvent` + +.. function:: on_soundboard_sounds_update(sounds, guild) + + Called when multiple guild :class:`SoundboardSound` are updated. + + :param sounds: A list of sounds being updated. + :type sounds: List[:class:`SoundboardSound`] + :param guild: The guild where the sounds were updated. + :type guild: :class:`Guild` + +.. function:: on_soundboard_create(sound, guild) + + Called when a user creates a :class:`SoundboardSound`. + + :param sound: The sound being created. + :type sound: :class:`SoundboardSound` + :param guild: The guild where the sound was created. + :type guild: :class:`Guild` + +.. function:: on_soundboard_update(sound, guild) + + Called when a user updated a :class:`SoundboardSound`. + + :param sound: The sound being updated. + :type sound: :class:`SoundboardSound` + :param guild: The guild where the sound was updated. + :type guild: :class:`Guild` + +.. function:: on_soundboard_delete(sound_id, guild) + + Called when a user deletes a :class:`SoundboardSound`. + + :param sound_id: The sound_id being deleted. + :type sound: :class:`SoundboardSound` + :param guild: The guild where the sound was deleted. + :type guild: :class:`Guild` From 4bf1c57a650bdc7cbc8e35b6efe7f990bf02a2c0 Mon Sep 17 00:00:00 2001 From: cyber-frodo Date: Fri, 9 May 2025 22:32:51 +0200 Subject: [PATCH 21/21] Fix on_application_command_permissions_update typo --- docs/api/events.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/events.rst b/docs/api/events.rst index 5fd21232..4bb36db3 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -964,7 +964,7 @@ to handle it, which defaults to print a traceback and ignoring the exception. :param interaction: he Interaction-object with all his attributes and methods to respond to the interaction :type interaction: :class:`~discord.ModalSubmitInteraction` -.. function:: on_application_command_permissions_update(guild, command, new_permissions): +.. function:: on_application_command_permissions_update(guild, command, new_permissions) Called when the permissions for an application command are updated.