This commit is contained in:
Sengolda 2021-09-30 16:59:29 +05:30
commit f0bef378f2
30 changed files with 942 additions and 158 deletions

View File

@ -6,7 +6,7 @@ body:
attributes: attributes:
value: > value: >
Thanks for taking the time to fill out a bug. Thanks for taking the time to fill out a bug.
If you want real-time support, consider joining our Discord at https://discord.gg/r3sSKJJ instead. If you want real-time support, consider joining our Discord at https://discord.gg/TvqYBrGXEm instead.
Please note that this form is for bugs only! Please note that this form is for bugs only!
- type: input - type: input

View File

@ -5,4 +5,4 @@ contact_links:
url: https://github.com/Rapptz/discord.py/discussions url: https://github.com/Rapptz/discord.py/discussions
- name: Discord Server - name: Discord Server
about: Use our official Discord server to ask for help and questions as well. about: Use our official Discord server to ask for help and questions as well.
url: https://discord.gg/r3sSKJJ url: https://discord.gg/TvqYBrGXEm

View File

@ -1,25 +0,0 @@
name: Lint
on: [push]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v1
with:
python-version: 3.8
- name: install black
run: pip install black
- name: run linter
uses: wearerequired/lint-action@v1
with:
black: true
black_args: ". --line-length 120"
auto_fix: true

38
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: CI
on: [push, pull_request]
jobs:
pyright:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v1
with:
python-version: 3.8
- name: Setup node.js (for pyright)
uses: actions/setup-node@v1
with:
node-version: "14"
- name: Run type checking
run: |
npm install -g pyright
pip install .
pyright --lib --verifytypes discord --ignoreexternal
black:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Run linter
uses: psf/black@stable
with:
options: "--line-length 120 --check"
src: "./discord"

1
.python-black Normal file
View File

@ -0,0 +1 @@

View File

@ -2,7 +2,7 @@ enhanced-discord.py
=================== ===================
.. image:: https://discord.com/api/guilds/514232441498763279/embed.png .. image:: https://discord.com/api/guilds/514232441498763279/embed.png
:target: https://discord.gg/PYAfZzpsjG :target: https://discord.gg/TvqYBrGXEm
:alt: Discord server invite :alt: Discord server invite
.. image:: https://img.shields.io/pypi/v/enhanced-dpy.svg .. image:: https://img.shields.io/pypi/v/enhanced-dpy.svg
:target: https://pypi.python.org/pypi/enhanced-dpy :target: https://pypi.python.org/pypi/enhanced-dpy
@ -117,5 +117,5 @@ Links
------ ------
- `Documentation <https://enhanced-dpy.readthedocs.io/en/latest/index.html>`_ - `Documentation <https://enhanced-dpy.readthedocs.io/en/latest/index.html>`_
- `Official Discord Server <https://discord.gg/PYAfZzpsjG>`_ - `Official Discord Server <https://discord.gg/TvqYBrGXEm>`_
- `Discord API <https://discord.gg/discord-api>`_ - `Discord API <https://discord.gg/discord-api>`_

View File

@ -624,7 +624,7 @@ class Client:
async def start(self, token: str, *, reconnect: bool = True) -> None: async def start(self, token: str, *, reconnect: bool = True) -> None:
"""|coro| """|coro|
A shorthand coroutine for :meth:`login` + :meth:`connect`. A shorthand coroutine for :meth:`login` + :meth:`setup` + :meth:`connect`.
Raises Raises
------- -------
@ -632,8 +632,21 @@ class Client:
An unexpected keyword argument was received. An unexpected keyword argument was received.
""" """
await self.login(token) await self.login(token)
await self.setup()
await self.connect(reconnect=reconnect) await self.connect(reconnect=reconnect)
async def setup(self) -> Any:
"""|coro|
A coroutine to be called to setup the bot, by default this is blank.
To perform asynchronous setup after the bot is logged in but before
it has connected to the Websocket, overwrite this coroutine.
.. versionadded:: 2.0
"""
pass
def run(self, *args: Any, **kwargs: Any) -> None: def run(self, *args: Any, **kwargs: Any) -> None:
"""A blocking call that abstracts away the event loop """A blocking call that abstracts away the event loop
initialisation from you. initialisation from you.
@ -722,7 +735,7 @@ class Client:
""":class:`.Status`: """:class:`.Status`:
The status being used upon logging on to Discord. The status being used upon logging on to Discord.
.. versionadded: 2.0 .. versionadded:: 2.0
""" """
if self._connection._status in set(state.value for state in Status): if self._connection._status in set(state.value for state in Status):
return Status(self._connection._status) return Status(self._connection._status)

View File

@ -398,16 +398,14 @@ class Embed:
return EmbedProxy(getattr(self, "_image", {})) # type: ignore return EmbedProxy(getattr(self, "_image", {})) # type: ignore
@image.setter @image.setter
def image(self: E, url: Any): def image(self, url: Any):
if url is EmptyEmbed: if url is EmptyEmbed:
del self._image del self.image
else: else:
self._image = { self._image = {"url": str(url)}
"url": str(url),
}
@image.deleter @image.deleter
def image(self: E): def image(self):
try: try:
del self._image del self._image
except AttributeError: except AttributeError:
@ -429,7 +427,6 @@ class Embed:
""" """
self.image = url self.image = url
return self return self
@property @property
@ -448,13 +445,11 @@ class Embed:
return EmbedProxy(getattr(self, "_thumbnail", {})) # type: ignore return EmbedProxy(getattr(self, "_thumbnail", {})) # type: ignore
@thumbnail.setter @thumbnail.setter
def thumbnail(self: E, url: Any): def thumbnail(self, url: Any):
if url is EmptyEmbed: if url is EmptyEmbed:
del self._thumbnail del self.thumbnail
else: else:
self._thumbnail = { self._thumbnail = {"url": str(url)}
"url": str(url),
}
@thumbnail.deleter @thumbnail.deleter
def thumbnail(self): def thumbnail(self):
@ -463,7 +458,7 @@ class Embed:
except AttributeError: except AttributeError:
pass pass
def set_thumbnail(self: E, *, url: MaybeEmpty[Any]): def set_thumbnail(self, *, url: MaybeEmpty[Any]):
"""Sets the thumbnail for the embed content. """Sets the thumbnail for the embed content.
This function returns the class instance to allow for fluent-style This function returns the class instance to allow for fluent-style
@ -479,7 +474,6 @@ class Embed:
""" """
self.thumbnail = url self.thumbnail = url
return self return self
@property @property

View File

@ -28,18 +28,43 @@ from __future__ import annotations
import asyncio import asyncio
import collections import collections
import collections.abc import collections.abc
import inspect import inspect
import importlib.util import importlib.util
import sys import sys
import traceback import traceback
import types import types
from typing import Any, Callable, Mapping, List, Dict, TYPE_CHECKING, Optional, TypeVar, Type, Union from collections import defaultdict
from discord.http import HTTPClient
from typing import (
Any,
Callable,
Iterable,
Tuple,
cast,
Mapping,
List,
Dict,
TYPE_CHECKING,
Optional,
TypeVar,
Type,
Union,
)
import discord import discord
from discord.types.interactions import (
ApplicationCommandInteractionData,
ApplicationCommandInteractionDataOption,
EditApplicationCommand,
_ApplicationCommandInteractionDataOptionString,
)
from .core import GroupMixin from .core import GroupMixin
from .view import StringView from .converter import Greedy
from .view import StringView, supported_quotes
from .context import Context from .context import Context
from .flags import FlagConverter
from . import errors from . import errors
from .help import HelpCommand, DefaultHelpCommand from .help import HelpCommand, DefaultHelpCommand
from .cog import Cog from .cog import Cog
@ -67,6 +92,23 @@ CFT = TypeVar("CFT", bound="CoroFunc")
CXT = TypeVar("CXT", bound="Context") CXT = TypeVar("CXT", bound="Context")
class _FakeSlashMessage(discord.PartialMessage):
activity = application = edited_at = reference = webhook_id = None
attachments = components = reactions = stickers = mentions = []
author: Union[discord.User, discord.Member]
tts = False
@classmethod
def from_interaction(
cls, interaction: discord.Interaction, channel: Union[discord.TextChannel, discord.DMChannel, discord.Thread]
):
self = cls(channel=channel, id=interaction.id)
assert interaction.user is not None
self.author = interaction.user
return self
def when_mentioned(bot: Union[Bot, AutoShardedBot], msg: Message) -> List[str]: def when_mentioned(bot: Union[Bot, AutoShardedBot], msg: Message) -> List[str]:
"""A callable that implements a command prefix equivalent to being mentioned. """A callable that implements a command prefix equivalent to being mentioned.
@ -118,6 +160,35 @@ def _is_submodule(parent: str, child: str) -> bool:
return parent == child or child.startswith(parent + ".") return parent == child or child.startswith(parent + ".")
def _unwrap_slash_groups(
data: ApplicationCommandInteractionData,
) -> Tuple[str, List[ApplicationCommandInteractionDataOption]]:
command_name = data["name"]
command_options = data.get("options") or []
while any(o["type"] in {1, 2} for o in command_options): # type: ignore
for option in command_options: # type: ignore
if option["type"] in {1, 2}: # type: ignore
command_name += f' {option["name"]}' # type: ignore
command_options = option.get("options") or []
return command_name, command_options
def _quote_string_safe(string: str) -> str:
# we need to quote this string otherwise we may spill into
# other parameters and cause all kinds of trouble, as many
# quotes are supported and some may be in the option, we
# loop through all supported quotes and if neither open or
# close are in the string, we add them
for open, close in supported_quotes.items():
if open not in string and close not in string:
return f"{open}{string}{close}"
# all supported quotes are in the message and we cannot add any
# safely, very unlikely but still got to be covered
raise errors.UnexpectedQuoteError(string)
class _DefaultRepr: class _DefaultRepr:
def __repr__(self): def __repr__(self):
return "<default-help-command>" return "<default-help-command>"
@ -127,9 +198,22 @@ _default = _DefaultRepr()
class BotBase(GroupMixin): class BotBase(GroupMixin):
def __init__(self, command_prefix, help_command=_default, description=None, *, intents: discord.Intents, **options): def __init__(
self,
command_prefix,
help_command=_default,
description=None,
*,
intents: discord.Intents,
message_commands: bool = True,
slash_commands: bool = False,
**options,
):
super().__init__(**options, intents=intents) super().__init__(**options, intents=intents)
self.command_prefix = command_prefix self.command_prefix = command_prefix
self.slash_commands = slash_commands
self.message_commands = message_commands
self.extra_events: Dict[str, List[CoroFunc]] = {} self.extra_events: Dict[str, List[CoroFunc]] = {}
self.__cogs: Dict[str, Cog] = {} self.__cogs: Dict[str, Cog] = {}
self.__extensions: Dict[str, types.ModuleType] = {} self.__extensions: Dict[str, types.ModuleType] = {}
@ -142,6 +226,7 @@ class BotBase(GroupMixin):
self.owner_id = options.get("owner_id") self.owner_id = options.get("owner_id")
self.owner_ids = options.get("owner_ids", set()) self.owner_ids = options.get("owner_ids", set())
self.strip_after_prefix = options.get("strip_after_prefix", False) self.strip_after_prefix = options.get("strip_after_prefix", False)
self.slash_command_guilds: Optional[Iterable[int]] = options.get("slash_command_guilds", None)
if self.owner_id and self.owner_ids: if self.owner_id and self.owner_ids:
raise TypeError("Both owner_id and owner_ids are set.") raise TypeError("Both owner_id and owner_ids are set.")
@ -149,6 +234,9 @@ class BotBase(GroupMixin):
if self.owner_ids and not isinstance(self.owner_ids, collections.abc.Collection): if self.owner_ids and not isinstance(self.owner_ids, collections.abc.Collection):
raise TypeError(f"owner_ids must be a collection not {self.owner_ids.__class__!r}") raise TypeError(f"owner_ids must be a collection not {self.owner_ids.__class__!r}")
if not (message_commands or slash_commands):
raise ValueError("Both message_commands and slash_commands are disabled.")
if help_command is _default: if help_command is _default:
self.help_command = DefaultHelpCommand() self.help_command = DefaultHelpCommand()
else: else:
@ -163,6 +251,55 @@ class BotBase(GroupMixin):
for event in self.extra_events.get(ev, []): for event in self.extra_events.get(ev, []):
self._schedule_event(event, ev, *args, **kwargs) # type: ignore self._schedule_event(event, ev, *args, **kwargs) # type: ignore
async def setup(self):
await self.create_slash_commands()
async def create_slash_commands(self):
commands: defaultdict[Optional[int], List[EditApplicationCommand]] = defaultdict(list)
for command in self.commands:
if command.hidden or (command.slash_command is None and not self.slash_commands):
continue
try:
payload = command.to_application_command()
except Exception:
raise errors.ApplicationCommandRegistrationError(command)
if payload is None:
continue
guilds = command.slash_command_guilds or self.slash_command_guilds
if guilds is None:
commands[None].append(payload)
else:
for guild in guilds:
commands[guild].append(payload)
http: HTTPClient = self.http # type: ignore
global_commands = commands.pop(None, None)
application_id = self.application_id or (await self.application_info()).id # type: ignore
if global_commands is not None:
if self.slash_command_guilds is None:
await http.bulk_upsert_global_commands(
payload=global_commands,
application_id=application_id,
)
else:
for guild in self.slash_command_guilds:
await http.bulk_upsert_guild_commands(
guild_id=guild,
payload=global_commands,
application_id=application_id,
)
for guild, guild_commands in commands.items():
assert guild is not None
await http.bulk_upsert_guild_commands(
guild_id=guild,
payload=guild_commands,
application_id=application_id,
)
@discord.utils.copy_doc(discord.Client.close) @discord.utils.copy_doc(discord.Client.close)
async def close(self) -> None: async def close(self) -> None:
for extension in tuple(self.__extensions): for extension in tuple(self.__extensions):
@ -928,6 +1065,9 @@ class BotBase(GroupMixin):
A list of prefixes or a single prefix that the bot is A list of prefixes or a single prefix that the bot is
listening for. listening for.
""" """
if isinstance(message, _FakeSlashMessage):
return "/"
prefix = ret = self.command_prefix prefix = ret = self.command_prefix
if callable(prefix): if callable(prefix):
ret = await discord.utils.maybe_coroutine(prefix, self, message) ret = await discord.utils.maybe_coroutine(prefix, self, message)
@ -1084,9 +1224,94 @@ class BotBase(GroupMixin):
ctx = await self.get_context(message) ctx = await self.get_context(message)
await self.invoke(ctx) await self.invoke(ctx)
async def process_slash_commands(self, interaction: discord.Interaction):
"""|coro|
This function processes a slash command interaction into a usable
message and calls :meth:`.process_commands` based on it. Without this
coroutine slash commands will not be triggered.
By default, this coroutine is called inside the :func:`.on_interaction`
event. If you choose to override the :func:`.on_interaction` event,
then you should invoke this coroutine as well.
.. versionadded:: 2.0
Parameters
-----------
interaction: :class:`discord.Interaction`
The interaction to process slash commands for.
"""
if interaction.type != discord.InteractionType.application_command:
return
interaction.data = cast(ApplicationCommandInteractionData, interaction.data)
command_name, command_options = _unwrap_slash_groups(interaction.data)
command = self.get_command(command_name)
if command is None:
raise errors.CommandNotFound(f'Command "{command_name}" is not found')
# Ensure the interaction channel is usable
channel = interaction.channel
if channel is None or isinstance(channel, discord.PartialMessageable):
if interaction.guild is None:
assert interaction.user is not None
channel = await interaction.user.create_dm()
elif interaction.channel_id is not None:
channel = await interaction.guild.fetch_channel(interaction.channel_id)
else:
return # cannot do anything without stable channel
# Make our fake message so we can pass it to ext.commands
message: discord.Message = _FakeSlashMessage.from_interaction(interaction, channel) # type: ignore
message.content = f"/{command_name} "
# Add arguments to fake message content, in the right order
ignore_params: List[inspect.Parameter] = []
for name, param in command.clean_params.items():
if inspect.isclass(param.annotation) and issubclass(param.annotation, FlagConverter):
for name, flag in param.annotation.get_flags().items():
option = next((o for o in command_options if o["name"] == name), None)
if option is None:
if flag.required:
raise errors.MissingRequiredFlag(flag)
else:
prefix = param.annotation.__commands_flag_prefix__
delimiter = param.annotation.__commands_flag_delimiter__
message.content += f"{prefix}{name} {option['value']}{delimiter}" # type: ignore
continue
option = next((o for o in command_options if o["name"] == name), None)
if option is None:
if param.default is param.empty and not command._is_typing_optional(param.annotation):
raise errors.MissingRequiredArgument(param)
else:
ignore_params.append(param)
elif (
option["type"] == 3
and not isinstance(param.annotation, Greedy)
and param.kind in {param.POSITIONAL_OR_KEYWORD, param.POSITIONAL_ONLY}
):
# String with space in without "consume rest"
option = cast(_ApplicationCommandInteractionDataOptionString, option)
message.content += f"{_quote_string_safe(option['value'])} "
else:
message.content += f'{option.get("value", "")} '
ctx = await self.get_context(message)
ctx._ignored_params = ignore_params
ctx.interaction = interaction
await self.invoke(ctx)
async def on_message(self, message): async def on_message(self, message):
await self.process_commands(message) await self.process_commands(message)
async def on_interaction(self, interaction: discord.Interaction):
await self.process_slash_commands(interaction)
class Bot(BotBase, discord.Client): class Bot(BotBase, discord.Client):
"""Represents a discord bot. """Represents a discord bot.
@ -1157,6 +1382,28 @@ class Bot(BotBase, discord.Client):
the ``command_prefix`` is set to ``!``. Defaults to ``False``. the ``command_prefix`` is set to ``!``. Defaults to ``False``.
.. versionadded:: 1.7 .. versionadded:: 1.7
message_commands: Optional[:class:`bool`]
Whether to process commands based on messages.
Can be overwritten per command in the command decorators or when making
a :class:`Command` object via the ``message_command`` parameter
.. versionadded:: 2.0
slash_commands: Optional[:class:`bool`]
Whether to upload and process slash commands.
Can be overwritten per command in the command decorators or when making
a :class:`Command` object via the ``slash_command`` parameter
.. versionadded:: 2.0
slash_command_guilds: Optional[:class:`List[int]`]
If this is set, only upload slash commands to these guild IDs.
Can be overwritten per command in the command decorators or when making
a :class:`Command` object via the ``slash_command_guilds`` parameter
.. versionadded:: 2.0
""" """
pass pass

View File

@ -25,8 +25,8 @@ from __future__ import annotations
import inspect import inspect
import re import re
from datetime import timedelta
from typing import Any, Dict, Generic, List, Optional, TYPE_CHECKING, TypeVar, Union from typing import Any, Dict, Generic, List, Literal, Optional, TYPE_CHECKING, TypeVar, Union, overload
import discord.abc import discord.abc
import discord.utils import discord.utils
@ -42,6 +42,8 @@ if TYPE_CHECKING:
from discord.member import Member from discord.member import Member
from discord.state import ConnectionState from discord.state import ConnectionState
from discord.user import ClientUser, User from discord.user import ClientUser, User
from discord.webhook import WebhookMessage
from discord.interactions import Interaction
from discord.voice_client import VoiceProtocol from discord.voice_client import VoiceProtocol
from .bot import Bot, AutoShardedBot from .bot import Bot, AutoShardedBot
@ -120,6 +122,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
A boolean that indicates if the command failed to be parsed, checked, A boolean that indicates if the command failed to be parsed, checked,
or invoked. or invoked.
""" """
interaction: Optional[Interaction] = None
def __init__( def __init__(
self, self,
@ -151,6 +154,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
self.subcommand_passed: Optional[str] = subcommand_passed self.subcommand_passed: Optional[str] = subcommand_passed
self.command_failed: bool = command_failed self.command_failed: bool = command_failed
self.current_parameter: Optional[inspect.Parameter] = current_parameter self.current_parameter: Optional[inspect.Parameter] = current_parameter
self._ignored_params: List[inspect.Parameter] = []
self._state: ConnectionState = self.message._state self._state: ConnectionState = self.message._state
async def invoke(self, command: Command[CogT, P, T], /, *args: P.args, **kwargs: P.kwargs) -> T: async def invoke(self, command: Command[CogT, P, T], /, *args: P.args, **kwargs: P.kwargs) -> T:
@ -402,6 +406,97 @@ class Context(discord.abc.Messageable, Generic[BotT]):
except CommandError as e: except CommandError as e:
await cmd.on_help_command_error(self, e) await cmd.on_help_command_error(self, e)
@overload
async def send(
self,
content: Optional[str] = None,
return_message: Literal[False] = False,
ephemeral: bool = False,
**kwargs: Any,
) -> Optional[Union[Message, WebhookMessage]]:
...
@overload
async def send(
self,
content: Optional[str] = None,
return_message: Literal[True] = True,
ephemeral: bool = False,
**kwargs: Any,
) -> Union[Message, WebhookMessage]:
...
async def send(
self, content: Optional[str] = None, return_message: bool = True, ephemeral: bool = False, **kwargs: Any
) -> Optional[Union[Message, WebhookMessage]]:
"""
|coro|
A shortcut method to :meth:`.abc.Messageable.send` with interaction helpers.
This function takes all the parameters of :meth:`.abc.Messageable.send` plus the following:
Parameters
------------
return_message: :class:`bool`
Ignored if not in a slash command context.
If this is set to False more native interaction methods will be used.
ephemeral: :class:`bool`
Ignored if not in a slash command context.
Indicates if the message should only be visible to the user who started the interaction.
If a view is sent with an ephemeral message and it has no timeout set then the timeout
is set to 15 minutes.
Returns
--------
Optional[Union[:class:`.Message`, :class:`.WebhookMessage`]]
In a slash command context, the message that was sent if return_message is True.
In a normal context, it always returns a :class:`.Message`
"""
if self.interaction is None or (
self.interaction.response.responded_at is not None
and discord.utils.utcnow() - self.interaction.response.responded_at >= timedelta(minutes=15)
):
return await super().send(content, **kwargs)
# Remove unsupported arguments from kwargs
kwargs.pop("nonce", None)
kwargs.pop("stickers", None)
kwargs.pop("reference", None)
kwargs.pop("delete_after", None)
kwargs.pop("mention_author", None)
if not (
return_message
or self.interaction.response.is_done()
or any(arg in kwargs for arg in ("file", "files", "allowed_mentions"))
):
send = self.interaction.response.send_message
else:
# We have to defer in order to use the followup webhook
if not self.interaction.response.is_done():
await self.interaction.response.defer(ephemeral=ephemeral)
send = self.interaction.followup.send
return await send(content, ephemeral=ephemeral, **kwargs) # type: ignore
@overload
async def reply(
self, content: Optional[str] = None, return_message: Literal[False] = False, **kwargs: Any
) -> Optional[Union[Message, WebhookMessage]]:
...
@overload
async def reply(
self, content: Optional[str] = None, return_message: Literal[True] = True, **kwargs: Any
) -> Union[Message, WebhookMessage]:
...
@discord.utils.copy_doc(Message.reply) @discord.utils.copy_doc(Message.reply)
async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message: async def reply(
return await self.message.reply(content, **kwargs) self, content: Optional[str] = None, return_message: bool = True, **kwargs: Any
) -> Optional[Union[Message, WebhookMessage]]:
return await self.send(content, return_message=return_message, reference=self.message, **kwargs) # type: ignore

View File

@ -77,6 +77,7 @@ __all__ = (
"GuildStickerConverter", "GuildStickerConverter",
"clean_content", "clean_content",
"Greedy", "Greedy",
"Option",
"run_converters", "run_converters",
) )
@ -96,6 +97,8 @@ T_co = TypeVar("T_co", covariant=True)
CT = TypeVar("CT", bound=discord.abc.GuildChannel) CT = TypeVar("CT", bound=discord.abc.GuildChannel)
TT = TypeVar("TT", bound=discord.Thread) TT = TypeVar("TT", bound=discord.Thread)
DT = TypeVar("DT", bound=str)
@runtime_checkable @runtime_checkable
class Converter(Protocol[T_co]): class Converter(Protocol[T_co]):
@ -583,7 +586,7 @@ class ThreadConverter(IDConverter[discord.Thread]):
2. Lookup by mention. 2. Lookup by mention.
3. Lookup by name. 3. Lookup by name.
.. versionadded: 2.0 .. versionadded:: 2.0
""" """
async def convert(self, ctx: Context, argument: str) -> discord.Thread: async def convert(self, ctx: Context, argument: str) -> discord.Thread:
@ -1005,6 +1008,50 @@ class Greedy(List[T]):
return cls(converter=converter) return cls(converter=converter)
class Option(Generic[T, DT]): # type: ignore
"""A special 'converter' to apply a description to slash command options.
For example in the following code:
.. code-block:: python3
@bot.command()
async def ban(ctx,
member: discord.Member, *,
reason: str = commands.Option('no reason', description='the reason to ban this member')
):
await member.ban(reason=reason)
The description would be ``the reason to ban this member`` and the default would be ``no reason``
.. versionadded:: 2.0
Attributes
------------
default: Optional[Any]
The default for this option, overwrites Option during parsing.
description: :class:`str`
The description for this option, is unpacked to :attr:`.Command.option_descriptions`
"""
description: DT
default: Union[T, inspect._empty]
__slots__ = (
"default",
"description",
)
def __init__(self, default: T = inspect.Parameter.empty, *, description: DT) -> None:
self.description = description
self.default = default
if TYPE_CHECKING:
# Terrible workaround for type checking reasons
def Option(default: T = inspect.Parameter.empty, *, description: str) -> T:
...
def _convert_to_bool(argument: str) -> bool: def _convert_to_bool(argument: str) -> bool:
lowered = argument.lower() lowered = argument.lower()
if lowered in ("yes", "y", "true", "t", "1", "enable", "on"): if lowered in ("yes", "y", "true", "t", "1", "enable", "on"):

View File

@ -23,12 +23,14 @@ DEALINGS IN THE SOFTWARE.
""" """
from __future__ import annotations from __future__ import annotations
from typing import ( from typing import (
Any, Any,
Callable, Callable,
Dict, Dict,
Generator, Generator,
Generic, Generic,
Iterable,
Literal, Literal,
List, List,
Optional, Optional,
@ -38,27 +40,41 @@ from typing import (
TypeVar, TypeVar,
Type, Type,
TYPE_CHECKING, TYPE_CHECKING,
cast,
overload, overload,
) )
import asyncio import asyncio
import functools import functools
import inspect import inspect
import datetime import datetime
from collections import defaultdict
from operator import itemgetter
import discord import discord
from .errors import * from .errors import *
from .cooldowns import Cooldown, BucketType, CooldownMapping, MaxConcurrency, DynamicCooldownMapping from .cooldowns import Cooldown, BucketType, CooldownMapping, MaxConcurrency, DynamicCooldownMapping
from .converter import run_converters, get_converter, Greedy from .converter import (
CONVERTER_MAPPING,
Converter,
MemberConverter,
RoleConverter,
run_converters,
get_converter,
Greedy,
Option,
)
from ._types import _BaseCommand from ._types import _BaseCommand
from .cog import Cog from .cog import Cog
from .context import Context from .context import Context
from .flags import FlagConverter
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Concatenate, ParamSpec, TypeGuard from typing_extensions import Concatenate, ParamSpec, TypeGuard
from discord.message import Message from discord.message import Message
from discord.types.interactions import EditApplicationCommand, ApplicationCommandInteractionDataOption
from ._types import ( from ._types import (
Coro, Coro,
@ -107,6 +123,20 @@ GroupT = TypeVar("GroupT", bound="Group")
HookT = TypeVar("HookT", bound="Hook") HookT = TypeVar("HookT", bound="Hook")
ErrorT = TypeVar("ErrorT", bound="Error") ErrorT = TypeVar("ErrorT", bound="Error")
REVERSED_CONVERTER_MAPPING = {v: k for k, v in CONVERTER_MAPPING.items()}
application_option_type_lookup = {
str: 3,
bool: 5,
int: 4,
(
discord.Member,
discord.User,
): 6, # Preferably discord.abc.User, but 'Protocols with non-method members don't support issubclass()'
(discord.abc.GuildChannel, discord.Thread): 7,
discord.Role: 8,
float: 10,
}
if TYPE_CHECKING: if TYPE_CHECKING:
P = ParamSpec("P") P = ParamSpec("P")
else: else:
@ -124,13 +154,21 @@ def unwrap_function(function: Callable[..., Any]) -> Callable[..., Any]:
return function return function
def get_signature_parameters(function: Callable[..., Any], globalns: Dict[str, Any]) -> Dict[str, inspect.Parameter]: def get_signature_parameters(
function: Callable[..., Any], globalns: Dict[str, Any]
) -> Tuple[Dict[str, inspect.Parameter], Dict[str, str]]:
signature = inspect.signature(function) signature = inspect.signature(function)
params = {} params = {}
cache: Dict[str, Any] = {} cache: Dict[str, Any] = {}
descriptions = defaultdict(lambda: "no description")
eval_annotation = discord.utils.evaluate_annotation eval_annotation = discord.utils.evaluate_annotation
for name, parameter in signature.parameters.items(): for name, parameter in signature.parameters.items():
annotation = parameter.annotation annotation = parameter.annotation
if isinstance(parameter.default, Option): # type: ignore
option = parameter.default
descriptions[name] = option.description
parameter = parameter.replace(default=option.default)
if annotation is parameter.empty: if annotation is parameter.empty:
params[name] = parameter params[name] = parameter
continue continue
@ -144,7 +182,7 @@ def get_signature_parameters(function: Callable[..., Any], globalns: Dict[str, A
params[name] = parameter.replace(annotation=annotation) params[name] = parameter.replace(annotation=annotation)
return params return params, descriptions
def wrap_callback(coro): def wrap_callback(coro):
@ -276,13 +314,36 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
extras: :class:`dict` extras: :class:`dict`
A dict of user provided extras to attach to the Command. A dict of user provided extras to attach to the Command.
.. versionadded:: 2.0
.. note:: .. note::
This object may be copied by the library. This object may be copied by the library.
message_command: Optional[:class:`bool`]
Whether to process this command based on messages.
This overwrites the global ``message_commands`` parameter of :class:`.Bot`.
.. versionadded:: 2.0
slash_command: Optional[:class:`bool`]
Whether to upload and process this command as a slash command.
This overwrites the global ``slash_commands`` parameter of :class:`.Bot`.
.. versionadded:: 2.0
slash_command_guilds: Optional[List[:class:`int`]]
If this is set, only upload this slash command to these guild IDs.
This overwrites the global ``slash_command_guilds`` parameter of :class:`.Bot`.
.. versionadded:: 2.0
option_descriptions: Dict[:class:`str`, :class:`str`]
The unpacked option descriptions from :class:`.Option`.
.. versionadded:: 2.0 .. versionadded:: 2.0
""" """
__original_kwargs__: Dict[str, Any] __original_kwargs__: Dict[str, Any]
_max_concurrency: Optional[MaxConcurrency]
def __new__(cls: Type[CommandT], *args: Any, **kwargs: Any) -> CommandT: def __new__(cls: Type[CommandT], *args: Any, **kwargs: Any) -> CommandT:
# if you're wondering why this is done, it's because we need to ensure # if you're wondering why this is done, it's because we need to ensure
@ -319,6 +380,10 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
self.callback = func self.callback = func
self.enabled: bool = kwargs.get("enabled", True) self.enabled: bool = kwargs.get("enabled", True)
self.slash_command: Optional[bool] = kwargs.get("slash_command", None)
self.message_command: Optional[bool] = kwargs.get("message_command", None)
self.slash_command_guilds: Optional[Iterable[int]] = kwargs.get("slash_command_guilds", None)
help_doc = kwargs.get("help") help_doc = kwargs.get("help")
if help_doc is not None: if help_doc is not None:
help_doc = inspect.cleandoc(help_doc) help_doc = inspect.cleandoc(help_doc)
@ -341,17 +406,20 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
self.description: str = inspect.cleandoc(kwargs.get("description", "")) self.description: str = inspect.cleandoc(kwargs.get("description", ""))
self.hidden: bool = kwargs.get("hidden", False) self.hidden: bool = kwargs.get("hidden", False)
if hasattr(func, "__command_attrs__"):
command_attrs: Dict[str, Any] = func.__command_attrs__
else:
command_attrs = {}
try: try:
checks = func.__commands_checks__ checks = func.__commands_checks__
checks.reverse() checks.reverse()
except AttributeError: except AttributeError:
checks = kwargs.get("checks", []) checks = kwargs.get("checks", [])
self.checks: List[Check] = checks
try: try:
cooldown = func.__commands_cooldown__ cooldown = command_attrs.pop("cooldown")
except AttributeError: except KeyError:
cooldown = kwargs.get("cooldown") cooldown = kwargs.get("cooldown")
if cooldown is None: if cooldown is None:
@ -360,14 +428,10 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
buckets = cooldown buckets = cooldown
else: else:
raise TypeError("Cooldown must be a an instance of CooldownMapping or None.") raise TypeError("Cooldown must be a an instance of CooldownMapping or None.")
self.checks: List[Check] = checks
self._buckets: CooldownMapping = buckets self._buckets: CooldownMapping = buckets
self._max_concurrency = kwargs.get("max_concurrency")
try:
max_concurrency = func.__commands_max_concurrency__
except AttributeError:
max_concurrency = kwargs.get("max_concurrency")
self._max_concurrency: Optional[MaxConcurrency] = max_concurrency
self.require_var_positional: bool = kwargs.get("require_var_positional", False) self.require_var_positional: bool = kwargs.get("require_var_positional", False)
self.ignore_extra: bool = kwargs.get("ignore_extra", True) self.ignore_extra: bool = kwargs.get("ignore_extra", True)
@ -377,23 +441,30 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
# bandaid for the fact that sometimes parent can be the bot instance # bandaid for the fact that sometimes parent can be the bot instance
parent = kwargs.get("parent") parent = kwargs.get("parent")
self.parent: Optional[GroupMixin] = parent if isinstance(parent, _BaseCommand) else None # type: ignore self.parent: Optional[GroupMixin] = parent if isinstance(parent, _BaseCommand) else None # type: ignore
if self.slash_command_guilds is not None and self.parent is not None:
raise ValueError(
"Cannot set specific guilds for a subcommand. They are inherited from the top level group."
)
self._before_invoke: Optional[Hook] = None self._before_invoke: Optional[Hook] = None
try: try:
before_invoke = func.__before_invoke__ before_invoke = command_attrs.pop("before_invoke")
except AttributeError: except KeyError:
pass pass
else: else:
self.before_invoke(before_invoke) self.before_invoke(before_invoke)
self._after_invoke: Optional[Hook] = None self._after_invoke: Optional[Hook] = None
try: try:
after_invoke = func.__after_invoke__ after_invoke = command_attrs.pop("after_invoke")
except AttributeError: except KeyError:
pass pass
else: else:
self.after_invoke(after_invoke) self.after_invoke(after_invoke)
# Handle user provided command attrs
self._update_attrs(**command_attrs)
@property @property
def callback( def callback(
self, self,
@ -417,7 +488,11 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
except AttributeError: except AttributeError:
globalns = {} globalns = {}
self.params = get_signature_parameters(function, globalns) self.params, self.option_descriptions = get_signature_parameters(function, globalns)
def _update_attrs(self, **command_attrs: Any):
for key, value in command_attrs.items():
setattr(self, key, value)
def add_check(self, func: Check) -> None: def add_check(self, func: Check) -> None:
"""Adds a check to the command. """Adds a check to the command.
@ -541,6 +616,10 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
ctx.bot.dispatch("command_error", ctx, error) ctx.bot.dispatch("command_error", ctx, error)
async def transform(self, ctx: Context, param: inspect.Parameter) -> Any: async def transform(self, ctx: Context, param: inspect.Parameter) -> Any:
if param in ctx._ignored_params:
# in a slash command, we need a way to mark a param as default so ctx._ignored_params is used
return param.default if param.default is not param.empty else None
required = param.default is param.empty required = param.default is param.empty
converter = get_converter(param) converter = get_converter(param)
consume_rest_is_special = param.kind == param.KEYWORD_ONLY and not self.rest_is_raw consume_rest_is_special = param.kind == param.KEYWORD_ONLY and not self.rest_is_raw
@ -1109,10 +1188,19 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
:class:`bool` :class:`bool`
A boolean indicating if the command can be invoked. A boolean indicating if the command can be invoked.
""" """
if not self.enabled: if not self.enabled:
raise DisabledCommand(f"{self.name} command is disabled") raise DisabledCommand(f"{self.name} command is disabled")
if ctx.interaction is None and (
self.message_command is False or (self.message_command is None and not ctx.bot.message_commands)
):
raise DisabledCommand(f"{self.name} command cannot be run as a message command")
if ctx.interaction is not None and (
self.slash_command is False or (self.slash_command is None and not ctx.bot.slash_commands)
):
raise DisabledCommand(f"{self.name} command cannot be run as a slash command")
original = ctx.command original = ctx.command
ctx.command = self ctx.command = self
@ -1137,6 +1225,92 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
finally: finally:
ctx.command = original ctx.command = original
def _param_to_options(
self, name: str, annotation: Any, required: bool, varadic: bool
) -> List[Optional[ApplicationCommandInteractionDataOption]]:
origin = getattr(annotation, "__origin__", None)
if inspect.isclass(annotation) and issubclass(annotation, FlagConverter):
return [
param
for name, flag in annotation.get_flags().items()
for param in self._param_to_options(
name, flag.annotation, required=flag.required, varadic=flag.annotation is tuple
)
]
if varadic:
annotation = str
origin = None
if not required and origin is not None and len(annotation.__args__) == 2:
# Unpack Optional[T] (Union[T, None]) into just T
annotation, origin = annotation.__args__[0], None
option: Dict[str, Any] = {
"type": 3,
"name": name,
"required": required,
"description": self.option_descriptions[name],
}
if origin is None:
if not inspect.isclass(annotation):
annotation = type(annotation)
if issubclass(annotation, Converter):
# If this is a converter, we want to check if it is a native
# one, in which we can get the original type, eg, (MemberConverter -> Member)
annotation = REVERSED_CONVERTER_MAPPING.get(annotation, annotation)
for python_type, discord_type in application_option_type_lookup.items():
if issubclass(annotation, python_type):
option["type"] = discord_type
break
elif origin is Union:
if annotation in {Union[discord.Member, discord.Role], Union[MemberConverter, RoleConverter]}:
option["type"] = 9
elif origin is Literal:
literal_values = annotation.__args__
python_type = type(literal_values[0])
if (
all(type(value) == python_type for value in literal_values)
and python_type in application_option_type_lookup.keys()
):
option["type"] = application_option_type_lookup[python_type]
option["choices"] = [
{"name": literal_value, "value": literal_value} for literal_value in annotation.__args__
]
return [option] # type: ignore
def to_application_command(self, nested: int = 0) -> Optional[EditApplicationCommand]:
if self.slash_command is False:
return
elif nested == 3:
raise ApplicationCommandRegistrationError(self, f"{self.qualified_name} is too deeply nested!")
payload = {"name": self.name, "description": self.short_doc or "no description", "options": []}
if nested != 0:
payload["type"] = 1
for name, param in self.clean_params.items():
options = self._param_to_options(
name,
param.annotation if param.annotation is not param.empty else str,
varadic=param.kind == param.KEYWORD_ONLY or isinstance(param.annotation, Greedy),
required=(param.default is param.empty and not self._is_typing_optional(param.annotation))
or param.kind == param.VAR_POSITIONAL,
)
if options is not None:
payload["options"].extend(option for option in options if option is not None)
# Now we have all options, make sure required is before optional.
payload["options"] = sorted(payload["options"], key=itemgetter("required"), reverse=True)
return payload # type: ignore
class GroupMixin(Generic[CogT]): class GroupMixin(Generic[CogT]):
"""A mixin that implements common functionality for classes that behave """A mixin that implements common functionality for classes that behave
@ -1510,6 +1684,19 @@ class Group(GroupMixin[CogT], Command[CogT, P, T]):
view.previous = previous view.previous = previous
await super().reinvoke(ctx, call_hooks=call_hooks) await super().reinvoke(ctx, call_hooks=call_hooks)
def to_application_command(self, nested: int = 0) -> Optional[EditApplicationCommand]:
if self.slash_command is False:
return
elif nested == 2:
raise ApplicationCommandRegistrationError(self, f"{self.qualified_name} is too deeply nested!")
return { # type: ignore
"name": self.name,
"type": int(not (nested - 1)) + 1,
"description": self.short_doc or "no description",
"options": [cmd.to_application_command(nested=nested + 1) for cmd in sorted(self.commands, key=lambda x: x.name)],
}
# Decorators # Decorators
@ -1664,7 +1851,7 @@ def group(
return command(name=name, cls=cls, **attrs) # type: ignore return command(name=name, cls=cls, **attrs) # type: ignore
def check(predicate: Check) -> Callable[[T], T]: def check(predicate: Check, **command_attrs: Any) -> Callable[[T], T]:
r"""A decorator that adds a check to the :class:`.Command` or its r"""A decorator that adds a check to the :class:`.Command` or its
subclasses. These checks could be accessed via :attr:`.Command.checks`. subclasses. These checks could be accessed via :attr:`.Command.checks`.
@ -1733,16 +1920,22 @@ def check(predicate: Check) -> Callable[[T], T]:
----------- -----------
predicate: Callable[[:class:`Context`], :class:`bool`] predicate: Callable[[:class:`Context`], :class:`bool`]
The predicate to check if the command should be invoked. The predicate to check if the command should be invoked.
**command_attrs: Dict[:class:`str`, Any]
key: value pairs to be added to the command's attributes.
""" """
def decorator(func: Union[Command, CoroFunc]) -> Union[Command, CoroFunc]: def decorator(func: Union[Command, CoroFunc]) -> Union[Command, CoroFunc]:
if isinstance(func, Command): if isinstance(func, Command):
func.checks.append(predicate) func.checks.append(predicate)
func._update_attrs(**command_attrs)
else: else:
if not hasattr(func, "__commands_checks__"): if not hasattr(func, "__commands_checks__"):
func.__commands_checks__ = [] func.__commands_checks__ = []
if not hasattr(func, "__command_attrs__"):
func.__command_attrs__ = {}
func.__commands_checks__.append(predicate) func.__commands_checks__.append(predicate)
func.__command_attrs__.update(command_attrs)
return func return func
@ -1915,7 +2108,7 @@ def has_any_role(*items: Union[int, str]) -> Callable[[T], T]:
return True return True
raise MissingAnyRole(list(items)) raise MissingAnyRole(list(items))
return check(predicate) return check(predicate, required_roles=items)
def bot_has_role(item: int) -> Callable[[T], T]: def bot_has_role(item: int) -> Callable[[T], T]:
@ -1945,7 +2138,7 @@ def bot_has_role(item: int) -> Callable[[T], T]:
raise BotMissingRole(item) raise BotMissingRole(item)
return True return True
return check(predicate) return check(predicate, bot_required_role=item)
def bot_has_any_role(*items: int) -> Callable[[T], T]: def bot_has_any_role(*items: int) -> Callable[[T], T]:
@ -1974,7 +2167,7 @@ def bot_has_any_role(*items: int) -> Callable[[T], T]:
return True return True
raise BotMissingAnyRole(list(items)) raise BotMissingAnyRole(list(items))
return check(predicate) return check(predicate, bot_required_roles=items)
def has_permissions(**perms: bool) -> Callable[[T], T]: def has_permissions(**perms: bool) -> Callable[[T], T]:
@ -2022,7 +2215,7 @@ def has_permissions(**perms: bool) -> Callable[[T], T]:
raise MissingPermissions(missing) raise MissingPermissions(missing)
return check(predicate) return check(predicate, required_permissions=perms)
def bot_has_permissions(**perms: bool) -> Callable[[T], T]: def bot_has_permissions(**perms: bool) -> Callable[[T], T]:
@ -2049,7 +2242,7 @@ def bot_has_permissions(**perms: bool) -> Callable[[T], T]:
raise BotMissingPermissions(missing) raise BotMissingPermissions(missing)
return check(predicate) return check(predicate, bot_required_permissions=perms)
def has_guild_permissions(**perms: bool) -> Callable[[T], T]: def has_guild_permissions(**perms: bool) -> Callable[[T], T]:
@ -2078,7 +2271,7 @@ def has_guild_permissions(**perms: bool) -> Callable[[T], T]:
raise MissingPermissions(missing) raise MissingPermissions(missing)
return check(predicate) return check(predicate, required_guild_permissions=perms)
def bot_has_guild_permissions(**perms: bool) -> Callable[[T], T]: def bot_has_guild_permissions(**perms: bool) -> Callable[[T], T]:
@ -2104,7 +2297,7 @@ def bot_has_guild_permissions(**perms: bool) -> Callable[[T], T]:
raise BotMissingPermissions(missing) raise BotMissingPermissions(missing)
return check(predicate) return check(predicate, bot_required_guild_permissions=perms)
def dm_only() -> Callable[[T], T]: def dm_only() -> Callable[[T], T]:
@ -2215,7 +2408,10 @@ def cooldown(
if isinstance(func, Command): if isinstance(func, Command):
func._buckets = CooldownMapping(Cooldown(rate, per), type) func._buckets = CooldownMapping(Cooldown(rate, per), type)
else: else:
func.__commands_cooldown__ = CooldownMapping(Cooldown(rate, per), type) if not hasattr(func, "__command_attrs__"):
func.__command_attrs__ = {}
func.__command_attrs__["cooldown"] = CooldownMapping(Cooldown(rate, per), type)
return func return func
return decorator # type: ignore return decorator # type: ignore
@ -2259,7 +2455,10 @@ def dynamic_cooldown(
if isinstance(func, Command): if isinstance(func, Command):
func._buckets = DynamicCooldownMapping(cooldown, type) func._buckets = DynamicCooldownMapping(cooldown, type)
else: else:
func.__commands_cooldown__ = DynamicCooldownMapping(cooldown, type) if not hasattr(func, "__command_attrs__"):
func.__command_attrs__ = {}
func.__command_attrs__["cooldown"] = DynamicCooldownMapping(cooldown, type)
return func return func
return decorator # type: ignore return decorator # type: ignore
@ -2294,7 +2493,10 @@ def max_concurrency(number: int, per: BucketType = BucketType.default, *, wait:
if isinstance(func, Command): if isinstance(func, Command):
func._max_concurrency = value func._max_concurrency = value
else: else:
func.__commands_max_concurrency__ = value if not hasattr(func, "__command_attrs__"):
func.__command_attrs__ = {}
func.__command_attrs__["_max_concurrency"] = value
return func return func
return decorator # type: ignore return decorator # type: ignore
@ -2343,7 +2545,10 @@ def before_invoke(coro) -> Callable[[T], T]:
if isinstance(func, Command): if isinstance(func, Command):
func.before_invoke(coro) func.before_invoke(coro)
else: else:
func.__before_invoke__ = coro if not hasattr(func, "__command_attrs__"):
func.__command_attrs__ = {}
func.__command_attrs__["before_invoke"] = coro
return func return func
return decorator # type: ignore return decorator # type: ignore
@ -2362,7 +2567,7 @@ def after_invoke(coro) -> Callable[[T], T]:
if isinstance(func, Command): if isinstance(func, Command):
func.after_invoke(coro) func.after_invoke(coro)
else: else:
func.__after_invoke__ = coro func.__command_attrs__["after_invoke"] = coro
return func return func
return decorator # type: ignore return decorator # type: ignore

View File

@ -33,6 +33,7 @@ if TYPE_CHECKING:
from .converter import Converter from .converter import Converter
from .context import Context from .context import Context
from .core import Command
from .cooldowns import Cooldown, BucketType from .cooldowns import Cooldown, BucketType
from .flags import Flag from .flags import Flag
from discord.abc import GuildChannel from discord.abc import GuildChannel
@ -93,6 +94,7 @@ __all__ = (
"ExtensionFailed", "ExtensionFailed",
"ExtensionNotFound", "ExtensionNotFound",
"CommandRegistrationError", "CommandRegistrationError",
"ApplicationCommandRegistrationError",
"FlagError", "FlagError",
"BadFlagArgument", "BadFlagArgument",
"MissingFlagArgument", "MissingFlagArgument",
@ -453,6 +455,11 @@ class BadInviteArgument(BadArgument):
This inherits from :exc:`BadArgument` This inherits from :exc:`BadArgument`
.. versionadded:: 1.5 .. versionadded:: 1.5
Attributes
-----------
argument: :class:`str`
The invite supplied by the caller that was not found
""" """
def __init__(self, argument: str) -> None: def __init__(self, argument: str) -> None:
@ -1014,6 +1021,25 @@ class CommandRegistrationError(ClientException):
super().__init__(f"The {type_} {name} is already an existing command or alias.") super().__init__(f"The {type_} {name} is already an existing command or alias.")
class ApplicationCommandRegistrationError(ClientException):
"""An exception raised when a command cannot be converted to an
application command.
This inherits from :exc:`discord.ClientException`
.. versionadded:: 2.0
Attributes
----------
command: :class:`Command`
The command that failed to be converted.
"""
def __init__(self, command: Command, msg: str = None) -> None:
self.command = command
super().__init__(msg or f"{command.qualified_name} failed to converted to an application command.")
class FlagError(BadArgument): class FlagError(BadArgument):
"""The base exception type for all flag parsing related errors. """The base exception type for all flag parsing related errors.

View File

@ -615,7 +615,7 @@ class HelpCommand:
:class:`.abc.Messageable` :class:`.abc.Messageable`
The destination where the help command will be output. The destination where the help command will be output.
""" """
return self.context.channel return self.context
async def send_error_message(self, error): async def send_error_message(self, error):
"""|coro| """|coro|
@ -977,6 +977,10 @@ class DefaultHelpCommand(HelpCommand):
for page in self.paginator.pages: for page in self.paginator.pages:
await destination.send(page) await destination.send(page)
interaction = self.context.interaction
if interaction is not None and destination == self.context.author and not interaction.response.is_done():
await interaction.response.send_message("Sent help to your DMs!", ephemeral=True)
def add_command_formatting(self, command): def add_command_formatting(self, command):
"""A utility function to format the non-indented block of commands and groups. """A utility function to format the non-indented block of commands and groups.
@ -1007,7 +1011,7 @@ class DefaultHelpCommand(HelpCommand):
elif self.dm_help is None and len(self.paginator) > self.dm_help_threshold: elif self.dm_help is None and len(self.paginator) > self.dm_help_threshold:
return ctx.author return ctx.author
else: else:
return ctx.channel return ctx
async def prepare_help_command(self, ctx, command): async def prepare_help_command(self, ctx, command):
self.paginator.clear() self.paginator.clear()

View File

@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE.
from .errors import UnexpectedQuoteError, InvalidEndOfQuotedStringError, ExpectedClosingQuoteError from .errors import UnexpectedQuoteError, InvalidEndOfQuotedStringError, ExpectedClosingQuoteError
# map from opening quotes to closing quotes # map from opening quotes to closing quotes
_quotes = { supported_quotes = {
'"': '"', '"': '"',
"": "", "": "",
"": "", "": "",
@ -44,7 +44,7 @@ _quotes = {
"": "", "": "",
"": "", "": "",
} }
_all_quotes = set(_quotes.keys()) | set(_quotes.values()) _all_quotes = set(supported_quotes.keys()) | set(supported_quotes.values())
class StringView: class StringView:
@ -130,7 +130,7 @@ class StringView:
if current is None: if current is None:
return None return None
close_quote = _quotes.get(current) close_quote = supported_quotes.get(current)
is_quoted = bool(close_quote) is_quoted = bool(close_quote)
if is_quoted: if is_quoted:
result = [] result = []

View File

@ -47,6 +47,8 @@ __all__ = (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime
from .types.interactions import ( from .types.interactions import (
Interaction as InteractionPayload, Interaction as InteractionPayload,
InteractionData, InteractionData,
@ -58,12 +60,10 @@ if TYPE_CHECKING:
from aiohttp import ClientSession from aiohttp import ClientSession
from .embeds import Embed from .embeds import Embed
from .ui.view import View from .ui.view import View
from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, PartialMessageable from .channel import TextChannel, CategoryChannel, StoreChannel, PartialMessageable
from .threads import Thread from .threads import Thread
InteractionChannel = Union[ InteractionChannel = Union[TextChannel, CategoryChannel, StoreChannel, Thread, PartialMessageable]
VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, Thread, PartialMessageable
]
MISSING: Any = utils.MISSING MISSING: Any = utils.MISSING
@ -179,7 +179,7 @@ class Interaction:
type = ChannelType.text if self.guild_id is not None else ChannelType.private type = ChannelType.text if self.guild_id is not None else ChannelType.private
return PartialMessageable(state=self._state, id=self.channel_id, type=type) return PartialMessageable(state=self._state, id=self.channel_id, type=type)
return None return None
return channel return channel # type: ignore
@property @property
def permissions(self) -> Permissions: def permissions(self) -> Permissions:
@ -369,20 +369,20 @@ class InteractionResponse:
""" """
__slots__: Tuple[str, ...] = ( __slots__: Tuple[str, ...] = (
"_responded", "responded_at",
"_parent", "_parent",
) )
def __init__(self, parent: Interaction): def __init__(self, parent: Interaction):
self.responded_at: Optional[datetime] = None
self._parent: Interaction = parent self._parent: Interaction = parent
self._responded: bool = False
def is_done(self) -> bool: def is_done(self) -> bool:
""":class:`bool`: Indicates whether an interaction response has been done before. """:class:`bool`: Indicates whether an interaction response has been done before.
An interaction can only be responded to once. An interaction can only be responded to once.
""" """
return self._responded return self.responded_at is not None
async def defer(self, *, ephemeral: bool = False) -> None: async def defer(self, *, ephemeral: bool = False) -> None:
"""|coro| """|coro|
@ -405,7 +405,7 @@ class InteractionResponse:
InteractionResponded InteractionResponded
This interaction has already been responded to before. This interaction has already been responded to before.
""" """
if self._responded: if self.is_done():
raise InteractionResponded(self._parent) raise InteractionResponded(self._parent)
defer_type: int = 0 defer_type: int = 0
@ -423,7 +423,8 @@ class InteractionResponse:
await adapter.create_interaction_response( await adapter.create_interaction_response(
parent.id, parent.token, session=parent._session, type=defer_type, data=data parent.id, parent.token, session=parent._session, type=defer_type, data=data
) )
self._responded = True
self.responded_at = utils.utcnow()
async def pong(self) -> None: async def pong(self) -> None:
"""|coro| """|coro|
@ -439,7 +440,7 @@ class InteractionResponse:
InteractionResponded InteractionResponded
This interaction has already been responded to before. This interaction has already been responded to before.
""" """
if self._responded: if self.is_done():
raise InteractionResponded(self._parent) raise InteractionResponded(self._parent)
parent = self._parent parent = self._parent
@ -448,7 +449,7 @@ class InteractionResponse:
await adapter.create_interaction_response( await adapter.create_interaction_response(
parent.id, parent.token, session=parent._session, type=InteractionResponseType.pong.value parent.id, parent.token, session=parent._session, type=InteractionResponseType.pong.value
) )
self._responded = True self.responded_at = utils.utcnow()
async def send_message( async def send_message(
self, self,
@ -494,7 +495,7 @@ class InteractionResponse:
InteractionResponded InteractionResponded
This interaction has already been responded to before. This interaction has already been responded to before.
""" """
if self._responded: if self.is_done():
raise InteractionResponded(self._parent) raise InteractionResponded(self._parent)
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
@ -537,7 +538,7 @@ class InteractionResponse:
self._parent._state.store_view(view) self._parent._state.store_view(view)
self._responded = True self.responded_at = utils.utcnow()
async def edit_message( async def edit_message(
self, self,
@ -578,7 +579,7 @@ class InteractionResponse:
InteractionResponded InteractionResponded
This interaction has already been responded to before. This interaction has already been responded to before.
""" """
if self._responded: if self.is_done():
raise InteractionResponded(self._parent) raise InteractionResponded(self._parent)
parent = self._parent parent = self._parent
@ -629,7 +630,7 @@ class InteractionResponse:
if view and not view.is_finished(): if view and not view.is_finished():
state.store_view(view, message_id) state.store_view(view, message_id)
self._responded = True self.responded_at = utils.utcnow()
class _InteractionMessageState: class _InteractionMessageState:

View File

@ -169,9 +169,14 @@ class Attachment(Hashable):
The attachment's `media type <https://en.wikipedia.org/wiki/Media_type>`_ The attachment's `media type <https://en.wikipedia.org/wiki/Media_type>`_
.. versionadded:: 1.7 .. versionadded:: 1.7
ephemeral: Optional[:class:`bool`]
If the attachment is ephemeral. Ephemeral attachments are temporary and
will automatically be removed after a set period of time.
.. versionadded:: 2.0
""" """
__slots__ = ("id", "size", "height", "width", "filename", "url", "proxy_url", "_http", "content_type") __slots__ = ("id", "size", "height", "width", "filename", "url", "proxy_url", "ephemeral", "_http", "content_type")
def __init__(self, *, data: AttachmentPayload, state: ConnectionState): def __init__(self, *, data: AttachmentPayload, state: ConnectionState):
self.id: int = int(data["id"]) self.id: int = int(data["id"])
@ -183,6 +188,7 @@ class Attachment(Hashable):
self.proxy_url: str = data.get("proxy_url") self.proxy_url: str = data.get("proxy_url")
self._http = state.http self._http = state.http
self.content_type: Optional[str] = data.get("content_type") self.content_type: Optional[str] = data.get("content_type")
self.ephemeral: Optional[bool] = data.get("ephemeral")
def is_spoiler(self) -> bool: def is_spoiler(self) -> bool:
""":class:`bool`: Whether this attachment contains a spoiler.""" """:class:`bool`: Whether this attachment contains a spoiler."""

View File

@ -227,8 +227,8 @@ class _EditApplicationCommandOptional(TypedDict, total=False):
description: str description: str
options: Optional[List[ApplicationCommandOption]] options: Optional[List[ApplicationCommandOption]]
type: ApplicationCommandType type: ApplicationCommandType
default_permission: bool
class EditApplicationCommand(_EditApplicationCommandOptional): class EditApplicationCommand(_EditApplicationCommandOptional):
name: str name: str
default_permission: bool

View File

@ -53,6 +53,7 @@ class _AttachmentOptional(TypedDict, total=False):
height: Optional[int] height: Optional[int]
width: Optional[int] width: Optional[int]
content_type: str content_type: str
ephemeral: bool
spoiler: bool spoiler: bool

View File

@ -353,7 +353,7 @@ class View:
return return
await item.callback(interaction) await item.callback(interaction)
if not interaction.response._responded: if not interaction.response.is_done():
await interaction.response.defer() await interaction.response.defer()
except Exception as e: except Exception as e:
return await self.on_error(e, item, interaction) return await self.on_error(e, item, interaction)

View File

@ -80,7 +80,7 @@ class BaseUser(_UserTag):
_state: ConnectionState _state: ConnectionState
_avatar: Optional[str] _avatar: Optional[str]
_banner: Optional[str] _banner: Optional[str]
_accent_colour: Optional[str] _accent_colour: Optional[int]
_public_flags: int _public_flags: int
def __init__(self, *, state: ConnectionState, data: UserPayload) -> None: def __init__(self, *, state: ConnectionState, data: UserPayload) -> None:

View File

@ -159,7 +159,7 @@ html_experimental_html5_writer = True
html_theme = "basic" html_theme = "basic"
html_context = { html_context = {
"discord_invite": "https://discord.gg/r3sSKJJ", "discord_invite": "https://discord.gg/TvqYBrGXEm",
"discord_extensions": [ "discord_extensions": [
("discord.ext.commands", "ext/commands"), ("discord.ext.commands", "ext/commands"),
("discord.ext.tasks", "ext/tasks"), ("discord.ext.tasks", "ext/tasks"),
@ -167,7 +167,7 @@ html_context = {
} }
resource_links = { resource_links = {
"discord": "https://discord.gg/r3sSKJJ", "discord": "https://discord.gg/TvqYBrGXEm",
"issues": "https://github.com/Rapptz/discord.py/issues", "issues": "https://github.com/Rapptz/discord.py/issues",
"discussions": "https://github.com/Rapptz/discord.py/discussions", "discussions": "https://github.com/Rapptz/discord.py/discussions",
"examples": f"https://github.com/Rapptz/discord.py/tree/{branch}/examples", "examples": f"https://github.com/Rapptz/discord.py/tree/{branch}/examples",
@ -352,5 +352,5 @@ texinfo_documents = [
def setup(app): def setup(app):
if app.config.language == "ja": if app.config.language == "ja":
app.config.intersphinx_mapping["py"] = ("https://docs.python.org/ja/3", None) app.config.intersphinx_mapping["py"] = ("https://docs.python.org/ja/3", None)
app.config.html_context["discord_invite"] = "https://discord.gg/nXzj3dg" app.config.html_context["discord_invite"] = "https://discord.gg/TvqYBrGXEm"
app.config.resource_links["discord"] = "https://discord.gg/nXzj3dg" app.config.resource_links["discord"] = "https://discord.gg/TvqYBrGXEm"

View File

@ -429,6 +429,12 @@ Converters
.. autofunction:: discord.ext.commands.run_converters .. autofunction:: discord.ext.commands.run_converters
Option
~~~~~~
.. autoclass:: discord.ext.commands.Option
:members:
Flag Converter Flag Converter
~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~

View File

@ -61,6 +61,7 @@ the name to something other than the function would be as simple as doing this:
async def _list(ctx, arg): async def _list(ctx, arg):
pass pass
Parameters Parameters
------------ ------------
@ -133,6 +134,11 @@ at all:
Since the ``args`` variable is a :class:`py:tuple`, Since the ``args`` variable is a :class:`py:tuple`,
you can do anything you would usually do with one. you can do anything you would usually do with one.
.. admonition:: Slash Command Only
This functionally is currently not supported by the slash command API, so is turned into
a single ``STRING`` parameter on discord's end which we do our own parsing on.
Keyword-Only Arguments Keyword-Only Arguments
++++++++++++++++++++++++ ++++++++++++++++++++++++
@ -179,6 +185,12 @@ know how the command was executed. It contains a lot of useful information:
The context implements the :class:`abc.Messageable` interface, so anything you can do on a :class:`abc.Messageable` you The context implements the :class:`abc.Messageable` interface, so anything you can do on a :class:`abc.Messageable` you
can do on the :class:`~ext.commands.Context`. can do on the :class:`~ext.commands.Context`.
.. admonition:: Slash Command Only
:attr:`.Context.message` will be fake if in a slash command, it is not
recommended to access if :attr:`.Context.interaction` is not None as most
methods will error due to the message not actually existing.
Converters Converters
------------ ------------
@ -400,47 +412,55 @@ specify.
Under the hood, these are implemented by the :ref:`ext_commands_adv_converters` interface. A table of the equivalent Under the hood, these are implemented by the :ref:`ext_commands_adv_converters` interface. A table of the equivalent
converter is given below: converter is given below:
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| Discord Class | Converter | | Discord Class | Converter | Supported By Slash Commands |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`Object` | :class:`~ext.commands.ObjectConverter` | | :class:`Object` | :class:`~ext.commands.ObjectConverter` | Not currently |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`Member` | :class:`~ext.commands.MemberConverter` | | :class:`Member` | :class:`~ext.commands.MemberConverter` | Yes, as type 6 (USER) |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`User` | :class:`~ext.commands.UserConverter` | | :class:`User` | :class:`~ext.commands.UserConverter` | Yes, as type 6 (USER) |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`Message` | :class:`~ext.commands.MessageConverter` | | :class:`Message` | :class:`~ext.commands.MessageConverter` | Not currently |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`PartialMessage` | :class:`~ext.commands.PartialMessageConverter` | | :class:`PartialMessage` | :class:`~ext.commands.PartialMessageConverter` | Not currently |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`.GuildChannel` | :class:`~ext.commands.GuildChannelConverter` | | :class:`.GuildChannel` | :class:`~ext.commands.GuildChannelConverter` | Yes, as type 7 (CHANNEL) |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`TextChannel` | :class:`~ext.commands.TextChannelConverter` | | :class:`TextChannel` | :class:`~ext.commands.TextChannelConverter` | Yes, as type 7 (CHANNEL) |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`VoiceChannel` | :class:`~ext.commands.VoiceChannelConverter` | | :class:`VoiceChannel` | :class:`~ext.commands.VoiceChannelConverter` | Yes, as type 7 (CHANNEL) |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`StageChannel` | :class:`~ext.commands.StageChannelConverter` | | :class:`StageChannel` | :class:`~ext.commands.StageChannelConverter` | Yes, as type 7 (CHANNEL) |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`StoreChannel` | :class:`~ext.commands.StoreChannelConverter` | | :class:`StoreChannel` | :class:`~ext.commands.StoreChannelConverter` | Yes, as type 7 (CHANNEL) |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`CategoryChannel` | :class:`~ext.commands.CategoryChannelConverter` | | :class:`CategoryChannel` | :class:`~ext.commands.CategoryChannelConverter` | Yes, as type 7 (CHANNEL) |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`Invite` | :class:`~ext.commands.InviteConverter` | | :class:`Thread` | :class:`~ext.commands.ThreadConverter` | Yes, as type 7 (CHANNEL) |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`Guild` | :class:`~ext.commands.GuildConverter` | | :class:`Invite` | :class:`~ext.commands.InviteConverter` | Not currently |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`Role` | :class:`~ext.commands.RoleConverter` | | :class:`Guild` | :class:`~ext.commands.GuildConverter` | Not currently |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`Game` | :class:`~ext.commands.GameConverter` | | :class:`Role` | :class:`~ext.commands.RoleConverter` | Yes, as type 8 (ROLE) |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`Colour` | :class:`~ext.commands.ColourConverter` | | :class:`Game` | :class:`~ext.commands.GameConverter` | Not currently |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`Emoji` | :class:`~ext.commands.EmojiConverter` | | :class:`Colour` | :class:`~ext.commands.ColourConverter` | Not currently |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`PartialEmoji` | :class:`~ext.commands.PartialEmojiConverter` | | :class:`Emoji` | :class:`~ext.commands.EmojiConverter` | Not currently |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
| :class:`Thread` | :class:`~ext.commands.ThreadConverter` | | :class:`PartialEmoji` | :class:`~ext.commands.PartialEmojiConverter` | Not currently |
+--------------------------+-------------------------------------------------+ +--------------------------+-------------------------------------------------+-----------------------------+
.. admonition:: Slash Command Only
If a slash command is not marked on the table above as supported, it will be sent as type 3 (STRING)
and parsed by normal content parsing, see
`the discord documentation <https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-type>`_
for all supported types by the API.
By providing the converter it allows us to use them as building blocks for another converter: By providing the converter it allows us to use them as building blocks for another converter:
@ -487,6 +507,10 @@ then a special error is raised, :exc:`~ext.commands.BadUnionArgument`.
Note that any valid converter discussed above can be passed in to the argument list of a :data:`typing.Union`. Note that any valid converter discussed above can be passed in to the argument list of a :data:`typing.Union`.
.. admonition:: Slash Command Only
These are not currently supported by the Discord API and will be sent as type 3 (STRING)
typing.Optional typing.Optional
^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^
@ -680,6 +704,11 @@ In order to customise the flag syntax we also have a few options that can be pas
a command line parser. The syntax is mainly inspired by Discord's search bar input and as a result a command line parser. The syntax is mainly inspired by Discord's search bar input and as a result
all flags need a corresponding value. all flags need a corresponding value.
.. admonition:: Slash Command Only
As these are built very similar to slash command options, they are converted into options and parsed
back into flags when the slash command is executed.
The flag converter is similar to regular commands and allows you to use most types of converters The flag converter is similar to regular commands and allows you to use most types of converters
(with the exception of :class:`~ext.commands.Greedy`) as the type annotation. Some extra support is added for specific (with the exception of :class:`~ext.commands.Greedy`) as the type annotation. Some extra support is added for specific
annotations as described below. annotations as described below.

View File

@ -15,4 +15,5 @@ extension library that handles this for you.
commands commands
cogs cogs
extensions extensions
slash-commands
api api

View File

@ -0,0 +1,23 @@
.. currentmodule:: discord
.. _ext_commands_slash_commands:
Slash Commands
==============
Slash Commands are currently supported in enhanced-discord.py using a system on top of ext.commands.
This system is very simple to use, and can be enabled via :attr:`.Bot.slash_commands` globally,
or only for specific commands via :attr:`.Command.slash_command`.
There is also the parameter ``slash_command_guilds`` which can be passed to either :class:`.Bot` or the command
decorator in order to only upload the commands as guild commands to these specific guild IDs, however this
should only be used for testing or small (<10 guilds) bots.
If you want to add option descriptions to your commands, you should use :class:`.Option`
For troubleshooting, see the :ref:`FAQ <ext_commands_slash_command_troubleshooting>`
.. admonition:: Slash Command Only
For parts of the docs specific to slash commands, look for this box!

View File

@ -410,3 +410,34 @@ Example: ::
await ctx.send(f'Pushing to {remote} {branch}') await ctx.send(f'Pushing to {remote} {branch}')
This could then be used as ``?git push origin master``. This could then be used as ``?git push origin master``.
How do I make slash commands?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
See :doc:`/ext/commands/slash-commands`
My slash commands aren't showing up!
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. _ext_commands_slash_command_troubleshooting:
You need to invite your bot with the ``application.commands`` scope on each guild and
you need the :attr:`Permissions.use_slash_commands` permission in order to see slash commands.
.. image:: /images/discord_oauth2_slash_scope.png
:alt: The scopes checkbox with "bot" and "applications.commands" ticked.
Global slash commands (created by not specifying :attr:`~ext.commands.Bot.slash_command_guilds`) will also take up an
hour to refresh on discord's end, so it is recommended to set :attr:`~ext.commands.Bot.slash_command_guilds` for development.
If none of this works, make sure you are actually running enhanced-discord.py by doing ``print(bot.slash_commands)``
My bot won't start after enabling slash commands!
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This means some of your command metadata is invalid for slash commands.
Make sure your command names and option names are lowercase, and they have to match the regex ``^[\w-]{1,32}$``
If you cannot figure out the problem, you should disable slash commands globally (:attr:`~ext.commands.Bot.slash_commands`\=False)
then go through commands, enabling them specifically with :attr:`~.commands.Command.slash_command`\=True until it
errors, then you can debug the problem with that command specifically.

View File

@ -43,7 +43,7 @@ Breaking Changes
- :attr:`GroupChannel.owner` is now Optional - :attr:`GroupChannel.owner` is now Optional
- ``edit`` methods now only accept None if it actually means something (e.g. clearing it) - ``edit`` methods now only accept None if it actually means something (e.g. clearing it)
- ``timeout`` parameter for ``ui.View.__init__`` is now keyword only - ``timeout`` parameter for ``ui.View.__init__`` is now keyword only
- When an interaction has already been responded and another one is sent, :exc:`InteractionResponded`is now raised. - When an interaction has already been responded and another one is sent, :exc:`InteractionResponded` is now raised.
- Discord's API only allows a single :attr:`interaction.response`. - Discord's API only allows a single :attr:`interaction.response`.
- Separate :func:`on_member_update` and :func:`on_presence_update` - Separate :func:`on_member_update` and :func:`on_presence_update`
- The new event :func:`on_presence_update` is now called when status/activity is changed. - The new event :func:`on_presence_update` is now called when status/activity is changed.

View File

@ -27,7 +27,7 @@ async def userinfo(ctx: commands.Context, user: discord.User):
# and can do the following: # and can do the following:
user_id = user.id user_id = user.id
username = user.name username = user.name
avatar = user.avatar.url avatar = user.display_avatar.url
await ctx.send(f"User found: {user_id} -- {username}\n{avatar}") await ctx.send(f"User found: {user_id} -- {username}\n{avatar}")

View File

@ -0,0 +1,41 @@
import discord
from discord.ext import commands
# Set slash commands=True when constructing your bot to enable all slash commands
# if your bot is only for a couple of servers, you can use the parameter
# `slash_command_guilds=[list, of, guild, ids]` to specify this,
# then the commands will be much faster to upload.
bot = commands.Bot("!", intents=discord.Intents(guilds=True, messages=True), slash_commands=True)
@bot.event
async def on_ready():
print(f"Logged in as {bot.user} (ID: {bot.user.id})")
print("------")
@bot.command()
# You can use commands.Option to define descriptions for your options, and converters will still work fine.
async def ping(
ctx: commands.Context, emoji: bool = commands.Option(description="whether to use an emoji when responding")
):
# This command can be used with slash commands or message commands
if emoji:
await ctx.send("\U0001f3d3")
else:
await ctx.send("Pong!")
@bot.command(message_command=False)
async def only_slash(ctx: commands.Context):
# This command can only be used with slash commands
await ctx.send("Hello from slash commands!")
@bot.command(slash_command=False)
async def only_message(ctx: commands.Context):
# This command can only be used with a message
await ctx.send("Hello from message commands!")
bot.run("token")