diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 68f037c31..9753d8927 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,12 +6,7 @@ The following is a set of guidelines for contributing to the repository. These a ## This is too much to read! I want to ask a question! -Generally speaking questions are better suited in our resources below. - -- The official support server: https://discord.gg/r3sSKJJ -- The Discord API server under #python_discord-py: https://discord.gg/discord-api -- [The FAQ in the documentation](https://discordpy.readthedocs.io/en/latest/faq.html) -- [StackOverflow's `discord.py` tag](https://stackoverflow.com/questions/tagged/discord.py) +Generally speaking questions are better suited in the repo discussions. Please try your best not to ask questions in our issue tracker. Most of them don't belong there unless they provide value to a larger audience. @@ -26,7 +21,7 @@ Please be aware of the following things when filing bug reports. - Guidance on **how to reproduce the issue**. Ideally, this should have a small code sample that allows us to run and see the issue for ourselves to debug. **Please make sure that the token is not displayed**. If you cannot provide a code snippet, then let us know what the steps were, how often it happens, etc. - Tell us **what you expected to happen**. That way we can meet that expectation. - Tell us **what actually happens**. What ends up happening in reality? It's not helpful to say "it fails" or "it doesn't work". Say *how* it failed, do you get an exception? Does it hang? How are the expectations different from reality? - - Tell us **information about your environment**. What version of discord.py are you using? How was it installed? What operating system are you running on? These are valuable questions and information that we use. + - Tell us **information about your environment**. What version of discord.py-self are you using? How was it installed? What operating system are you running on? These are valuable questions and information that we use. If the bug report is missing this information then it'll take us longer to fix the issue. We will probably ask for clarification, and barring that if no response was given then the issue will be closed. @@ -36,7 +31,7 @@ Submitting a pull request is fairly simple, just make sure it focuses on a singl ### Git Commit Guidelines -- Use present tense (e.g. "Add feature" not "Added feature") +- Try to use present tense (e.g. "Add feature" not "Added feature") - Limit all lines to 72 characters or less. - Reference issues or pull requests outside of the first line. - Please use the shorthand `#123` and not the full URL. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index eabf5c071..034658e4d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -5,10 +5,7 @@ body: - type: markdown attributes: value: > - 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. - - Please note that this form is for bugs only! + Thanks for taking the time to fill out a bug report! - type: input attributes: label: Summary @@ -19,35 +16,27 @@ body: attributes: label: Reproduction Steps description: > - What you did to make it happen. + How did you make it happen? validations: required: true - type: textarea attributes: - label: Minimal Reproducible Code + label: Code description: > - A short snippet of code that showcases the bug. + Relevant code that shows the bug. render: python - type: textarea attributes: label: Expected Results description: > - What did you expect to happen? + What is supposed to happen? validations: required: true - type: textarea attributes: label: Actual Results description: > - What actually happened? - validations: - required: true - - type: input - attributes: - label: Intents - description: > - What intents are you using for your bot? - This is the `discord.Intents` class you pass to the client. + What is actually happening? validations: required: true - type: textarea @@ -55,24 +44,21 @@ body: label: System Information description: > Run `python -m discord -v` and paste this information below. - - This command required v1.1.0 or higher of the library. If this errors out then show some basic - information involving your system such as operating system and Python version. validations: required: true - type: checkboxes attributes: label: Checklist description: > - Let's make sure you've properly done due dilligence when reporting this issue! + Let's make sure this issue is valid! options: - label: I have searched the open issues for duplicates. required: true - - label: I have shown the entire traceback, if possible. + - label: I have shared the entire traceback. required: true - - label: I have removed my token from display, if visible. + - label: I am using a user token (and it isn't visible in the code). required: true - type: textarea attributes: - label: Additional Context - description: If there is anything else to say, please do so here. + label: Additional Information + description: Put any extra context, weird configurations, or other important info here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7934e4a85..8b71d28e9 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - - name: Ask a question - about: Ask questions and discuss with other users of the library. - url: https://github.com/Rapptz/discord.py/discussions - - name: Discord Server - about: Use our official Discord server to ask for help and questions as well. - url: https://discord.gg/r3sSKJJ + - name: Get help + url: https://github.com/dolfies/discord.py-self/discussions/new?category=help + about: Ask a question on our help page (*not* for bug reports). + - name: Suggest a feature + url: https://github.com/dolfies/discord.py-self/discussions/new?category=ideas + about: Suggest new features for the library! diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 55941f4e1..18a34d101 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,13 +2,12 @@ -## Checklist - +## General Info - [ ] If code changes were made then they have been tested. - - [ ] I have updated the documentation to reflect the changes. -- [ ] This PR fixes an issue. + +- [ ] This PR fixes an issue (please put issue # in summary). - [ ] This PR adds something new (e.g. new method or parameters). - [ ] This PR is a breaking change (e.g. methods or parameters removed/renamed) - [ ] This PR is **not** a code change (e.g. documentation, README, ...) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 000000000..77ab0cb86 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,32 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Upload Python Package + +on: + workflow_dispatch: + release: + types: [created] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.gitignore b/.gitignore index 51ed18d76..5978dad70 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ docs/crowdin.py *.flac *.mo /.coverage +*test.py diff --git a/README.ja.rst b/README.ja.rst deleted file mode 100644 index 11deab899..000000000 --- a/README.ja.rst +++ /dev/null @@ -1,113 +0,0 @@ -discord.py -========== - -.. image:: https://discord.com/api/guilds/336642139381301249/embed.png - :target: https://discord.gg/nXzj3dg - :alt: Discordサーバーの招待 -.. image:: https://img.shields.io/pypi/v/discord.py.svg - :target: https://pypi.python.org/pypi/discord.py - :alt: PyPIのバージョン情報 -.. image:: https://img.shields.io/pypi/pyversions/discord.py.svg - :target: https://pypi.python.org/pypi/discord.py - :alt: PyPIのサポートしているPythonのバージョン - -discord.py は機能豊富かつモダンで使いやすい、非同期処理にも対応したDiscord用のAPIラッパーです。 - -主な特徴 -------------- - -- ``async`` と ``await`` を使ったモダンなPythonらしいAPI。 -- 適切なレート制限処理 -- メモリと速度の両方を最適化。 - -インストール -------------- - -**Python 3.8 以降のバージョンが必須です** - -完全な音声サポートなしでライブラリをインストールする場合は次のコマンドを実行してください: - -.. code:: sh - - # Linux/OS X - python3 -m pip install -U discord.py - - # Windows - py -3 -m pip install -U discord.py - -音声サポートが必要なら、次のコマンドを実行しましょう: - -.. code:: sh - - # Linux/OS X - python3 -m pip install -U discord.py[voice] - - # Windows - py -3 -m pip install -U discord.py[voice] - - -開発版をインストールしたいのならば、次の手順に従ってください: - -.. code:: sh - - $ git clone https://github.com/Rapptz/discord.py - $ cd discord.py - $ python3 -m pip install -U .[voice] - - -オプションパッケージ -~~~~~~~~~~~~~~~~~~~~~~ - -* PyNaCl (音声サポート用) - -Linuxで音声サポートを導入するには、前述のコマンドを実行する前にお気に入りのパッケージマネージャー(例えば ``apt`` や ``dnf`` など)を使って以下のパッケージをインストールする必要があります: - -* libffi-dev (システムによっては ``libffi-devel``) -* python-dev (例えばPython 3.6用の ``python3.6-dev``) - -簡単な例 --------------- - -.. code:: py - - import discord - - class MyClient(discord.Client): - async def on_ready(self): - print('Logged on as', self.user) - - async def on_message(self, message): - # don't respond to ourselves - if message.author == self.user: - return - - if message.content == 'ping': - await message.channel.send('pong') - - client = MyClient() - client.run('token') - -Botの例 -~~~~~~~~~~~~~ - -.. code:: py - - import discord - from discord.ext import commands - - bot = commands.Bot(command_prefix='>') - - @bot.command() - async def ping(ctx): - await ctx.send('pong') - - bot.run('token') - -examplesディレクトリに更に多くのサンプルがあります。 - -リンク ------- - -- `ドキュメント `_ -- `公式Discordサーバー `_ -- `Discord API `_ diff --git a/discord/__init__.py b/discord/__init__.py index 8864567f4..cc01d3472 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -2,18 +2,17 @@ Discord API Wrapper ~~~~~~~~~~~~~~~~~~~ -A basic wrapper for the Discord API. +A basic wrapper for the Discord user API. -:copyright: (c) 2015-present Rapptz +:copyright: (c) 2015-present Rapptz and 2021-present Dolfies :license: MIT, see LICENSE for more details. - """ -__title__ = 'discord' -__author__ = 'Rapptz' +__title__ = 'discord.py-self' +__author__ = 'Dolfies' __license__ = 'MIT' -__copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.0.0a' +__copyright__ = 'Copyright 2015-present Rapptz and 2021-present Dolfies' +__version__ = '2.0.0a2' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -43,11 +42,10 @@ from .template import * from .widget import * from .object import * from .reaction import * -from . import utils, opus, abc, ui, app_commands +from . import utils, opus, abc from .enums import * from .embeds import * from .mentions import * -from .shard import * from .player import * from .webhook import * from .voice_client import * @@ -60,16 +58,21 @@ from .scheduled_event import * from .interactions import * from .components import * from .threads import * +from .relationship import * +from .guild_folder import * +from .settings import * +from .profile import * +from .welcome_screen import * +from .modal import * -class VersionInfo(NamedTuple): +class _VersionInfo(NamedTuple): major: int minor: int micro: int - releaselevel: Literal["alpha", "beta", "candidate", "final"] + releaselevel: Literal['alpha', 'beta', 'candidate', 'final'] serial: int - -version_info: VersionInfo = VersionInfo(major=2, minor=0, micro=0, releaselevel='alpha', serial=0) +version_info: _VersionInfo = _VersionInfo(major=2, minor=0, micro=0, releaselevel='alpha', serial=2) logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/discord/__main__.py b/discord/__main__.py index 7ee5b0314..51c0933aa 100644 --- a/discord/__main__.py +++ b/discord/__main__.py @@ -60,7 +60,7 @@ from discord.ext import commands import discord import config -class Bot(commands.{base}): +class Bot(commands.Bot): def __init__(self, **kwargs): super().__init__(command_prefix=commands.when_mentioned_or('{prefix}'), **kwargs) for cog in config.cogs: @@ -241,8 +241,7 @@ def newbot(parser, args): try: with open(str(new_directory / 'bot.py'), 'w', encoding='utf-8') as fp: - base = 'Bot' if not args.sharded else 'AutoShardedBot' - fp.write(_bot_template.format(base=base, prefix=args.prefix)) + fp.write(_bot_template.format(prefix=args.prefix)) except OSError as exc: parser.error(f'could not create bot file ({exc})') @@ -297,7 +296,6 @@ def add_newbot_args(subparser): parser.add_argument('name', help='the bot project name') parser.add_argument('directory', help='the directory to place it in (default: .)', nargs='?', default=Path.cwd()) parser.add_argument('--prefix', help='the bot prefix (default: $)', default='$', metavar='') - parser.add_argument('--sharded', help='whether to use AutoShardedBot', action='store_true') parser.add_argument('--no-git', help='do not create a .gitignore file', action='store_true', dest='no_git') diff --git a/discord/abc.py b/discord/abc.py index d01b40fcf..d39b98c25 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -46,8 +46,9 @@ from typing import ( from .object import OLDEST_OBJECT, Object from .context_managers import Typing -from .enums import ChannelType +from .enums import AppCommandType, ChannelType from .errors import ClientException +from .iterators import CommandIterator from .mentions import AllowedMentions from .permissions import PermissionOverwrite, Permissions from .role import Role @@ -56,6 +57,7 @@ from .file import File from .http import handle_message_parameters from .voice_client import VoiceClient, VoiceProtocol from .sticker import GuildSticker, StickerItem +from .settings import ChannelSettings from . import utils __all__ = ( @@ -71,20 +73,17 @@ T = TypeVar('T', bound=VoiceProtocol) if TYPE_CHECKING: from typing_extensions import Self - from .client import Client - from .user import ClientUser + from .user import ClientUser, User from .asset import Asset from .state import ConnectionState from .guild import Guild from .member import Member from .channel import CategoryChannel - from .embeds import Embed from .message import Message, MessageReference, PartialMessage - from .channel import TextChannel, DMChannel, GroupChannel, PartialMessageable + from .channel import DMChannel, GroupChannel, PartialMessageable, TextChannel, VocalGuildChannel from .threads import Thread from .enums import InviteTarget - from .ui.view import View from .types.channel import ( PermissionOverwrite as PermissionOverwritePayload, Channel as ChannelPayload, @@ -95,6 +94,7 @@ if TYPE_CHECKING: PartialMessageableChannel = Union[TextChannel, Thread, DMChannel, PartialMessageable] MessageableChannel = Union[PartialMessageableChannel, GroupChannel] SnowflakeTime = Union["Snowflake", datetime] + ConnectableChannel = Union[VocalGuildChannel, DMChannel, GroupChannel, User] MISSING = utils.MISSING @@ -147,6 +147,8 @@ class User(Snowflake, Protocol): The user's discriminator. bot: :class:`bool` If the user is a bot account. + system: :class:`bool` + If the user is a system user (i.e. represents Discord officially). """ __slots__ = () @@ -192,6 +194,9 @@ class PrivateChannel(Snowflake, Protocol): me: ClientUser + def _add_call(self, **kwargs): + raise NotImplementedError + class _Overwrites: __slots__ = ('id', 'allow', 'deny', 'type') @@ -345,7 +350,7 @@ class GuildChannel: options['permission_overwrites'] = [c._asdict() for c in category._overwrites] options['parent_id'] = parent_id elif lock_permissions and self.category_id is not None: - # if we're syncing permissions on a pre-existing channel category without changing it + # If we're syncing permissions on a pre-existing channel category without changing it # we need to update the permissions to point to the pre-existing category category = self.guild.get_channel(self.category_id) if category: @@ -412,6 +417,13 @@ class GuildChannel: if tmp: tmp[everyone_index], tmp[0] = tmp[0], tmp[everyone_index] + @property + def notification_settings(self) -> ChannelSettings: + """:class:`ChannelSettings`: Returns the notification settings for this channel""" + guild = self.guild + # guild.notification_settings will always be present at this point + return guild.notification_settings._channel_overrides.get(self.id) or ChannelSettings(guild.id, state=self._state) # type: ignore + @property def changed_roles(self) -> List[Role]: """List[:class:`~discord.Role`]: Returns a list of roles that have been overridden from @@ -438,6 +450,14 @@ class GuildChannel: """:class:`datetime.datetime`: Returns the channel's creation time in UTC.""" return utils.snowflake_time(self.id) + @property + def jump_url(self) -> str: + """:class:`str`: Returns a URL that allows the client to jump to the channel. + + .. versionadded:: 2.0 + """ + return f'https://discord.com/channels/{self.guild.id}/{self.id}' + def overwrites_for(self, obj: Union[Role, User]) -> PermissionOverwrite: """Returns the channel-specific overwrites for a member or a role. @@ -469,7 +489,7 @@ class GuildChannel: return PermissionOverwrite() @property - def overwrites(self) -> Dict[Union[Role, Member], PermissionOverwrite]: + def overwrites(self) -> Dict[Union[Object, Role, Member], PermissionOverwrite]: """Returns all of the channel's overwrites. This is returned as a dictionary where the key contains the target which @@ -478,7 +498,7 @@ class GuildChannel: Returns -------- - Dict[Union[:class:`~discord.Role`, :class:`~discord.Member`], :class:`~discord.PermissionOverwrite`] + Dict[Union[:class:`~discord.Object`, :class:`~discord.Role`, :class:`~discord.Member`], :class:`~discord.PermissionOverwrite`] The channel's permission overwrites. """ ret = {} @@ -493,13 +513,10 @@ class GuildChannel: elif ow.is_member(): target = self.guild.get_member(ow.id) - # TODO: There is potential data loss here in the non-chunked - # case, i.e. target is None because get_member returned nothing. - # This can be fixed with a slight breaking change to the return type, - # i.e. adding discord.Object to the list of it - # However, for now this is an acceptable compromise. - if target is not None: - ret[target] = overwrite + if target is None: + target = Object(ow.id) + + ret[target] = overwrite return ret @property @@ -564,18 +581,18 @@ class GuildChannel: """ # The current cases can be explained as: - # Guild owner get all permissions -- no questions asked. Otherwise... - # The @everyone role gets the first application. + # Guild owner get all permissions -- no questions asked + # The @everyone role gets the first application # After that, the applied roles that the user has in the channel - # (or otherwise) are then OR'd together. + # (or otherwise) are then OR'd together # After the role permissions are resolved, the member permissions - # have to take into effect. - # After all that is done.. you have to do the following: + # have to take into effect + # After all that is done, you have to do the following: - # If manage permissions is True, then all permissions are set to True. + # If manage permissions is True, then all permissions are set to True # The operation first takes into consideration the denied - # and then the allowed. + # and then the allowed if self.guild.owner_id == obj.id: return Permissions.all() @@ -832,7 +849,7 @@ class GuildChannel: data = await self._state.http.create_channel(guild_id, self.type.value, reason=reason, **base_attrs) obj = cls(state=self._state, guild=self.guild, data=data) - # temporarily add it to the cache + # Temporarily add it to the cache self.guild._channels[obj.id] = obj # type: ignore - obj is a GuildChannel return obj @@ -1046,7 +1063,7 @@ class GuildChannel: await self._state.http.bulk_channel_update(self.guild.id, payload, reason=reason) - async def create_invite( + async def create_invite( # TODO: add validate self, *, reason: Optional[str] = None, @@ -1176,7 +1193,6 @@ class Messageable: content: Optional[str] = ..., *, tts: bool = ..., - embed: Embed = ..., file: File = ..., stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., delete_after: float = ..., @@ -1184,7 +1200,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., suppress_embeds: bool = ..., ) -> Message: ... @@ -1195,7 +1210,6 @@ class Messageable: content: Optional[str] = ..., *, tts: bool = ..., - embed: Embed = ..., files: List[File] = ..., stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., delete_after: float = ..., @@ -1203,7 +1217,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., suppress_embeds: bool = ..., ) -> Message: ... @@ -1214,7 +1227,6 @@ class Messageable: content: Optional[str] = ..., *, tts: bool = ..., - embeds: List[Embed] = ..., file: File = ..., stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., delete_after: float = ..., @@ -1222,7 +1234,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., suppress_embeds: bool = ..., ) -> Message: ... @@ -1233,7 +1244,6 @@ class Messageable: content: Optional[str] = ..., *, tts: bool = ..., - embeds: List[Embed] = ..., files: List[File] = ..., stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., delete_after: float = ..., @@ -1241,7 +1251,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., suppress_embeds: bool = ..., ) -> Message: ... @@ -1250,18 +1259,15 @@ class Messageable: self, content=None, *, - tts=None, - embed=None, - embeds=None, + tts=False, file=None, files=None, stickers=None, delete_after=None, - nonce=None, + nonce=MISSING, allowed_mentions=None, reference=None, mention_author=None, - view=None, suppress_embeds=False, ): """|coro| @@ -1269,19 +1275,13 @@ class Messageable: Sends a message to the destination with the content given. The content must be a type that can convert to a string through ``str(content)``. - If the content is set to ``None`` (the default), then the ``embed`` parameter must - be provided. + If the content is set to ``None`` (the default), then a sticker or file must be sent. To upload a single file, the ``file`` parameter should be used with a single :class:`~discord.File` object. To upload multiple files, the ``files`` parameter should be used with a :class:`list` of :class:`~discord.File` objects. **Specifying both parameters will lead to an exception**. - To upload a single embed, the ``embed`` parameter should be used with a - single :class:`~discord.Embed` object. To upload multiple embeds, the ``embeds`` - parameter should be used with a :class:`list` of :class:`~discord.Embed` objects. - **Specifying both parameters will lead to an exception**. - .. versionchanged:: 2.0 This function no-longer raises ``InvalidArgument`` instead raising :exc:`ValueError` or :exc:`TypeError` in various cases. @@ -1292,15 +1292,13 @@ class Messageable: The content of the message to send. tts: :class:`bool` Indicates if the message should be sent using text-to-speech. - embed: :class:`~discord.Embed` - The rich embed for the content. file: :class:`~discord.File` The file to upload. files: List[:class:`~discord.File`] A list of files to upload. Must be a maximum of 10. nonce: :class:`int` The nonce to use for sending this message. If the message was successfully sent, - then the message will have a nonce with this value. + then the message will have a nonce with this value. Generates one by default. delete_after: :class:`float` If provided, the number of seconds to wait in the background before deleting the message we just sent. If the deletion fails, @@ -1327,12 +1325,6 @@ class Messageable: If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. .. versionadded:: 1.6 - view: :class:`discord.ui.View` - A Discord UI View to add to the message. - embeds: List[:class:`~discord.Embed`] - A list of embeds to upload. Must be a maximum of 10. - - .. versionadded:: 2.0 stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. @@ -1352,7 +1344,6 @@ class Messageable: The ``files`` list is not of the appropriate size. TypeError You specified both ``file`` and ``files``, - or you specified both ``embed`` and ``embeds``, or the ``reference`` object is not a :class:`~discord.Message`, :class:`~discord.MessageReference` or :class:`~discord.PartialMessage`. @@ -1367,6 +1358,9 @@ class Messageable: content = str(content) if content is not None else None previous_allowed_mention = state.allowed_mentions + if nonce is MISSING: + nonce = str(utils.time_snowflake(datetime.utcnow())) + if stickers is not None: stickers = [sticker.id for sticker in stickers] else: @@ -1380,9 +1374,6 @@ class Messageable: else: reference = MISSING - if view and not hasattr(view, '__discord_ui_view__'): - raise TypeError(f'view parameter must be View not {view.__class__!r}') - if suppress_embeds: from .message import MessageFlags # circular import @@ -1395,22 +1386,17 @@ class Messageable: tts=tts, file=file if file is not None else MISSING, files=files if files is not None else MISSING, - embed=embed if embed is not None else MISSING, - embeds=embeds if embeds is not None else MISSING, nonce=nonce, allowed_mentions=allowed_mentions, message_reference=reference, previous_allowed_mentions=previous_allowed_mention, mention_author=mention_author, stickers=stickers, - view=view, flags=flags, ) as params: data = await state.http.send_message(channel.id, params=params) ret = state.create_message(channel=channel, data=data) - if view: - state.store_view(view, ret.id) if delete_after is not None: await ret.delete(delay=delete_after) @@ -1474,6 +1460,32 @@ class Messageable: data = await self._state.http.get_message(channel.id, id) return self._state.create_message(channel=channel, data=data) + async def ack(self) -> None: + """|coro| + + Marks every message in this channel as read. + + Raises + ------- + HTTPException + Acking failed. + """ + channel = await self._get_channel() + await self._state.http.ack_message(channel.id, channel.last_message_id or utils.time_snowflake(utils.utcnow())) + + async def ack_pins(self) -> None: + """|coro| + + Acks the channel's pins. + + Raises + ------- + ~discord.HTTPException + Acking the pinned messages failed. + """ + channel = await self._get_channel() + await self._state.http.ack_pins(channel.id) + async def pins(self) -> List[Message]: """|coro| @@ -1663,6 +1675,78 @@ class Messageable: for raw_message in data: yield self._state.create_message(channel=channel, data=raw_message) + def slash_commands( + self, + query: Optional[str] = None, + *, + limit: Optional[int] = None, + command_ids: Optional[List[int]] = None, + applications: bool = True, + application: Optional[Snowflake] = None, + ): + """Returns an iterator that allows you to see what slash commands are available to use. + + .. note:: + If this is a DM context, all parameters here are faked, as the only way to get commands is to fetch them all at once. + Because of this, all except ``query``, ``limit``, and ``command_ids`` are ignored. + It is recommended to not pass any parameters in that case. + + Examples + --------- + + Usage :: + + async for command in channel.slash_commands(): + print(command.name) + + Flattening into a list :: + + commands = await channel.slash_commands().flatten() + # commands is now a list of SlashCommand... + + All parameters are optional. + + Parameters + ---------- + query: Optional[:class:`str`] + The query to search for. + limit: Optional[:class:`int`] + The maximum number of commands to send back. + cache: :class:`bool` + Whether to cache the commands internally. + command_ids: Optional[List[:class:`int`]] + List of command IDs to search for. If the command doesn't exist it won't be returned. + applications: :class:`bool` + Whether to include applications in the response. This defaults to ``False``. + application: Optional[:class:`Snowflake`] + Query commands only for this application. + + Raises + ------ + TypeError + The user is not a bot. + Both query and command_ids were passed. + ValueError + The limit was not > 0. + HTTPException + Getting the commands failed. + + Yields + ------- + :class:`.SlashCommand` + A slash command. + """ + iterator = CommandIterator( + self, + AppCommandType.chat_input, + query, + limit, + command_ids, + applications=applications, + application=application, + ) + return iterator.iterate() + class Connectable(Protocol): """An ABC that details the common operations on a channel that can @@ -1672,11 +1756,16 @@ class Connectable(Protocol): - :class:`~discord.VoiceChannel` - :class:`~discord.StageChannel` + - :class:`~discord.DMChannel` + - :class:`~discord.GroupChannel` """ __slots__ = () _state: ConnectionState + async def _get_channel(self) -> Connectable: + return self + def _get_voice_client_key(self) -> Tuple[int, str]: raise NotImplementedError @@ -1689,14 +1778,13 @@ class Connectable(Protocol): timeout: float = 60.0, reconnect: bool = True, cls: Callable[[Client, Connectable], T] = MISSING, + _channel: Optional[Connectable] = None ) -> T: """|coro| Connects to voice and creates a :class:`~discord.VoiceClient` to establish your connection to the voice server. - This requires :attr:`~discord.Intents.voice_states`. - Parameters ----------- timeout: :class:`float` @@ -1726,19 +1814,19 @@ class Connectable(Protocol): key_id, _ = self._get_voice_client_key() state = self._state + connectable = _channel or self + channel = await connectable._get_channel() if state._get_voice_client(key_id): - raise ClientException('Already connected to a voice channel.') - - client = state._get_client() + raise ClientException('Already connected to a voice channel') if cls is MISSING: cls = VoiceClient - voice = cls(client, self) + voice = cls(state.client, channel) if not isinstance(voice, VoiceProtocol): - raise TypeError('Type must meet VoiceProtocol abstract base class.') + raise TypeError('Type must meet VoiceProtocol abstract base class') state._add_voice_client(key_id, voice) @@ -1748,8 +1836,7 @@ class Connectable(Protocol): try: await voice.disconnect(force=True) except Exception: - # we don't care if disconnect failed because connection failed - pass - raise # re-raise + pass # We don't care if disconnect failed because connection failed + raise # Re-raise return voice diff --git a/discord/activity.py b/discord/activity.py index f9d43ccf9..4440655db 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -252,6 +252,22 @@ class Activity(BaseActivity): inner = ' '.join('%s=%r' % t for t in attrs) return f'' + def __eq__(self, other): + return ( + isinstance(other, Activity) and + other.type == self.type and + other.name == self.name and + other.url == self.url and + other.emoji == self.emoji and + other.state == self.state and + other.session_id == self.session_id and + other.sync_id == self.sync_id and + other.start == self.start + ) + + def __ne__(self, other): + return not self.__eq__(other) + def to_dict(self) -> Dict[str, Any]: ret: Dict[str, Any] = {} for attr in self.__slots__: @@ -730,31 +746,45 @@ class CustomActivity(BaseActivity): .. versionadded:: 1.3 + .. note:: + Technically, the name of custom activities is hardcoded to "Custom Status", + and the state parameter has the actual custom text. + This is confusing, so here, the name represents the actual custom text. + However, the "correct" way still works. + Attributes ----------- name: Optional[:class:`str`] The custom activity's name. emoji: Optional[:class:`PartialEmoji`] The emoji to pass to the activity, if any. + expires_at: Optional[:class:`datetime.datetime`] + When the custom activity will expire. This is only available from :attr:`discord.Settings.custom_activity` """ - __slots__ = ('name', 'emoji', 'state') - - def __init__(self, name: Optional[str], *, emoji: Optional[PartialEmoji] = None, **extra: Any): - super().__init__(**extra) + __slots__ = ('name', 'emoji', 'expires_at') + + def __init__( + self, + name: Optional[str], + *, + emoji: Optional[PartialEmoji] = None, + state: Optional[str] = None, + expires_at: Optional[datetime.datetime] = None, + **kwargs, + ): + super().__init__(**kwargs) + if name == 'Custom Status': + name = state self.name: Optional[str] = name - self.state: Optional[str] = extra.pop('state', None) - if self.name == 'Custom Status': - self.name = self.state + self.expires_at = expires_at self.emoji: Optional[PartialEmoji] - if emoji is None: - self.emoji = emoji - elif isinstance(emoji, dict): + if isinstance(emoji, dict): self.emoji = PartialEmoji.from_dict(emoji) elif isinstance(emoji, str): self.emoji = PartialEmoji(name=emoji) - elif isinstance(emoji, PartialEmoji): + elif isinstance(emoji, PartialEmoji) or emoji is None: self.emoji = emoji else: raise TypeError(f'Expected str, PartialEmoji, or None, received {type(emoji)!r} instead.') @@ -767,23 +797,29 @@ class CustomActivity(BaseActivity): """ return ActivityType.custom - def to_dict(self) -> Dict[str, Any]: - if self.name == self.state: - o = { - 'type': ActivityType.custom.value, - 'state': self.name, - 'name': 'Custom Status', - } - else: - o = { - 'type': ActivityType.custom.value, - 'name': self.name, - } - + def to_dict(self) -> Dict[str, Union[str, int]]: + o = { + 'type': ActivityType.custom.value, + 'state': self.name, + 'name': 'Custom Status', # Not a confusing API at all + } if self.emoji: o['emoji'] = self.emoji.to_dict() return o + def to_settings_dict(self) -> Dict[str, Any]: + o: Dict[str, Optional[Union[str, int]]] = {} + + if (text := self.name): + o['text'] = text + if (emoji := self.emoji): + o['emoji_name'] = emoji.name + if emoji.id: + o['emoji_id'] = emoji.id + if (expiry := self.expires_at) is not None: + o['expires_at'] = expiry.isoformat() + return o + def __eq__(self, other: Any) -> bool: return isinstance(other, CustomActivity) and other.name == self.name and other.emoji == self.emoji @@ -833,13 +869,26 @@ def create_activity(data: Optional[ActivityPayload]) -> Optional[ActivityTypes]: except KeyError: return Activity(**data) else: - # we removed the name key from data already + # We removed the name key from data already return CustomActivity(name=name, **data) # type: ignore elif game_type is ActivityType.streaming: if 'url' in data: - # the url won't be None here + # The url won't be None here return Streaming(**data) # type: ignore return Activity(**data) elif game_type is ActivityType.listening and 'sync_id' in data and 'session_id' in data: return Spotify(**data) return Activity(**data) + +def create_settings_activity(*, data, state): + if not data: + return + + emoji = None + if (emoji_id := _get_as_snowflake(data, 'emoji_id')) is not None: + emoji = state.get_emoji(emoji_id) + emoji = emoji and emoji._to_partial() + elif (emoji_name := data.get('emoji_name')) is not None: + emoji = PartialEmoji(name=emoji_name) + + return CustomActivity(name=data.get('text'), emoji=emoji, expires_at=data.get('expires_at')) \ No newline at end of file diff --git a/discord/app_commands/__init__.py b/discord/app_commands/__init__.py deleted file mode 100644 index 3e88e4d4a..000000000 --- a/discord/app_commands/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -discord.app_commands -~~~~~~~~~~~~~~~~~~~~~ - -Application commands support for the Discord API - -:copyright: (c) 2015-present Rapptz -:license: MIT, see LICENSE for more details. - -""" - -from .commands import * -from .errors import * -from .models import * -from .tree import * -from .namespace import * -from .transformers import * diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py deleted file mode 100644 index 98307c8c5..000000000 --- a/discord/app_commands/commands.py +++ /dev/null @@ -1,1064 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations -import inspect - -from typing import ( - Any, - Callable, - ClassVar, - Coroutine, - Dict, - Generic, - List, - Optional, - Set, - TYPE_CHECKING, - Tuple, - TypeVar, - Union, -) -from textwrap import TextWrapper - -import re - -from ..enums import AppCommandOptionType, AppCommandType -from ..interactions import Interaction -from .models import Choice -from .transformers import annotation_to_parameter, CommandParameter, NoneType -from .errors import AppCommandError, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered -from ..message import Message -from ..user import User -from ..member import Member -from ..utils import resolve_annotation, MISSING, is_inside_class - -if TYPE_CHECKING: - from typing_extensions import ParamSpec, Concatenate - from .namespace import Namespace - from .models import ChoiceT - -__all__ = ( - 'Command', - 'ContextMenu', - 'Group', - 'context_menu', - 'command', - 'describe', - 'choices', - 'autocomplete', -) - -if TYPE_CHECKING: - P = ParamSpec('P') -else: - P = TypeVar('P') - -T = TypeVar('T') -GroupT = TypeVar('GroupT', bound='Group') -Coro = Coroutine[Any, Any, T] -Error = Union[ - Callable[[GroupT, Interaction, AppCommandError], Coro[Any]], - Callable[[Interaction, AppCommandError], Coro[Any]], -] - - -if TYPE_CHECKING: - CommandCallback = Union[ - Callable[Concatenate[GroupT, Interaction, P], Coro[T]], - Callable[Concatenate[Interaction, P], Coro[T]], - ] - - ContextMenuCallback = Union[ - # If groups end up support context menus these would be uncommented - # Callable[[GroupT, Interaction, Member], Coro[Any]], - # Callable[[GroupT, Interaction, User], Coro[Any]], - # Callable[[GroupT, Interaction, Message], Coro[Any]], - # Callable[[GroupT, Interaction, Union[Member, User]], Coro[Any]], - Callable[[Interaction, Member], Coro[Any]], - Callable[[Interaction, User], Coro[Any]], - Callable[[Interaction, Message], Coro[Any]], - Callable[[Interaction, Union[Member, User]], Coro[Any]], - ] - - AutocompleteCallback = Union[ - Callable[[GroupT, Interaction, ChoiceT, Namespace], Coro[List[Choice[ChoiceT]]]], - Callable[[Interaction, ChoiceT, Namespace], Coro[List[Choice[ChoiceT]]]], - ] -else: - CommandCallback = Callable[..., Coro[T]] - ContextMenuCallback = Callable[..., Coro[T]] - AutocompleteCallback = Callable[..., Coro[T]] - - -VALID_SLASH_COMMAND_NAME = re.compile(r'^[\w-]{1,32}$') -CAMEL_CASE_REGEX = re.compile(r'(? str: - return _wrapper.fill(' '.join(input.strip().split())) - - -def _to_kebab_case(text: str) -> str: - return CAMEL_CASE_REGEX.sub('-', text).lower() - - -def _context_menu_annotation(annotation: Any, *, _none=NoneType) -> AppCommandType: - if annotation is Message: - return AppCommandType.message - - supported_types: Set[Any] = {Member, User} - if annotation in supported_types: - return AppCommandType.user - - # Check if there's an origin - origin = getattr(annotation, '__origin__', None) - if origin is not Union: - # Only Union is supported so bail early - msg = ( - f'unsupported type annotation {annotation!r}, must be either discord.Member, ' - 'discord.User, discord.Message, or a typing.Union of discord.Member and discord.User' - ) - raise TypeError(msg) - - # Only Union[Member, User] is supported - if not all(arg in supported_types for arg in annotation.__args__): - raise TypeError(f'unsupported types given inside {annotation!r}') - - return AppCommandType.user - - -def _populate_descriptions(params: Dict[str, CommandParameter], descriptions: Dict[str, Any]) -> None: - for name, param in params.items(): - description = descriptions.pop(name, MISSING) - if description is MISSING: - param.description = '...' - continue - - if not isinstance(description, str): - raise TypeError('description must be a string') - - param.description = description - - if descriptions: - first = next(iter(descriptions)) - raise TypeError(f'unknown parameter given: {first}') - - -def _populate_choices(params: Dict[str, CommandParameter], all_choices: Dict[str, List[Choice]]) -> None: - for name, param in params.items(): - choices = all_choices.pop(name, MISSING) - if choices is MISSING: - continue - - if not isinstance(choices, list): - raise TypeError('choices must be a list of Choice') - - if not all(isinstance(choice, Choice) for choice in choices): - raise TypeError('choices must be a list of Choice') - - if param.type not in (AppCommandOptionType.string, AppCommandOptionType.number, AppCommandOptionType.integer): - raise TypeError('choices are only supported for integer, string, or number option types') - - # There's a type safety hole if someone does Choice[float] as an annotation - # but the values are actually Choice[int]. Since the input-output is the same this feels - # safe enough to ignore. - param.choices = choices - - if all_choices: - first = next(iter(all_choices)) - raise TypeError(f'unknown parameter given: {first}') - - -def _populate_autocomplete(params: Dict[str, CommandParameter], autocomplete: Dict[str, Any]) -> None: - for name, param in params.items(): - callback = autocomplete.pop(name, MISSING) - if callback is MISSING: - continue - - if not inspect.iscoroutinefunction(callback): - raise TypeError('autocomplete callback must be a coroutine function') - - if param.type not in (AppCommandOptionType.string, AppCommandOptionType.number, AppCommandOptionType.integer): - raise TypeError('autocomplete is only supported for integer, string, or number option types') - - param.autocomplete = callback - - if autocomplete: - first = next(iter(autocomplete)) - raise TypeError(f'unknown parameter given: {first}') - - -def _extract_parameters_from_callback(func: Callable[..., Any], globalns: Dict[str, Any]) -> Dict[str, CommandParameter]: - params = inspect.signature(func).parameters - cache = {} - required_params = is_inside_class(func) + 1 - if len(params) < required_params: - raise TypeError(f'callback must have more than {required_params - 1} parameter(s)') - - iterator = iter(params.values()) - for _ in range(0, required_params): - next(iterator) - - parameters: List[CommandParameter] = [] - for parameter in iterator: - if parameter.annotation is parameter.empty: - raise TypeError(f'annotation for {parameter.name} must be given') - - resolved = resolve_annotation(parameter.annotation, globalns, globalns, cache) - param = annotation_to_parameter(resolved, parameter) - parameters.append(param) - - values = sorted(parameters, key=lambda a: a.required, reverse=True) - result = {v.name: v for v in values} - - try: - descriptions = func.__discord_app_commands_param_description__ - except AttributeError: - for param in values: - if param.description is MISSING: - param.description = '...' - else: - _populate_descriptions(result, descriptions) - - try: - choices = func.__discord_app_commands_param_choices__ - except AttributeError: - pass - else: - _populate_choices(result, choices) - - try: - autocomplete = func.__discord_app_commands_param_autocomplete__ - except AttributeError: - pass - else: - _populate_autocomplete(result, autocomplete) - - return result - - -def _get_context_menu_parameter(func: ContextMenuCallback) -> Tuple[str, Any, AppCommandType]: - params = inspect.signature(func).parameters - if len(params) != 2: - msg = ( - 'context menu callbacks require 2 parameters, the first one being the annotation and the ' - 'other one explicitly annotated with either discord.Message, discord.User, discord.Member, ' - 'or a typing.Union of discord.Member and discord.User' - ) - raise TypeError(msg) - - iterator = iter(params.values()) - next(iterator) # skip interaction - parameter = next(iterator) - if parameter.annotation is parameter.empty: - msg = ( - 'second parameter of context menu callback must be explicitly annotated with either discord.Message, ' - 'discord.User, discord.Member, or a typing.Union of discord.Member and discord.User' - ) - raise TypeError(msg) - - resolved = resolve_annotation(parameter.annotation, func.__globals__, func.__globals__, {}) - type = _context_menu_annotation(resolved) - return (parameter.name, resolved, type) - - -class Command(Generic[GroupT, P, T]): - """A class that implements an application command. - - These are usually not created manually, instead they are created using - one of the following decorators: - - - :func:`~discord.app_commands.command` - - :meth:`Group.command ` - - :meth:`CommandTree.command ` - - .. versionadded:: 2.0 - - Attributes - ------------ - name: :class:`str` - The name of the application command. - description: :class:`str` - The description of the application command. This shows up in the UI to describe - the application command. - parent: Optional[:class:`Group`] - The parent application command. ``None`` if there isn't one. - """ - - def __init__( - self, - *, - name: str, - description: str, - callback: CommandCallback[GroupT, P, T], - parent: Optional[Group] = None, - ): - self.name: str = name - self.description: str = description - self._callback: CommandCallback[GroupT, P, T] = callback - self.parent: Optional[Group] = parent - self.binding: Optional[GroupT] = None - self.on_error: Optional[Error[GroupT]] = None - self._params: Dict[str, CommandParameter] = _extract_parameters_from_callback(callback, callback.__globals__) - - @property - def callback(self) -> CommandCallback[GroupT, P, T]: - """:ref:`coroutine `: The coroutine that is executed when the command is called.""" - return self._callback - - def _copy_with_binding(self, binding: GroupT) -> Command: - cls = self.__class__ - copy = cls.__new__(cls) - copy.name = self.name - copy.description = self.description - copy._callback = self._callback - copy.parent = self.parent - copy.on_error = self.on_error - copy._params = self._params.copy() - copy.binding = binding - return copy - - def to_dict(self) -> Dict[str, Any]: - # If we have a parent then our type is a subcommand - # Otherwise, the type falls back to the specific command type (e.g. slash command or context menu) - option_type = AppCommandType.chat_input.value if self.parent is None else AppCommandOptionType.subcommand.value - return { - 'name': self.name, - 'description': self.description, - 'type': option_type, - 'options': [param.to_dict() for param in self._params.values()], - } - - async def _invoke_error_handler(self, interaction: Interaction, error: AppCommandError) -> None: - # These type ignores are because the type checker can't narrow this type properly. - if self.on_error is not None: - if self.binding is not None: - await self.on_error(self.binding, interaction, error) # type: ignore - else: - await self.on_error(interaction, error) # type: ignore - - parent = self.parent - if parent is not None: - await parent.on_error(interaction, self, error) - - if parent.parent is not None: - await parent.parent.on_error(interaction, self, error) - - async def _invoke_with_namespace(self, interaction: Interaction, namespace: Namespace) -> T: - values = namespace.__dict__ - for name, param in self._params.items(): - try: - value = values[name] - except KeyError: - if not param.required: - values[name] = param.default - else: - raise CommandSignatureMismatch(self) from None - else: - values[name] = await param.transform(interaction, value) - - # These type ignores are because the type checker doesn't quite understand the narrowing here - # Likewise, it thinks we're missing positional arguments when there aren't any. - try: - if self.binding is not None: - return await self._callback(self.binding, interaction, **values) # type: ignore - return await self._callback(interaction, **values) # type: ignore - except TypeError as e: - # In order to detect mismatch from the provided signature and the Discord data, - # there are many ways it can go wrong yet all of them eventually lead to a TypeError - # from the Python compiler showcasing that the signature is incorrect. This lovely - # piece of code essentially checks the last frame of the caller and checks if the - # locals contains our `self` reference. - # - # This is because there is a possibility that a TypeError is raised within the body - # of the function, and in that case the locals wouldn't contain a reference to - # the command object under the name `self`. - frame = inspect.trace()[-1].frame - if frame.f_locals.get('self') is self: - raise CommandSignatureMismatch(self) from None - raise CommandInvokeError(self, e) from e - except AppCommandError: - raise - except Exception as e: - raise CommandInvokeError(self, e) from e - - async def _invoke_autocomplete(self, interaction: Interaction, name: str, namespace: Namespace): - value = namespace.__dict__[name] - - try: - param = self._params[name] - except KeyError: - raise CommandSignatureMismatch(self) from None - - if param.autocomplete is None: - raise CommandSignatureMismatch(self) - - if self.binding is not None: - choices = await param.autocomplete(self.binding, interaction, value, namespace) - else: - choices = await param.autocomplete(interaction, value, namespace) - - if interaction.response.is_done(): - return - - await interaction.response.autocomplete(choices) - - def _get_internal_command(self, name: str) -> Optional[Union[Command, Group]]: - return None - - @property - def root_parent(self) -> Optional[Group]: - """Optional[:class:`Group`]: The root parent of this command.""" - if self.parent is None: - return None - parent = self.parent - return parent.parent or parent - - def error(self, coro: Error[GroupT]) -> Error[GroupT]: - """A decorator that registers a coroutine as a local error handler. - - The local error handler is called whenever an exception is raised in the body - of the command or during handling of the command. The error handler must take - 2 parameters, the interaction and the error. - - The error passed will be derived from :exc:`AppCommandError`. - - Parameters - ----------- - coro: :ref:`coroutine ` - The coroutine to register as the local error handler. - - Raises - ------- - TypeError - The coroutine passed is not actually a coroutine. - """ - - if not inspect.iscoroutinefunction(coro): - raise TypeError('The error handler must be a coroutine.') - - self.on_error = coro - return coro - - def autocomplete( - self, name: str - ) -> Callable[[AutocompleteCallback[GroupT, ChoiceT]], AutocompleteCallback[GroupT, ChoiceT]]: - """A decorator that registers a coroutine as an autocomplete prompt for a parameter. - - The coroutine callback must have 3 parameters, the :class:`~discord.Interaction`, - the current value by the user (usually either a :class:`str`, :class:`int`, or :class:`float`, - depending on the type of the parameter being marked as autocomplete), and then the - :class:`Namespace` that represents possible values are partially filled in. - - The coroutine decorator **must** return a list of :class:`~discord.app_commands.Choice` objects. - Only up to 25 objects are supported. - - Example: - - .. code-block:: python3 - - @app_commands.command() - async def fruits(interaction: discord.Interaction, fruits: str): - await interaction.response.send_message(f'Your favourite fruit seems to be {fruits}') - - @fruits.autocomplete('fruits') - async def fruits_autocomplete( - interaction: discord.Interaction, - current: str, - namespace: app_commands.Namespace - ) -> List[app_commands.Choice[str]]: - fruits = ['Banana', 'Pineapple', 'Apple', 'Watermelon', 'Melon', 'Cherry'] - return [ - app_commands.Choice(name=fruit, value=fruit) - for fruit in fruits if current.lower() in fruit.lower() - ] - - - Parameters - ----------- - name: :class:`str` - The parameter name to register as autocomplete. - - Raises - ------- - TypeError - The coroutine passed is not actually a coroutine or - the parameter is not found or of an invalid type. - """ - - def decorator(coro: AutocompleteCallback[GroupT, ChoiceT]) -> AutocompleteCallback[GroupT, ChoiceT]: - if not inspect.iscoroutinefunction(coro): - raise TypeError('The error handler must be a coroutine.') - - try: - param = self._params[name] - except KeyError: - raise TypeError(f'unknown parameter: {name!r}') from None - - if param.type not in (AppCommandOptionType.string, AppCommandOptionType.number, AppCommandOptionType.integer): - raise TypeError('autocomplete is only supported for integer, string, or number option types') - - param.autocomplete = coro - return coro - - return decorator - - -class ContextMenu: - """A class that implements a context menu application command. - - These are usually not created manually, instead they are created using - one of the following decorators: - - - :func:`~discord.app_commands.context_menu` - - :meth:`CommandTree.command ` - - .. versionadded:: 2.0 - - Attributes - ------------ - name: :class:`str` - The name of the context menu. - type: :class:`.AppCommandType` - The type of context menu application command. - """ - - def __init__( - self, - *, - name: str, - callback: ContextMenuCallback, - type: AppCommandType, - ): - self.name: str = name - self._callback: ContextMenuCallback = callback - self.type: AppCommandType = type - (param, annotation, actual_type) = _get_context_menu_parameter(callback) - if actual_type != type: - raise ValueError(f'context menu callback implies a type of {actual_type} but {type} was passed.') - self._param_name = param - self._annotation = annotation - - @property - def callback(self) -> ContextMenuCallback: - """:ref:`coroutine `: The coroutine that is executed when the context menu is called.""" - return self._callback - - @classmethod - def _from_decorator(cls, callback: ContextMenuCallback, *, name: str = MISSING) -> ContextMenu: - (param, annotation, type) = _get_context_menu_parameter(callback) - - self = cls.__new__(cls) - self.name = callback.__name__.title() if name is MISSING else name - self._callback = callback - self.type = type - self._param_name = param - self._annotation = annotation - return self - - def to_dict(self) -> Dict[str, Any]: - return { - 'name': self.name, - 'type': self.type.value, - } - - async def _invoke(self, interaction: Interaction, arg: Any): - try: - await self._callback(interaction, arg) - except AppCommandError: - raise - except Exception as e: - raise CommandInvokeError(self, e) from e - - -class Group: - """A class that implements an application command group. - - These are usually inherited rather than created manually. - - .. versionadded:: 2.0 - - Attributes - ------------ - name: :class:`str` - The name of the group. If not given, it defaults to a lower-case - kebab-case version of the class name. - description: :class:`str` - The description of the group. This shows up in the UI to describe - the group. If not given, it defaults to the docstring of the - class shortened to 100 characters. - parent: Optional[:class:`Group`] - The parent group. ``None`` if there isn't one. - """ - - __discord_app_commands_group_children__: ClassVar[List[Union[Command, Group]]] = [] - __discord_app_commands_group_name__: str = MISSING - __discord_app_commands_group_description__: str = MISSING - - def __init_subclass__(cls, *, name: str = MISSING, description: str = MISSING) -> None: - cls.__discord_app_commands_group_children__ = children = [ - member for member in cls.__dict__.values() if isinstance(member, (Group, Command)) and member.parent is None - ] - - found = set() - for child in children: - if child.name in found: - raise TypeError(f'Command {child.name} is a duplicate') - found.add(child.name) - - if name is MISSING: - cls.__discord_app_commands_group_name__ = _to_kebab_case(cls.__name__) - else: - cls.__discord_app_commands_group_name__ = name - - if description is MISSING: - if cls.__doc__ is None: - cls.__discord_app_commands_group_description__ = '...' - else: - cls.__discord_app_commands_group_description__ = _shorten(cls.__doc__) - else: - cls.__discord_app_commands_group_description__ = description - - if len(children) > 25: - raise TypeError('groups cannot have more than 25 commands') - - def __init__( - self, - *, - name: str = MISSING, - description: str = MISSING, - parent: Optional[Group] = None, - ): - cls = self.__class__ - self.name: str = name if name is not MISSING else cls.__discord_app_commands_group_name__ - self.description: str = description or cls.__discord_app_commands_group_description__ - - if not self.description: - raise TypeError('groups must have a description') - - self.parent: Optional[Group] = parent - - self._children: Dict[str, Union[Command, Group]] = { - child.name: child._copy_with_binding(self) for child in self.__discord_app_commands_group_children__ - } - - for child in self._children.values(): - child.parent = self - - if parent is not None and parent.parent is not None: - raise ValueError('groups can only be nested at most one level') - - def _copy_with_binding(self, binding: Group) -> Group: - cls = self.__class__ - copy = cls.__new__(cls) - copy.name = self.name - copy.description = self.description - copy.parent = self.parent - copy._children = {child.name: child._copy_with_binding(binding) for child in self._children.values()} - return copy - - def to_dict(self) -> Dict[str, Any]: - # If this has a parent command then it's part of a subcommand group - # Otherwise, it's just a regular command - option_type = 1 if self.parent is None else AppCommandOptionType.subcommand_group.value - return { - 'name': self.name, - 'description': self.description, - 'type': option_type, - 'options': [child.to_dict() for child in self._children.values()], - } - - @property - def root_parent(self) -> Optional[Group]: - """Optional[:class:`Group`]: The parent of this group.""" - return self.parent - - def _get_internal_command(self, name: str) -> Optional[Union[Command, Group]]: - return self._children.get(name) - - async def on_error(self, interaction: Interaction, command: Command, error: AppCommandError) -> None: - """|coro| - - A callback that is called when a child's command raises an :exc:`AppCommandError`. - - The default implementation does nothing. - - Parameters - ----------- - interaction: :class:`~discord.Interaction` - The interaction that is being handled. - command: :class:`~discord.app_commands.Command` - The command that failed. - error: :exc:`AppCommandError` - The exception that was raised. - """ - - pass - - def add_command(self, command: Union[Command, Group], /, *, override: bool = False): - """Adds a command or group to this group's internal list of commands. - - Parameters - ----------- - command: Union[:class:`Command`, :class:`Group`] - The command or group to add. - override: :class:`bool` - Whether to override a pre-existing command or group with the same name. - If ``False`` then an exception is raised. - - Raises - ------- - CommandAlreadyRegistered - The command or group is already registered. Note that the :attr:`CommandAlreadyRegistered.guild_id` - attribute will always be ``None`` in this case. - ValueError - There are too many commands already registered. - TypeError - The wrong command type was passed. - """ - - if not isinstance(command, (Command, Group)): - raise TypeError(f'expected Command or Group not {command.__class__!r}') - - if not override and command.name in self._children: - raise CommandAlreadyRegistered(command.name, guild_id=None) - - self._children[command.name] = command - if len(self._children) > 25: - raise ValueError('maximum number of child commands exceeded') - - def remove_command(self, name: str, /) -> Optional[Union[Command, Group]]: - """Removes a command or group from the internal list of commands. - - Parameters - ----------- - name: :class:`str` - The name of the command or group to remove. - - Returns - -------- - Optional[Union[:class:`~discord.app_commands.Command`, :class:`~discord.app_commands.Group`]] - The command that was removed. If nothing was removed - then ``None`` is returned instead. - """ - - self._children.pop(name, None) - - def get_command(self, name: str, /) -> Optional[Union[Command, Group]]: - """Retrieves a command or group from its name. - - Parameters - ----------- - name: :class:`str` - The name of the command or group to retrieve. - - Returns - -------- - Optional[Union[:class:`~discord.app_commands.Command`, :class:`~discord.app_commands.Group`]] - The command or group that was retrieved. If nothing was found - then ``None`` is returned instead. - """ - return self._children.get(name) - - def command( - self, - *, - name: str = MISSING, - description: str = MISSING, - ) -> Callable[[CommandCallback[GroupT, P, T]], Command[GroupT, P, T]]: - """Creates an application command under this group. - - Parameters - ------------ - name: :class:`str` - The name of the application command. If not given, it defaults to a lower-case - version of the callback name. - description: :class:`str` - The description of the application command. This shows up in the UI to describe - the application command. If not given, it defaults to the first line of the docstring - of the callback shortened to 100 characters. - """ - - def decorator(func: CommandCallback[GroupT, P, T]) -> Command[GroupT, P, T]: - if not inspect.iscoroutinefunction(func): - raise TypeError('command function must be a coroutine function') - - if description is MISSING: - if func.__doc__ is None: - desc = '...' - else: - desc = _shorten(func.__doc__) - else: - desc = description - - command = Command( - name=name if name is not MISSING else func.__name__, - description=desc, - callback=func, - parent=self, - ) - self.add_command(command) - return command - - return decorator - - -def command( - *, - name: str = MISSING, - description: str = MISSING, -) -> Callable[[CommandCallback[GroupT, P, T]], Command[GroupT, P, T]]: - """Creates an application command from a regular function. - - Parameters - ------------ - name: :class:`str` - The name of the application command. If not given, it defaults to a lower-case - version of the callback name. - description: :class:`str` - The description of the application command. This shows up in the UI to describe - the application command. If not given, it defaults to the first line of the docstring - of the callback shortened to 100 characters. - """ - - def decorator(func: CommandCallback[GroupT, P, T]) -> Command[GroupT, P, T]: - if not inspect.iscoroutinefunction(func): - raise TypeError('command function must be a coroutine function') - - if description is MISSING: - if func.__doc__ is None: - desc = '...' - else: - desc = _shorten(func.__doc__) - else: - desc = description - - return Command( - name=name if name is not MISSING else func.__name__, - description=desc, - callback=func, - parent=None, - ) - - return decorator - - -def context_menu(*, name: str = MISSING) -> Callable[[ContextMenuCallback], ContextMenu]: - """Creates a application command context menu from a regular function. - - This function must have a signature of :class:`~discord.Interaction` as its first parameter - and taking either a :class:`~discord.Member`, :class:`~discord.User`, or :class:`~discord.Message`, - or a :obj:`typing.Union` of ``Member`` and ``User`` as its second parameter. - - Examples - --------- - - .. code-block:: python3 - - @app_commands.context_menu() - async def react(interaction: discord.Interaction, message: discord.Message): - await interaction.response.send_message('Very cool message!', ephemeral=True) - - @app_commands.context_menu() - async def ban(interaction: discord.Interaction, user: discord.Member): - await interaction.response.send_message(f'Should I actually ban {user}...', ephemeral=True) - - Parameters - ------------ - name: :class:`str` - The name of the context menu command. If not given, it defaults to a title-case - version of the callback name. Note that unlike regular slash commands this can - have spaces and upper case characters in the name. - """ - - def decorator(func: ContextMenuCallback) -> ContextMenu: - if not inspect.iscoroutinefunction(func): - raise TypeError('context menu function must be a coroutine function') - - return ContextMenu._from_decorator(func, name=name) - - return decorator - - -def describe(**parameters: str) -> Callable[[T], T]: - r"""Describes the given parameters by their name using the key of the keyword argument - as the name. - - Example: - - .. code-block:: python3 - - @app_commands.command() - @app_commands.describe(member='the member to ban') - async def ban(interaction: discord.Interaction, member: discord.Member): - await interaction.response.send_message(f'Banned {member}') - - Parameters - ----------- - \*\*parameters - The description of the parameters. - - Raises - -------- - TypeError - The parameter name is not found. - """ - - def decorator(inner: T) -> T: - if isinstance(inner, Command): - _populate_descriptions(inner._params, parameters) - else: - try: - inner.__discord_app_commands_param_description__.update(parameters) # type: ignore - Runtime attribute access - except AttributeError: - inner.__discord_app_commands_param_description__ = parameters # type: ignore - Runtime attribute assignment - - return inner - - return decorator - - -def choices(**parameters: List[Choice]) -> Callable[[T], T]: - r"""Instructs the given parameters by their name to use the given choices for their choices. - - Example: - - .. code-block:: python3 - - @app_commands.command() - @app_commands.describe(fruits='fruits to choose from') - @app_commands.choices(fruits=[ - Choice(name='apple', value=1), - Choice(name='banana', value=2), - Choice(name='cherry', value=3), - ]) - async def fruit(interaction: discord.Interaction, fruits: Choice[int]): - await interaction.response.send_message(f'Your favourite fruit is {fruits.name}.') - - .. note:: - - This is not the only way to provide choices to a command. There are two more ergonomic ways - of doing this. The first one is to use a :obj:`typing.Literal` annotation: - - .. code-block:: python3 - - @app_commands.command() - @app_commands.describe(fruits='fruits to choose from') - async def fruit(interaction: discord.Interaction, fruits: Literal['apple', 'banana', 'cherry']): - await interaction.response.send_message(f'Your favourite fruit is {fruits}.') - - The second way is to use an :class:`enum.Enum`: - - .. code-block:: python3 - - class Fruits(enum.Enum): - apple = 1 - banana = 2 - cherry = 3 - - @app_commands.command() - @app_commands.describe(fruits='fruits to choose from') - async def fruit(interaction: discord.Interaction, fruits: Fruits): - await interaction.response.send_message(f'Your favourite fruit is {fruits}.') - - - Parameters - ----------- - \*\*parameters - The choices of the parameters. - - Raises - -------- - TypeError - The parameter name is not found or the parameter type was incorrect. - """ - - def decorator(inner: T) -> T: - if isinstance(inner, Command): - _populate_choices(inner._params, parameters) - else: - try: - inner.__discord_app_commands_param_choices__.update(parameters) # type: ignore - Runtime attribute access - except AttributeError: - inner.__discord_app_commands_param_choices__ = parameters # type: ignore - Runtime attribute assignment - - return inner - - return decorator - - -def autocomplete(**parameters: AutocompleteCallback[GroupT, ChoiceT]) -> Callable[[T], T]: - r"""Associates the given parameters with the given autocomplete callback. - - Autocomplete is only supported on types that have :class:`str`, :class:`int`, or :class:`float` - values. - - Example: - - .. code-block:: python3 - - @app_commands.command() - @app_commands.autocomplete(fruits=fruits_autocomplete) - async def fruits(interaction: discord.Interaction, fruits: str): - await interaction.response.send_message(f'Your favourite fruit seems to be {fruits}') - - async def fruits_autocomplete( - interaction: discord.Interaction, - current: str, - namespace: app_commands.Namespace - ) -> List[app_commands.Choice[str]]: - fruits = ['Banana', 'Pineapple', 'Apple', 'Watermelon', 'Melon', 'Cherry'] - return [ - app_commands.Choice(name=fruit, value=fruit) - for fruit in fruits if current.lower() in fruit.lower() - ] - - Parameters - ----------- - \*\*parameters - The parameters to mark as autocomplete. - - Raises - -------- - TypeError - The parameter name is not found or the parameter type was incorrect. - """ - - def decorator(inner: T) -> T: - if isinstance(inner, Command): - _populate_autocomplete(inner._params, parameters) - else: - try: - inner.__discord_app_commands_param_autocomplete__.update(parameters) # type: ignore - Runtime attribute access - except AttributeError: - inner.__discord_app_commands_param_autocomplete__ = parameters # type: ignore - Runtime attribute assignment - - return inner - - return decorator diff --git a/discord/app_commands/errors.py b/discord/app_commands/errors.py deleted file mode 100644 index e4a379e09..000000000 --- a/discord/app_commands/errors.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations - -from typing import Any, TYPE_CHECKING, List, Optional, Type, Union - - -from ..enums import AppCommandOptionType, AppCommandType -from ..errors import DiscordException - -__all__ = ( - 'AppCommandError', - 'CommandInvokeError', - 'TransformerError', - 'CommandAlreadyRegistered', - 'CommandSignatureMismatch', - 'CommandNotFound', -) - -if TYPE_CHECKING: - from .commands import Command, Group, ContextMenu - from .transformers import Transformer - - -class AppCommandError(DiscordException): - """The base exception type for all application command related errors. - - This inherits from :exc:`discord.DiscordException`. - - This exception and exceptions inherited from it are handled - in a special way as they are caught and passed into various error handlers - in this order: - - - :meth:`Command.error ` - - :meth:`Group.on_error ` - - :meth:`CommandTree.on_error ` - - .. versionadded:: 2.0 - """ - - pass - - -class CommandInvokeError(AppCommandError): - """An exception raised when the command being invoked raised an exception. - - This inherits from :exc:`~discord.app_commands.AppCommandError`. - - .. versionadded:: 2.0 - - Attributes - ----------- - original: :exc:`Exception` - The original exception that was raised. You can also get this via - the ``__cause__`` attribute. - command: Union[:class:`Command`, :class:`ContextMenu`] - The command that failed. - """ - - def __init__(self, command: Union[Command, ContextMenu], e: Exception) -> None: - self.original: Exception = e - self.command: Union[Command, ContextMenu] = command - super().__init__(f'Command {command.name!r} raised an exception: {e.__class__.__name__}: {e}') - - -class TransformerError(AppCommandError): - """An exception raised when a :class:`Transformer` or type annotation fails to - convert to its target type. - - This inherits from :exc:`~discord.app_commands.AppCommandError`. - - .. note:: - - If the transformer raises a custom :exc:`AppCommandError` then it will - be propagated rather than wrapped into this exception. - - .. versionadded:: 2.0 - - Attributes - ----------- - value: Any - The value that failed to convert. - type: :class:`~discord.AppCommandOptionType` - The type of argument that failed to convert. - transformer: Type[:class:`Transformer`] - The transformer that failed the conversion. - """ - - def __init__(self, value: Any, opt_type: AppCommandOptionType, transformer: Type[Transformer]): - self.value: Any = value - self.type: AppCommandOptionType = opt_type - self.transformer: Type[Transformer] = transformer - - try: - result_type = transformer.transform.__annotations__['return'] - except KeyError: - name = transformer.__name__ - if name.endswith('Transformer'): - result_type = name[:-11] - else: - result_type = name - else: - if isinstance(result_type, type): - result_type = result_type.__name__ - - super().__init__(f'Failed to convert {value} to {result_type!s}') - - -class CommandAlreadyRegistered(AppCommandError): - """An exception raised when a command is already registered. - - This inherits from :exc:`~discord.app_commands.AppCommandError`. - - .. versionadded:: 2.0 - - Attributes - ----------- - name: :class:`str` - The name of the command already registered. - guild_id: Optional[:class:`int`] - The guild ID this command was already registered at. - If ``None`` then it was a global command. - """ - - def __init__(self, name: str, guild_id: Optional[int]): - self.name: str = name - self.guild_id: Optional[int] = guild_id - super().__init__(f'Command {name!r} already registered.') - - -class CommandNotFound(AppCommandError): - """An exception raised when an application command could not be found. - - This inherits from :exc:`~discord.app_commands.AppCommandError`. - - .. versionadded:: 2.0 - - Attributes - ------------ - name: :class:`str` - The name of the application command not found. - parents: List[:class:`str`] - A list of parent command names that were previously found - prior to the application command not being found. - type: :class:`~discord.AppCommandType` - The type of command that was not found. - """ - - def __init__(self, name: str, parents: List[str], type: AppCommandType = AppCommandType.chat_input): - self.name: str = name - self.parents: List[str] = parents - self.type: AppCommandType = type - super().__init__(f'Application command {name!r} not found') - - -class CommandSignatureMismatch(AppCommandError): - """An exception raised when an application command from Discord has a different signature - from the one provided in the code. This happens because your command definition differs - from the command definition you provided Discord. Either your code is out of date or the - data from Discord is out of sync. - - This inherits from :exc:`~discord.app_commands.AppCommandError`. - - .. versionadded:: 2.0 - - Attributes - ------------ - command: Union[:class:`~.app_commands.Command`, :class:`~.app_commands.ContextMenu`, :class:`~.app_commands.Group`] - The command that had the signature mismatch. - """ - - def __init__(self, command: Union[Command, ContextMenu, Group]): - self.command: Union[Command, ContextMenu, Group] = command - msg = ( - f'The signature for command {command.name!r} is different from the one provided by Discord. ' - 'This can happen because either your code is out of date or you have not synced the ' - 'commands with Discord, causing the mismatch in data. It is recommended to sync the ' - 'command tree to fix this issue.' - ) - super().__init__(msg) diff --git a/discord/app_commands/models.py b/discord/app_commands/models.py deleted file mode 100644 index b4654988a..000000000 --- a/discord/app_commands/models.py +++ /dev/null @@ -1,609 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations -from datetime import datetime - - -from ..permissions import Permissions -from ..enums import AppCommandOptionType, AppCommandType, ChannelType, try_enum -from ..mixins import Hashable -from ..utils import _get_as_snowflake, parse_time, snowflake_time -from typing import Generic, List, TYPE_CHECKING, Optional, TypeVar, Union - -__all__ = ( - 'AppCommand', - 'AppCommandGroup', - 'AppCommandChannel', - 'AppCommandThread', - 'Argument', - 'Choice', -) - -ChoiceT = TypeVar('ChoiceT', str, int, float, Union[str, int, float]) - - -def is_app_command_argument_type(value: int) -> bool: - return 11 >= value >= 3 - - -if TYPE_CHECKING: - from ..types.command import ( - ApplicationCommand as ApplicationCommandPayload, - ApplicationCommandOptionChoice, - ApplicationCommandOption, - ) - from ..types.interactions import ( - PartialChannel, - PartialThread, - ) - from ..types.threads import ThreadMetadata - from ..state import ConnectionState - from ..guild import GuildChannel, Guild - from ..channel import TextChannel - from ..threads import Thread - - ApplicationCommandParent = Union['AppCommand', 'AppCommandGroup'] - - -class AppCommand(Hashable): - """Represents a application command. - - In common parlance this is referred to as a "Slash Command" or a - "Context Menu Command". - - .. versionadded:: 2.0 - - .. container:: operations - - .. describe:: x == y - - Checks if two application commands are equal. - - .. describe:: x != y - - Checks if two application commands are not equal. - - .. describe:: hash(x) - - Returns the application command's hash. - - .. describe:: str(x) - - Returns the application command's name. - - Attributes - ----------- - id: :class:`int` - The application command's ID. - application_id: :class:`int` - The application command's application's ID. - type: :class:`~discord.AppCommandType` - The application command's type. - name: :class:`str` - The application command's name. - description: :class:`str` - The application command's description. - """ - - __slots__ = ( - 'id', - 'type', - 'application_id', - 'name', - 'description', - 'options', - '_state', - ) - - def __init__(self, *, data: ApplicationCommandPayload, state=None): - self._state = state - self._from_data(data) - - def _from_data(self, data: ApplicationCommandPayload): - self.id: int = int(data['id']) - self.application_id: int = int(data['application_id']) - self.name: str = data['name'] - self.description: str = data['description'] - self.type: AppCommandType = try_enum(AppCommandType, data.get('type', 1)) - self.options = [app_command_option_factory(data=d, parent=self, state=self._state) for d in data.get('options', [])] - - def to_dict(self) -> ApplicationCommandPayload: - return { - 'id': self.id, - 'type': self.type.value, - 'application_id': self.application_id, - 'name': self.name, - 'description': self.description, - 'options': [opt.to_dict() for opt in self.options], - } # type: ignore -- Type checker does not understand this literal. - - def __str__(self) -> str: - return self.name - - def __repr__(self) -> str: - return f'<{self.__class__.__name__} id={self.id!r} name={self.name!r} type={self.type!r}>' - - -class Choice(Generic[ChoiceT]): - """Represents an application command argument choice. - - .. versionadded:: 2.0 - - .. container:: operations - - .. describe:: x == y - - Checks if two choices are equal. - - .. describe:: x != y - - Checks if two choices are not equal. - - .. describe:: hash(x) - - Returns the choice's hash. - - Parameters - ----------- - name: :class:`str` - The name of the choice. Used for display purposes. - value: Union[:class:`int`, :class:`str`, :class:`float`] - The value of the choice. - """ - - __slots__ = ('name', 'value') - - def __init__(self, *, name: str, value: ChoiceT): - self.name: str = name - self.value: ChoiceT = value - - def __eq__(self, o: object) -> bool: - return isinstance(o, Choice) and self.name == o.name and self.value == o.value - - def __hash__(self) -> int: - return hash((self.name, self.value)) - - def __repr__(self) -> str: - return f'{self.__class__.__name__}(name={self.name!r}, value={self.value!r})' - - def to_dict(self) -> ApplicationCommandOptionChoice: - return { - 'name': self.name, - 'value': self.value, - } # type: ignore -- Type checker does not understand this literal. - - -class AppCommandChannel(Hashable): - """Represents an application command partially resolved channel object. - - .. versionadded:: 2.0 - - .. container:: operations - - .. describe:: x == y - - Checks if two channels are equal. - - .. describe:: x != y - - Checks if two channels are not equal. - - .. describe:: hash(x) - - Returns the channel's hash. - - .. describe:: str(x) - - Returns the channel's name. - - Attributes - ----------- - id: :class:`int` - The ID of the channel. - type: :class:`~discord.ChannelType` - The type of channel. - name: :class:`str` - The name of the channel. - permissions: :class:`~discord.Permissions` - The resolved permissions of the user who invoked - the application command in that channel. - guild_id: :class:`int` - The guild ID this channel belongs to. - """ - - __slots__ = ( - 'id', - 'type', - 'name', - 'permissions', - 'guild_id', - '_state', - ) - - def __init__( - self, - *, - state: ConnectionState, - data: PartialChannel, - guild_id: int, - ): - self._state = state - self.guild_id = guild_id - self.id = int(data['id']) - self.type = try_enum(ChannelType, data['type']) - self.name = data['name'] - self.permissions = Permissions(int(data['permissions'])) - - def __str__(self) -> str: - return self.name - - def __repr__(self) -> str: - return f'<{self.__class__.__name__} id={self.id!r} name={self.name!r} type={self.type!r}>' - - @property - def guild(self) -> Optional[Guild]: - """Optional[:class:`~discord.Guild`]: The channel's guild, from cache, if found.""" - return self._state._get_guild(self.guild_id) - - def resolve(self) -> Optional[GuildChannel]: - """Resolves the application command channel to the appropriate channel - from cache if found. - - Returns - -------- - Optional[:class:`.abc.GuildChannel`] - The resolved guild channel or ``None`` if not found in cache. - """ - guild = self._state._get_guild(self.guild_id) - if guild is not None: - return guild.get_channel(self.id) - return None - - async def fetch(self) -> GuildChannel: - """|coro| - - Fetches the partial channel to a full :class:`.abc.GuildChannel`. - - Raises - -------- - NotFound - The channel was not found. - Forbidden - You do not have the permissions required to get a channel. - HTTPException - Retrieving the channel failed. - - Returns - -------- - :class:`.abc.GuildChannel` - The full channel. - """ - client = self._state._get_client() - return await client.fetch_channel(self.id) # type: ignore -- This is explicit narrowing - - @property - def mention(self) -> str: - """:class:`str`: The string that allows you to mention the channel.""" - return f'<#{self.id}>' - - @property - def created_at(self) -> datetime: - """:class:`datetime.datetime`: An aware timestamp of when this channel was created in UTC.""" - return snowflake_time(self.id) - - -class AppCommandThread(Hashable): - """Represents an application command partially resolved thread object. - - .. versionadded:: 2.0 - - .. container:: operations - - .. describe:: x == y - - Checks if two thread are equal. - - .. describe:: x != y - - Checks if two thread are not equal. - - .. describe:: hash(x) - - Returns the thread's hash. - - .. describe:: str(x) - - Returns the thread's name. - - Attributes - ----------- - id: :class:`int` - The ID of the thread. - type: :class:`~discord.ChannelType` - The type of thread. - name: :class:`str` - The name of the thread. - parent_id: :class:`int` - The parent text channel ID this thread belongs to. - permissions: :class:`~discord.Permissions` - The resolved permissions of the user who invoked - the application command in that thread. - guild_id: :class:`int` - The guild ID this thread belongs to. - archived: :class:`bool` - Whether the thread is archived. - locked: :class:`bool` - Whether the thread is locked. - invitable: :class:`bool` - Whether non-moderators can add other non-moderators to this thread. - This is always ``True`` for public threads. - archiver_id: Optional[:class:`int`] - The user's ID that archived this thread. - auto_archive_duration: :class:`int` - The duration in minutes until the thread is automatically archived due to inactivity. - Usually a value of 60, 1440, 4320 and 10080. - archive_timestamp: :class:`datetime.datetime` - An aware timestamp of when the thread's archived status was last updated in UTC. - """ - - __slots__ = ( - 'id', - 'type', - 'name', - 'permissions', - 'guild_id', - 'parent_id', - 'archived', - 'archiver_id', - 'auto_archive_duration', - 'archive_timestamp', - 'locked', - 'invitable', - '_created_at', - '_state', - ) - - def __init__( - self, - *, - state: ConnectionState, - data: PartialThread, - guild_id: int, - ): - self._state = state - self.guild_id = guild_id - self.id = int(data['id']) - self.parent_id = int(data['parent_id']) - self.type = try_enum(ChannelType, data['type']) - self.name = data['name'] - self.permissions = Permissions(int(data['permissions'])) - self._unroll_metadata(data['thread_metadata']) - - def __str__(self) -> str: - return self.name - - def __repr__(self) -> str: - return f'<{self.__class__.__name__} id={self.id!r} name={self.name!r} archived={self.archived} type={self.type!r}>' - - @property - def guild(self) -> Optional[Guild]: - """Optional[:class:`~discord.Guild`]: The channel's guild, from cache, if found.""" - return self._state._get_guild(self.guild_id) - - def _unroll_metadata(self, data: ThreadMetadata): - self.archived = data['archived'] - self.archiver_id = _get_as_snowflake(data, 'archiver_id') - self.auto_archive_duration = data['auto_archive_duration'] - self.archive_timestamp = parse_time(data['archive_timestamp']) - self.locked = data.get('locked', False) - self.invitable = data.get('invitable', True) - self._created_at = parse_time(data.get('create_timestamp')) - - @property - def parent(self) -> Optional[TextChannel]: - """Optional[:class:`~discord.TextChannel`]: The parent channel this thread belongs to.""" - return self.guild.get_channel(self.parent_id) # type: ignore - - @property - def mention(self) -> str: - """:class:`str`: The string that allows you to mention the thread.""" - return f'<#{self.id}>' - - @property - def created_at(self) -> Optional[datetime]: - """An aware timestamp of when the thread was created in UTC. - - .. note:: - - This timestamp only exists for threads created after 9 January 2022, otherwise returns ``None``. - """ - return self._created_at - - def resolve(self) -> Optional[Thread]: - """Resolves the application command channel to the appropriate channel - from cache if found. - - Returns - -------- - Optional[:class:`.abc.GuildChannel`] - The resolved guild channel or ``None`` if not found in cache. - """ - guild = self._state._get_guild(self.guild_id) - if guild is not None: - return guild.get_thread(self.id) - return None - - async def fetch(self) -> Thread: - """|coro| - - Fetches the partial channel to a full :class:`~discord.Thread`. - - Raises - -------- - NotFound - The thread was not found. - Forbidden - You do not have the permissions required to get a thread. - HTTPException - Retrieving the thread failed. - - Returns - -------- - :class:`~discord.Thread` - The full thread. - """ - client = self._state._get_client() - return await client.fetch_channel(self.id) # type: ignore -- This is explicit narrowing - - -class Argument: - """Represents a application command argument. - - .. versionadded:: 2.0 - - Attributes - ------------ - type: :class:`~discord.AppCommandOptionType` - The type of argument. - name: :class:`str` - The name of the argument. - description: :class:`str` - The description of the argument. - required: :class:`bool` - Whether the argument is required. - choices: List[:class:`Choice`] - A list of choices for the command to choose from for this argument. - parent: Union[:class:`AppCommand`, :class:`AppCommandGroup`] - The parent application command that has this argument. - """ - - __slots__ = ( - 'type', - 'name', - 'description', - 'required', - 'choices', - 'parent', - '_state', - ) - - def __init__(self, *, parent: ApplicationCommandParent, data: ApplicationCommandOption, state=None): - self._state = state - self.parent = parent - self._from_data(data) - - def __repr__(self) -> str: - return f'<{self.__class__.__name__} name={self.name!r} type={self.type!r} required={self.required}>' - - def _from_data(self, data: ApplicationCommandOption): - self.type: AppCommandOptionType = try_enum(AppCommandOptionType, data['type']) - self.name: str = data['name'] - self.description: str = data['description'] - self.required: bool = data.get('required', False) - self.choices: List[Choice] = [Choice(name=d['name'], value=d['value']) for d in data.get('choices', [])] - - def to_dict(self) -> ApplicationCommandOption: - return { - 'name': self.name, - 'type': self.type.value, - 'description': self.description, - 'required': self.required, - 'choices': [choice.to_dict() for choice in self.choices], - 'options': [], - } # type: ignore -- Type checker does not understand this literal. - - -class AppCommandGroup: - """Represents a application command subcommand. - - .. versionadded:: 2.0 - - Attributes - ------------ - type: :class:`~discord.AppCommandOptionType` - The type of subcommand. - name: :class:`str` - The name of the subcommand. - description: :class:`str` - The description of the subcommand. - required: :class:`bool` - Whether the subcommand is required. - choices: List[:class:`Choice`] - A list of choices for the command to choose from for this subcommand. - arguments: List[:class:`Argument`] - A list of arguments. - parent: Union[:class:`AppCommand`, :class:`AppCommandGroup`] - The parent application command. - """ - - __slots__ = ( - 'type', - 'name', - 'description', - 'required', - 'choices', - 'arguments', - 'parent', - '_state', - ) - - def __init__(self, *, parent: ApplicationCommandParent, data: ApplicationCommandOption, state=None): - self.parent = parent - self._state = state - self._from_data(data) - - def __repr__(self) -> str: - return f'<{self.__class__.__name__} name={self.name!r} type={self.type!r} required={self.required}>' - - def _from_data(self, data: ApplicationCommandOption): - self.type: AppCommandOptionType = try_enum(AppCommandOptionType, data['type']) - self.name: str = data['name'] - self.description: str = data['description'] - self.required: bool = data.get('required', False) - self.choices: List[Choice] = [Choice(name=d['name'], value=d['value']) for d in data.get('choices', [])] - self.arguments: List[Argument] = [ - Argument(parent=self, state=self._state, data=d) - for d in data.get('options', []) - if is_app_command_argument_type(d['type']) - ] - - def to_dict(self) -> 'ApplicationCommandOption': - return { - 'name': self.name, - 'type': self.type.value, - 'description': self.description, - 'required': self.required, - 'choices': [choice.to_dict() for choice in self.choices], - 'options': [arg.to_dict() for arg in self.arguments], - } # type: ignore -- Type checker does not understand this literal. - - -def app_command_option_factory( - parent: ApplicationCommandParent, data: ApplicationCommandOption, *, state=None -) -> Union[Argument, AppCommandGroup]: - if is_app_command_argument_type(data['type']): - return Argument(parent=parent, data=data, state=state) - else: - return AppCommandGroup(parent=parent, data=data, state=state) diff --git a/discord/app_commands/namespace.py b/discord/app_commands/namespace.py deleted file mode 100644 index 7f5d797fa..000000000 --- a/discord/app_commands/namespace.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, NamedTuple, Tuple -from ..interactions import Interaction -from ..member import Member -from ..object import Object -from ..role import Role -from ..message import Message, Attachment -from ..channel import PartialMessageable -from ..enums import AppCommandOptionType -from .models import AppCommandChannel, AppCommandThread - -if TYPE_CHECKING: - from ..types.interactions import ResolvedData, ApplicationCommandInteractionDataOption - -__all__ = ('Namespace',) - - -class ResolveKey(NamedTuple): - id: str - # CommandOptionType does not use 0 or negative numbers so those can be safe for library - # internal use, if necessary. Likewise, only 6, 7, 8, and 11 are actually in use. - type: int - - @classmethod - def any_with(cls, id: str) -> ResolveKey: - return ResolveKey(id=id, type=-1) - - def __eq__(self, o: object) -> bool: - if not isinstance(o, ResolveKey): - return NotImplemented - if self.type == -1 or o.type == -1: - return self.id == o.id - return (self.id, self.type) == (o.id, o.type) - - def __hash__(self) -> int: - # Most of the time an ID lookup is all that is necessary - # In case of collision then we look up both the ID and the type. - return hash(self.id) - - -class Namespace: - """An object that holds the parameters being passed to a command in a mostly raw state. - - This class is deliberately simple and just holds the option name and resolved value as a simple - key-pair mapping. These attributes can be accessed using dot notation. For example, an option - with the name of ``example`` can be accessed using ``ns.example``. - - .. versionadded:: 2.0 - - .. container:: operations - - .. describe:: x == y - - Checks if two namespaces are equal by checking if all attributes are equal. - .. describe:: x != y - - Checks if two namespaces are not equal. - - This namespace object converts resolved objects into their appropriate form depending on their - type. Consult the table below for conversion information. - - +-------------------------------------------+-------------------------------------------------------------------------------+ - | Option Type | Resolved Type | - +===========================================+===============================================================================+ - | :attr:`.AppCommandOptionType.string` | :class:`str` | - +-------------------------------------------+-------------------------------------------------------------------------------+ - | :attr:`.AppCommandOptionType.integer` | :class:`int` | - +-------------------------------------------+-------------------------------------------------------------------------------+ - | :attr:`.AppCommandOptionType.boolean` | :class:`bool` | - +-------------------------------------------+-------------------------------------------------------------------------------+ - | :attr:`.AppCommandOptionType.number` | :class:`float` | - +-------------------------------------------+-------------------------------------------------------------------------------+ - | :attr:`.AppCommandOptionType.user` | :class:`~discord.User` or :class:`~discord.Member` | - +-------------------------------------------+-------------------------------------------------------------------------------+ - | :attr:`.AppCommandOptionType.channel` | :class:`.AppCommandChannel` or :class:`.AppCommandThread` | - +-------------------------------------------+-------------------------------------------------------------------------------+ - | :attr:`.AppCommandOptionType.role` | :class:`~discord.Role` | - +-------------------------------------------+-------------------------------------------------------------------------------+ - | :attr:`.AppCommandOptionType.mentionable` | :class:`~discord.User` or :class:`~discord.Member`, or :class:`~discord.Role` | - +-------------------------------------------+-------------------------------------------------------------------------------+ - | :attr:`.AppCommandOptionType.attachment` | :class:`~discord.Attachment` | - +-------------------------------------------+-------------------------------------------------------------------------------+ - """ - - def __init__( - self, - interaction: Interaction, - resolved: ResolvedData, - options: List[ApplicationCommandInteractionDataOption], - ): - completed = self._get_resolved_items(interaction, resolved) - for option in options: - opt_type = option['type'] - name = option['name'] - if opt_type in (3, 4, 5): # string, integer, boolean - value = option['value'] # type: ignore -- Key is there - self.__dict__[name] = value - elif opt_type == 10: # number - value = option['value'] # type: ignore -- Key is there - if value is None: - self.__dict__[name] = float('nan') - else: - self.__dict__[name] = float(value) - elif opt_type in (6, 7, 8, 9, 11): - # Remaining ones should be snowflake based ones with resolved data - snowflake: str = option['value'] # type: ignore -- Key is there - if opt_type == 9: # Mentionable - # Mentionable is User | Role, these do not cause any conflict - key = ResolveKey.any_with(snowflake) - else: - # The remaining keys can conflict, for example, a role and a channel - # could end up with the same ID in very old guilds since they used to default - # to sharing the guild ID. Old general channels no longer exist, but some old - # servers will still have them so this needs to be handled. - key = ResolveKey(id=snowflake, type=opt_type) - - value = completed.get(key) - self.__dict__[name] = value - - @classmethod - def _get_resolved_items(cls, interaction: Interaction, resolved: ResolvedData) -> Dict[ResolveKey, Any]: - completed: Dict[ResolveKey, Any] = {} - state = interaction._state - members = resolved.get('members', {}) - guild_id = interaction.guild_id - guild = (state._get_guild(guild_id) or Object(id=guild_id)) if guild_id is not None else None - type = AppCommandOptionType.user.value - for (user_id, user_data) in resolved.get('users', {}).items(): - try: - member_data = members[user_id] - except KeyError: - completed[ResolveKey(id=user_id, type=type)] = state.create_user(user_data) - else: - member_data['user'] = user_data - # Guild ID can't be None in this case. - # There's a type mismatch here that I don't actually care about - member = Member(state=state, guild=guild, data=member_data) # type: ignore - completed[ResolveKey(id=user_id, type=type)] = member - - type = AppCommandOptionType.role.value - completed.update( - { - # The guild ID can't be None in this case. - ResolveKey(id=role_id, type=type): Role(guild=guild, state=state, data=role_data) # type: ignore - for role_id, role_data in resolved.get('roles', {}).items() - } - ) - - type = AppCommandOptionType.channel.value - for (channel_id, channel_data) in resolved.get('channels', {}).items(): - key = ResolveKey(id=channel_id, type=type) - if channel_data['type'] in (10, 11, 12): - # The guild ID can't be none in this case - completed[key] = AppCommandThread(state=state, data=channel_data, guild_id=guild_id) # type: ignore - else: - # The guild ID can't be none in this case - completed[key] = AppCommandChannel(state=state, data=channel_data, guild_id=guild_id) # type: ignore - - type = AppCommandOptionType.attachment.value - completed.update( - { - ResolveKey(id=attachment_id, type=type): Attachment(data=attachment_data, state=state) - for attachment_id, attachment_data in resolved.get('attachments', {}).items() - } - ) - - guild = state._get_guild(guild_id) - for (message_id, message_data) in resolved.get('messages', {}).items(): - channel_id = int(message_data['channel_id']) - if guild is None: - channel = PartialMessageable(state=state, id=channel_id) - else: - channel = guild.get_channel_or_thread(channel_id) or PartialMessageable(state=state, id=channel_id) - - # Type checker doesn't understand this due to failure to narrow - message = Message(state=state, channel=channel, data=message_data) # type: ignore - key = ResolveKey(id=message_id, type=-1) - completed[key] = message - - return completed - - def __repr__(self) -> str: - items = (f'{k}={v!r}' for k, v in self.__dict__.items()) - return '<{} {}>'.format(self.__class__.__name__, ' '.join(items)) - - def __eq__(self, other: object) -> bool: - if isinstance(self, Namespace) and isinstance(other, Namespace): - return self.__dict__ == other.__dict__ - return NotImplemented - - def _update_with_defaults(self, defaults: Iterable[Tuple[str, Any]]) -> None: - for key, value in defaults: - self.__dict__.setdefault(key, value) diff --git a/discord/app_commands/transformers.py b/discord/app_commands/transformers.py deleted file mode 100644 index 330932e5c..000000000 --- a/discord/app_commands/transformers.py +++ /dev/null @@ -1,664 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations -import inspect - -from dataclasses import dataclass -from enum import Enum -from typing import ( - TYPE_CHECKING, - Any, - Callable, - ClassVar, - Coroutine, - Dict, - List, - Literal, - Optional, - Set, - Tuple, - Type, - TypeVar, - Union, -) - -from .errors import TransformerError -from .models import AppCommandChannel, AppCommandThread, Choice -from ..channel import StageChannel, StoreChannel, VoiceChannel, TextChannel, CategoryChannel -from ..enums import AppCommandOptionType, ChannelType -from ..utils import MISSING -from ..user import User -from ..role import Role -from ..member import Member -from ..message import Attachment - -__all__ = ( - 'Transformer', - 'Transform', - 'Range', -) - -T = TypeVar('T') -NoneType = type(None) - -if TYPE_CHECKING: - from ..interactions import Interaction - - -@dataclass -class CommandParameter: - """Represents a application command parameter. - - Attributes - ----------- - name: :class:`str` - The name of the parameter. - description: :class:`str` - The description of the parameter - required: :class:`bool` - Whether the parameter is required - choices: List[:class:`~discord.app_commands.Choice`] - A list of choices this parameter takes - type: :class:`~discord.AppCommandOptionType` - The underlying type of this parameter. - channel_types: List[:class:`~discord.ChannelType`] - The channel types that are allowed for this parameter. - min_value: Optional[Union[:class:`int`, :class:`float`]] - The minimum supported value for this parameter. - max_value: Optional[Union[:class:`int`, :class:`float`]] - The maximum supported value for this parameter. - """ - - name: str = MISSING - description: str = MISSING - required: bool = MISSING - default: Any = MISSING - choices: List[Choice] = MISSING - type: AppCommandOptionType = MISSING - channel_types: List[ChannelType] = MISSING - min_value: Optional[Union[int, float]] = None - max_value: Optional[Union[int, float]] = None - autocomplete: Optional[Callable[..., Coroutine[Any, Any, Any]]] = None - _annotation: Any = MISSING - - def to_dict(self) -> Dict[str, Any]: - base = { - 'type': self.type.value, - 'name': self.name, - 'description': self.description, - 'required': self.required, - } - - if self.choices: - base['choices'] = [choice.to_dict() for choice in self.choices] - if self.channel_types: - base['channel_types'] = [t.value for t in self.channel_types] - if self.autocomplete: - base['autocomplete'] = True - if self.min_value is not None: - base['min_value'] = self.min_value - if self.max_value is not None: - base['max_value'] = self.max_value - - return base - - async def transform(self, interaction: Interaction, value: Any) -> Any: - if hasattr(self._annotation, '__discord_app_commands_transformer__'): - # This one needs special handling for type safety reasons - if self._annotation.__discord_app_commands_is_choice__: - choice = next((c for c in self.choices if c.value == value), None) - if choice is None: - raise TransformerError(value, self.type, self._annotation) - return choice - - return await self._annotation.transform(interaction, value) - return value - - -class Transformer: - """The base class that allows a type annotation in an application command parameter - to map into a :class:`~discord.AppCommandOptionType` and transform the raw value into one - from this type. - - This class is customisable through the overriding of :func:`classmethod` in the class - and by using it as the second type parameter of the :class:`~discord.app_commands.Transform` - class. For example, to convert a string into a custom pair type: - - .. code-block:: python3 - - class Point(typing.NamedTuple): - x: int - y: int - - class PointTransformer(app_commands.Transformer): - @classmethod - async def transform(cls, interaction: discord.Interaction, value: str) -> Point: - (x, _, y) = value.partition(',') - return Point(x=int(x.strip()), y=int(y.strip())) - - @app_commands.command() - async def graph( - interaction: discord.Interaction, - point: app_commands.Transform[Point, PointTransformer], - ): - await interaction.response.send_message(str(point)) - - .. versionadded:: 2.0 - """ - - __discord_app_commands_transformer__: ClassVar[bool] = True - __discord_app_commands_is_choice__: ClassVar[bool] = False - - @classmethod - def type(cls) -> AppCommandOptionType: - """:class:`~discord.AppCommandOptionType`: The option type associated with this transformer. - - This must be a :obj:`classmethod`. - - Defaults to :attr:`~discord.AppCommandOptionType.string`. - """ - return AppCommandOptionType.string - - @classmethod - def channel_types(cls) -> List[ChannelType]: - """List[:class:`~discord.ChannelType`]: A list of channel types that are allowed to this parameter. - - Only valid if the :meth:`type` returns :attr:`~discord.AppCommandOptionType.channel`. - - Defaults to an empty list. - """ - return [] - - @classmethod - def min_value(cls) -> Optional[Union[int, float]]: - """Optional[:class:`int`]: The minimum supported value for this parameter. - - Only valid if the :meth:`type` returns :attr:`~discord.AppCommandOptionType.number` or - :attr:`~discord.AppCommandOptionType.integer`. - - Defaults to ``None``. - """ - return None - - @classmethod - def max_value(cls) -> Optional[Union[int, float]]: - """Optional[:class:`int`]: The maximum supported value for this parameter. - - Only valid if the :meth:`type` returns :attr:`~discord.AppCommandOptionType.number` or - :attr:`~discord.AppCommandOptionType.integer`. - - Defaults to ``None``. - """ - return None - - @classmethod - async def transform(cls, interaction: Interaction, value: Any) -> Any: - """|coro| - - Transforms the converted option value into another value. - - The value passed into this transform function is the same as the - one in the :class:`conversion table `. - - Parameters - ----------- - interaction: :class:`~discord.Interaction` - The interaction being handled. - value: Any - The value of the given argument after being resolved. - See the :class:`conversion table ` - for how certain option types correspond to certain values. - """ - raise NotImplementedError('Derived classes need to implement this.') - - -class _TransformMetadata: - __discord_app_commands_transform__: ClassVar[bool] = True - __slots__ = ('metadata',) - - def __init__(self, metadata: Type[Transformer]): - self.metadata: Type[Transformer] = metadata - - -async def _identity_transform(cls, interaction: Interaction, value: Any) -> Any: - return value - - -def _make_range_transformer( - opt_type: AppCommandOptionType, - *, - min: Optional[Union[int, float]] = None, - max: Optional[Union[int, float]] = None, -) -> Type[Transformer]: - ns = { - 'type': classmethod(lambda _: opt_type), - 'min_value': classmethod(lambda _: min), - 'max_value': classmethod(lambda _: max), - 'transform': classmethod(_identity_transform), - } - return type('RangeTransformer', (Transformer,), ns) - - -def _make_literal_transformer(values: Tuple[Any, ...]) -> Type[Transformer]: - if len(values) < 2: - raise TypeError(f'typing.Literal requires at least two values.') - - first = type(values[0]) - if first is int: - opt_type = AppCommandOptionType.integer - elif first is float: - opt_type = AppCommandOptionType.number - elif first is str: - opt_type = AppCommandOptionType.string - else: - raise TypeError(f'expected int, str, or float values not {first!r}') - - ns = { - 'type': classmethod(lambda _: opt_type), - 'transform': classmethod(_identity_transform), - '__discord_app_commands_transformer_choices__': [Choice(name=str(v), value=v) for v in values], - } - return type('LiteralTransformer', (Transformer,), ns) - - -def _make_choice_transformer(inner_type: Any) -> Type[Transformer]: - if inner_type is int: - opt_type = AppCommandOptionType.integer - elif inner_type is float: - opt_type = AppCommandOptionType.number - elif inner_type is str: - opt_type = AppCommandOptionType.string - else: - raise TypeError(f'expected int, str, or float values not {inner_type!r}') - - ns = { - 'type': classmethod(lambda _: opt_type), - 'transform': classmethod(_identity_transform), - '__discord_app_commands_is_choice__': True, - } - return type('ChoiceTransformer', (Transformer,), ns) - - -def _make_enum_transformer(enum) -> Type[Transformer]: - values = list(enum) - if len(values) < 2: - raise TypeError(f'enum.Enum requires at least two values.') - - first = type(values[0].value) - if first is int: - opt_type = AppCommandOptionType.integer - elif first is float: - opt_type = AppCommandOptionType.number - elif first is str: - opt_type = AppCommandOptionType.string - else: - raise TypeError(f'expected int, str, or float values not {first!r}') - - async def transform(cls, interaction: Interaction, value: Any) -> Any: - return enum(value) - - ns = { - 'type': classmethod(lambda _: opt_type), - 'transform': classmethod(transform), - '__discord_app_commands_transformer_enum__': enum, - '__discord_app_commands_transformer_choices__': [Choice(name=v.name, value=v.value) for v in values], - } - - return type(f'{enum.__name__}EnumTransformer', (Transformer,), ns) - - -if TYPE_CHECKING: - from typing_extensions import Annotated as Transform - from typing_extensions import Annotated as Range -else: - - class Transform: - """A type annotation that can be applied to a parameter to customise the behaviour of - an option type by transforming with the given :class:`Transformer`. This requires - the usage of two generic parameters, the first one is the type you're converting to and the second - one is the type of the :class:`Transformer` actually doing the transformation. - - During type checking time this is equivalent to :obj:`typing.Annotated` so type checkers understand - the intent of the code. - - For example usage, check :class:`Transformer`. - - .. versionadded:: 2.0 - """ - - def __class_getitem__(cls, items) -> _TransformMetadata: - if not isinstance(items, tuple): - raise TypeError(f'expected tuple for arguments, received {items.__class__!r} instead') - - if len(items) != 2: - raise TypeError(f'Transform only accepts exactly two arguments') - - _, transformer = items - - is_valid = inspect.isclass(transformer) and issubclass(transformer, Transformer) - if not is_valid: - raise TypeError(f'second argument of Transform must be a Transformer class not {transformer!r}') - - return _TransformMetadata(transformer) - - class Range: - """A type annotation that can be applied to a parameter to require a numeric type - to fit within the range provided. - - During type checking time this is equivalent to :obj:`typing.Annotated` so type checkers understand - the intent of the code. - - Some example ranges: - - - ``Range[int, 10]`` means the minimum is 10 with no maximum. - - ``Range[int, None, 10]`` means the maximum is 10 with no minimum. - - ``Range[int, 1, 10]`` means the minimum is 1 and the maximum is 10. - - .. versionadded:: 2.0 - - Examples - ---------- - - .. code-block:: python3 - - @app_commands.command() - async def range(interaction: discord.Interaction, value: app_commands.Range[int, 10, 12]): - await interaction.response.send_message(f'Your value is {value}', ephemeral=True) - """ - - def __class_getitem__(cls, obj) -> _TransformMetadata: - if not isinstance(obj, tuple): - raise TypeError(f'expected tuple for arguments, received {obj.__class__!r} instead') - - if len(obj) == 2: - obj = (*obj, None) - elif len(obj) != 3: - raise TypeError('Range accepts either two or three arguments with the first being the type of range.') - - obj_type, min, max = obj - - if min is None and max is None: - raise TypeError('Range must not be empty') - - if min is not None and max is not None: - # At this point max and min are both not none - if type(min) != type(max): - raise TypeError('Both min and max in Range must be the same type') - - if obj_type is int: - opt_type = AppCommandOptionType.integer - elif obj_type is float: - opt_type = AppCommandOptionType.number - else: - raise TypeError(f'expected int or float as range type, received {obj_type!r} instead') - - transformer = _make_range_transformer( - opt_type, - min=obj_type(min) if min is not None else None, - max=obj_type(max) if max is not None else None, - ) - return _TransformMetadata(transformer) - - -def passthrough_transformer(opt_type: AppCommandOptionType) -> Type[Transformer]: - class _Generated(Transformer): - @classmethod - def type(cls) -> AppCommandOptionType: - return opt_type - - @classmethod - async def transform(cls, interaction: Interaction, value: Any) -> Any: - return value - - return _Generated - - -class MemberTransformer(Transformer): - @classmethod - def type(cls) -> AppCommandOptionType: - return AppCommandOptionType.user - - @classmethod - async def transform(cls, interaction: Interaction, value: Any) -> Member: - if not isinstance(value, Member): - raise TransformerError(value, cls.type(), cls) - return value - - -def channel_transformer(*channel_types: Type[Any], raw: Optional[bool] = False) -> Type[Transformer]: - if raw: - - async def transform(cls, interaction: Interaction, value: Any): - if not isinstance(value, channel_types): - raise TransformerError(value, AppCommandOptionType.channel, cls) - return value - - elif raw is False: - - async def transform(cls, interaction: Interaction, value: Any): - resolved = value.resolve() - if resolved is None or not isinstance(resolved, channel_types): - raise TransformerError(value, AppCommandOptionType.channel, cls) - return resolved - - else: - - async def transform(cls, interaction: Interaction, value: Any): - if isinstance(value, channel_types): - return value - - resolved = value.resolve() - if resolved is None or not isinstance(resolved, channel_types): - raise TransformerError(value, AppCommandOptionType.channel, cls) - return resolved - - if len(channel_types) == 1: - name = channel_types[0].__name__ - types = CHANNEL_TO_TYPES[channel_types[0]] - else: - name = 'MultiChannel' - types = [] - - for t in channel_types: - try: - types.extend(CHANNEL_TO_TYPES[t]) - except KeyError: - raise TypeError(f'Union type of channels must be entirely made up of channels') from None - - return type( - f'{name}Transformer', - (Transformer,), - { - 'type': classmethod(lambda cls: AppCommandOptionType.channel), - 'transform': classmethod(transform), - 'channel_types': classmethod(lambda cls: types), - }, - ) - - -CHANNEL_TO_TYPES: Dict[Any, List[ChannelType]] = { - AppCommandChannel: [ - ChannelType.stage_voice, - ChannelType.store, - ChannelType.voice, - ChannelType.text, - ChannelType.category, - ], - AppCommandThread: [ChannelType.news_thread, ChannelType.private_thread, ChannelType.public_thread], - StageChannel: [ChannelType.stage_voice], - StoreChannel: [ChannelType.store], - VoiceChannel: [ChannelType.voice], - TextChannel: [ChannelType.text], - CategoryChannel: [ChannelType.category], -} - -BUILT_IN_TRANSFORMERS: Dict[Any, Type[Transformer]] = { - str: passthrough_transformer(AppCommandOptionType.string), - int: passthrough_transformer(AppCommandOptionType.integer), - float: passthrough_transformer(AppCommandOptionType.number), - bool: passthrough_transformer(AppCommandOptionType.boolean), - User: passthrough_transformer(AppCommandOptionType.user), - Member: MemberTransformer, - Role: passthrough_transformer(AppCommandOptionType.role), - AppCommandChannel: channel_transformer(AppCommandChannel, raw=True), - AppCommandThread: channel_transformer(AppCommandThread, raw=True), - StageChannel: channel_transformer(StageChannel), - StoreChannel: channel_transformer(StoreChannel), - VoiceChannel: channel_transformer(VoiceChannel), - TextChannel: channel_transformer(TextChannel), - CategoryChannel: channel_transformer(CategoryChannel), - Attachment: passthrough_transformer(AppCommandOptionType.attachment), -} - -ALLOWED_DEFAULTS: Dict[AppCommandOptionType, Tuple[Type[Any], ...]] = { - AppCommandOptionType.string: (str, NoneType), - AppCommandOptionType.integer: (int, NoneType), - AppCommandOptionType.boolean: (bool, NoneType), -} - - -def get_supported_annotation( - annotation: Any, - *, - _none=NoneType, - _mapping: Dict[Any, Type[Transformer]] = BUILT_IN_TRANSFORMERS, -) -> Tuple[Any, Any]: - """Returns an appropriate, yet supported, annotation along with an optional default value. - - This differs from the built in mapping by supporting a few more things. - Likewise, this returns a "transformed" annotation that is ready to use with CommandParameter.transform. - """ - - try: - return (_mapping[annotation], MISSING) - except KeyError: - pass - - if hasattr(annotation, '__discord_app_commands_transform__'): - return (annotation.metadata, MISSING) - - if inspect.isclass(annotation): - if issubclass(annotation, Transformer): - return (annotation, MISSING) - if issubclass(annotation, Enum): - return (_make_enum_transformer(annotation), MISSING) - if annotation is Choice: - raise TypeError(f'Choice requires a type argument of int, str, or float') - - # Check if there's an origin - origin = getattr(annotation, '__origin__', None) - if origin is Literal: - args = annotation.__args__ # type: ignore - return (_make_literal_transformer(args), MISSING) - - if origin is Choice: - arg = annotation.__args__[0] # type: ignore - return (_make_choice_transformer(arg), MISSING) - - if origin is not Union: - # Only Union/Optional is supported right now so bail early - raise TypeError(f'unsupported type annotation {annotation!r}') - - default = MISSING - args = annotation.__args__ # type: ignore - if args[-1] is _none: - if len(args) == 2: - underlying = args[0] - inner, _ = get_supported_annotation(underlying) - if inner is None: - raise TypeError(f'unsupported inner optional type {underlying!r}') - return (inner, None) - else: - args = args[:-1] - default = None - - # Check for channel union types - if any(arg in CHANNEL_TO_TYPES for arg in args): - # If any channel type is given, then *all* must be channel types - return (channel_transformer(*args, raw=None), default) - - # The only valid transformations here are: - # [Member, User] => user - # [Member, User, Role] => mentionable - # [Member | User, Role] => mentionable - supported_types: Set[Any] = {Role, Member, User} - if not all(arg in supported_types for arg in args): - raise TypeError(f'unsupported types given inside {annotation!r}') - if args == (User, Member) or args == (Member, User): - return (passthrough_transformer(AppCommandOptionType.user), default) - - return (passthrough_transformer(AppCommandOptionType.mentionable), default) - - -def annotation_to_parameter(annotation: Any, parameter: inspect.Parameter) -> CommandParameter: - """Returns the appropriate :class:`CommandParameter` for the given annotation. - - The resulting ``_annotation`` attribute might not match the one given here and might - be transformed in order to be easier to call from the ``transform`` asynchronous function - of a command parameter. - """ - - (inner, default) = get_supported_annotation(annotation) - type = inner.type() - if default is MISSING: - default = parameter.default - if default is parameter.empty: - default = MISSING - - # Verify validity of the default parameter - if default is not MISSING: - enum_type = getattr(inner, '__discord_app_commands_transformer_enum__', None) - if default.__class__ is not enum_type: - valid_types: Tuple[Any, ...] = ALLOWED_DEFAULTS.get(type, (NoneType,)) - if not isinstance(default, valid_types): - raise TypeError(f'invalid default parameter type given ({default.__class__}), expected {valid_types}') - - result = CommandParameter( - type=type, - _annotation=inner, - default=default, - required=default is MISSING, - name=parameter.name, - ) - - try: - choices = inner.__discord_app_commands_transformer_choices__ - except AttributeError: - pass - else: - result.choices = choices - - # These methods should be duck typed - if type in (AppCommandOptionType.number, AppCommandOptionType.integer): - result.min_value = inner.min_value() - result.max_value = inner.max_value() - - if type is AppCommandOptionType.channel: - result.channel_types = inner.channel_types() - - if parameter.kind in (parameter.POSITIONAL_ONLY, parameter.VAR_KEYWORD, parameter.VAR_POSITIONAL): - raise TypeError(f'unsupported parameter kind in callback: {parameter.kind!s}') - - return result diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py deleted file mode 100644 index b4582b6bf..000000000 --- a/discord/app_commands/tree.py +++ /dev/null @@ -1,713 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from __future__ import annotations -import inspect -import sys -import traceback -from typing import Callable, Dict, Generic, List, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union, overload - - -from .namespace import Namespace, ResolveKey -from .models import AppCommand -from .commands import Command, ContextMenu, Group, _shorten -from .errors import ( - AppCommandError, - CommandAlreadyRegistered, - CommandNotFound, - CommandSignatureMismatch, -) -from ..errors import ClientException -from ..enums import AppCommandType, InteractionType -from ..utils import MISSING - -if TYPE_CHECKING: - from ..types.interactions import ApplicationCommandInteractionData, ApplicationCommandInteractionDataOption - from ..interactions import Interaction - from ..client import Client - from ..abc import Snowflake - from .commands import ContextMenuCallback, CommandCallback, P, T - -__all__ = ('CommandTree',) - -ClientT = TypeVar('ClientT', bound='Client') - - -class CommandTree(Generic[ClientT]): - """Represents a container that holds application command information. - - Parameters - ----------- - client: :class:`~discord.Client` - The client instance to get application command information from. - """ - - def __init__(self, client: ClientT): - self.client: ClientT = client - self._http = client.http - self._state = client._connection - self._state._command_tree = self - self._guild_commands: Dict[int, Dict[str, Union[Command, Group]]] = {} - self._global_commands: Dict[str, Union[Command, Group]] = {} - # (name, guild_id, command_type): Command - # The above two mappings can use this structure too but we need fast retrieval - # by name and guild_id in the above case while here it isn't as important since - # it's uncommon and N=5 anyway. - self._context_menus: Dict[Tuple[str, Optional[int], int], ContextMenu] = {} - - async def fetch_commands(self, *, guild: Optional[Snowflake] = None) -> List[AppCommand]: - """|coro| - - Fetches the application's current commands. - - If no guild is passed then global commands are fetched, otherwise - the guild's commands are fetched instead. - - .. note:: - - This includes context menu commands. - - Parameters - ----------- - guild: Optional[:class:`~discord.abc.Snowflake`] - The guild to fetch the commands from. If not passed then global commands - are fetched instead. - - Raises - ------- - HTTPException - Fetching the commands failed. - ClientException - The application ID could not be found. - - Returns - -------- - List[:class:`~discord.app_commands.AppCommand`] - The application's commands. - """ - if self.client.application_id is None: - raise ClientException('Client does not have an application ID set') - - if guild is None: - commands = await self._http.get_global_commands(self.client.application_id) - else: - commands = await self._http.get_guild_commands(self.client.application_id, guild.id) - - return [AppCommand(data=data, state=self._state) for data in commands] - - def add_command( - self, - command: Union[Command, ContextMenu, Group], - /, - *, - guild: Optional[Snowflake] = None, - override: bool = False, - ): - """Adds an application command to the tree. - - This only adds the command locally -- in order to sync the commands - and enable them in the client, :meth:`sync` must be called. - - The root parent of the command is added regardless of the type passed. - - Parameters - ----------- - command: Union[:class:`Command`, :class:`Group`] - The application command or group to add. - guild: Optional[:class:`~discord.abc.Snowflake`] - The guild to add the command to. If not given then it - becomes a global command instead. - override: :class:`bool` - Whether to override a command with the same name. If ``False`` - an exception is raised. Default is ``False``. - - Raises - -------- - ~discord.app_commands.CommandAlreadyRegistered - The command was already registered and no override was specified. - TypeError - The application command passed is not a valid application command. - ValueError - The maximum number of commands was reached globally or for that guild. - This is currently 100 for slash commands and 5 for context menu commands. - """ - - if isinstance(command, ContextMenu): - guild_id = None if guild is None else guild.id - type = command.type.value - key = (command.name, guild_id, type) - found = key in self._context_menus - if found and not override: - raise CommandAlreadyRegistered(command.name, guild_id) - - total = sum(1 for _, g, t in self._context_menus if g == guild_id and t == type) - if total + found > 5: - raise ValueError('maximum number of context menu commands exceeded (5)') - self._context_menus[key] = command - return - elif not isinstance(command, (Command, Group)): - raise TypeError(f'Expected a application command, received {command.__class__!r} instead') - - # todo: validate application command groups having children (required) - - root = command.root_parent or command - name = root.name - if guild is not None: - commands = self._guild_commands.setdefault(guild.id, {}) - found = name in commands - if found and not override: - raise CommandAlreadyRegistered(name, guild.id) - if len(commands) + found > 100: - raise ValueError('maximum number of slash commands exceeded (100)') - commands[name] = root - else: - found = name in self._global_commands - if found and not override: - raise CommandAlreadyRegistered(name, None) - if len(self._global_commands) + found > 100: - raise ValueError('maximum number of slash commands exceeded (100)') - self._global_commands[name] = root - - @overload - def remove_command( - self, - command: str, - /, - *, - guild: Optional[Snowflake] = ..., - type: Literal[AppCommandType.message, AppCommandType.user] = ..., - ) -> Optional[ContextMenu]: - ... - - @overload - def remove_command( - self, - command: str, - /, - *, - guild: Optional[Snowflake] = ..., - type: Literal[AppCommandType.chat_input] = ..., - ) -> Optional[Union[Command, Group]]: - ... - - @overload - def remove_command( - self, - command: str, - /, - *, - guild: Optional[Snowflake] = ..., - type: AppCommandType = ..., - ) -> Optional[Union[Command, ContextMenu, Group]]: - ... - - def remove_command( - self, - command: str, - /, - *, - guild: Optional[Snowflake] = None, - type: AppCommandType = AppCommandType.chat_input, - ) -> Optional[Union[Command, ContextMenu, Group]]: - """Removes an application command from the tree. - - This only removes the command locally -- in order to sync the commands - and remove them in the client, :meth:`sync` must be called. - - Parameters - ----------- - command: :class:`str` - The name of the root command to remove. - guild: Optional[:class:`~discord.abc.Snowflake`] - The guild to remove the command from. If not given then it - removes a global command instead. - type: :class:`~discord.AppCommandType` - The type of command to remove. Defaults to :attr:`~discord.AppCommandType.chat_input`, - i.e. slash commands. - - Returns - --------- - Optional[Union[:class:`Command`, :class:`ContextMenu`, :class:`Group`]] - The application command that got removed. - If nothing was removed then ``None`` is returned instead. - """ - - if type is AppCommandType.chat_input: - if guild is None: - return self._global_commands.pop(command, None) - else: - try: - commands = self._guild_commands[guild.id] - except KeyError: - return None - else: - return commands.pop(command, None) - elif type in (AppCommandType.user, AppCommandType.message): - guild_id = None if guild is None else guild.id - key = (command, guild_id, type.value) - return self._context_menus.pop(key, None) - - @overload - def get_command( - self, - command: str, - /, - *, - guild: Optional[Snowflake] = ..., - type: Literal[AppCommandType.message, AppCommandType.user] = ..., - ) -> Optional[ContextMenu]: - ... - - @overload - def get_command( - self, - command: str, - /, - *, - guild: Optional[Snowflake] = ..., - type: Literal[AppCommandType.chat_input] = ..., - ) -> Optional[Union[Command, Group]]: - ... - - @overload - def get_command( - self, - command: str, - /, - *, - guild: Optional[Snowflake] = ..., - type: AppCommandType = ..., - ) -> Optional[Union[Command, ContextMenu, Group]]: - ... - - def get_command( - self, - command: str, - /, - *, - guild: Optional[Snowflake] = None, - type: AppCommandType = AppCommandType.chat_input, - ) -> Optional[Union[Command, ContextMenu, Group]]: - """Gets a application command from the tree. - - Parameters - ----------- - command: :class:`str` - The name of the root command to get. - guild: Optional[:class:`~discord.abc.Snowflake`] - The guild to get the command from. If not given then it - gets a global command instead. - type: :class:`~discord.AppCommandType` - The type of command to get. Defaults to :attr:`~discord.AppCommandType.chat_input`, - i.e. slash commands. - - Returns - --------- - Optional[Union[:class:`Command`, :class:`ContextMenu`, :class:`Group`]] - The application command that was found. - If nothing was found then ``None`` is returned instead. - """ - - if type is AppCommandType.chat_input: - if guild is None: - return self._global_commands.get(command) - else: - try: - commands = self._guild_commands[guild.id] - except KeyError: - return None - else: - return commands.get(command) - elif type in (AppCommandType.user, AppCommandType.message): - guild_id = None if guild is None else guild.id - key = (command, guild_id, type.value) - return self._context_menus.get(key) - - @overload - def get_commands( - self, - *, - guild: Optional[Snowflake] = ..., - type: Literal[AppCommandType.message, AppCommandType.user] = ..., - ) -> List[ContextMenu]: - ... - - @overload - def get_commands( - self, - *, - guild: Optional[Snowflake] = ..., - type: Literal[AppCommandType.chat_input] = ..., - ) -> List[Union[Command, Group]]: - ... - - @overload - def get_commands( - self, - *, - guild: Optional[Snowflake] = ..., - type: AppCommandType = ..., - ) -> Union[List[Union[Command, Group]], List[ContextMenu]]: - ... - - def get_commands( - self, - *, - guild: Optional[Snowflake] = None, - type: AppCommandType = AppCommandType.chat_input, - ) -> Union[List[Union[Command, Group]], List[ContextMenu]]: - """Gets all application commands from the tree. - - Parameters - ----------- - guild: Optional[:class:`~discord.abc.Snowflake`] - The guild to get the commands from. If not given then it - gets all global commands instead. - type: :class:`~discord.AppCommandType` - The type of commands to get. Defaults to :attr:`~discord.AppCommandType.chat_input`, - i.e. slash commands. - - Returns - --------- - Union[List[:class:`ContextMenu`], List[Union[:class:`Command`, :class:`Group`]] - The application commands from the tree. - """ - - if type is AppCommandType.chat_input: - if guild is None: - return list(self._global_commands.values()) - else: - try: - commands = self._guild_commands[guild.id] - except KeyError: - return [] - else: - return list(commands.values()) - else: - guild_id = None if guild is None else guild.id - value = type.value - return [command for ((_, g, t), command) in self._context_menus.items() if g == guild_id and t == value] - - def _get_all_commands(self, *, guild: Optional[Snowflake] = None) -> List[Union[Command, Group, ContextMenu]]: - if guild is None: - base: List[Union[Command, Group, ContextMenu]] = list(self._global_commands.values()) - base.extend(cmd for ((_, g, _), cmd) in self._context_menus.items() if g is None) - return base - else: - try: - commands = self._guild_commands[guild.id] - except KeyError: - return [cmd for ((_, g, _), cmd) in self._context_menus.items() if g is None] - else: - base: List[Union[Command, Group, ContextMenu]] = list(commands.values()) - guild_id = guild.id - base.extend(cmd for ((_, g, _), cmd) in self._context_menus.items() if g == guild_id) - return base - - async def on_error( - self, - interaction: Interaction, - command: Optional[Union[ContextMenu, Command]], - error: AppCommandError, - ) -> None: - """|coro| - - A callback that is called when any command raises an :exc:`AppCommandError`. - - The default implementation prints the traceback to stderr. - - Parameters - ----------- - interaction: :class:`~discord.Interaction` - The interaction that is being handled. - command: Optional[Union[:class:`~discord.app_commands.Command`, :class:`~discord.app_commands.ContextMenu`]] - The command that failed, if any. - error: :exc:`AppCommandError` - The exception that was raised. - """ - - if command is not None: - print(f'Ignoring exception in command {command.name!r}:', file=sys.stderr) - else: - print(f'Ignoring exception in command tree:', file=sys.stderr) - - traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr) - - def command( - self, - *, - name: str = MISSING, - description: str = MISSING, - guild: Optional[Snowflake] = None, - ) -> Callable[[CommandCallback[Group, P, T]], Command[Group, P, T]]: - """Creates an application command directly under this tree. - - Parameters - ------------ - name: :class:`str` - The name of the application command. If not given, it defaults to a lower-case - version of the callback name. - description: :class:`str` - The description of the application command. This shows up in the UI to describe - the application command. If not given, it defaults to the first line of the docstring - of the callback shortened to 100 characters. - guild: Optional[:class:`~discord.abc.Snowflake`] - The guild to add the command to. If not given then it - becomes a global command instead. - """ - - def decorator(func: CommandCallback[Group, P, T]) -> Command[Group, P, T]: - if not inspect.iscoroutinefunction(func): - raise TypeError('command function must be a coroutine function') - - if description is MISSING: - if func.__doc__ is None: - desc = '...' - else: - desc = _shorten(func.__doc__) - else: - desc = description - - command = Command( - name=name if name is not MISSING else func.__name__, - description=desc, - callback=func, - parent=None, - ) - self.add_command(command, guild=guild) - return command - - return decorator - - def context_menu( - self, *, name: str = MISSING, guild: Optional[Snowflake] = None - ) -> Callable[[ContextMenuCallback], ContextMenu]: - """Creates a application command context menu from a regular function directly under this tree. - - This function must have a signature of :class:`~discord.Interaction` as its first parameter - and taking either a :class:`~discord.Member`, :class:`~discord.User`, or :class:`~discord.Message`, - or a :obj:`typing.Union` of ``Member`` and ``User`` as its second parameter. - - Examples - --------- - - .. code-block:: python3 - - @app_commands.context_menu() - async def react(interaction: discord.Interaction, message: discord.Message): - await interaction.response.send_message('Very cool message!', ephemeral=True) - - @app_commands.context_menu() - async def ban(interaction: discord.Interaction, user: discord.Member): - await interaction.response.send_message(f'Should I actually ban {user}...', ephemeral=True) - - Parameters - ------------ - name: :class:`str` - The name of the context menu command. If not given, it defaults to a title-case - version of the callback name. Note that unlike regular slash commands this can - have spaces and upper case characters in the name. - guild: Optional[:class:`~discord.abc.Snowflake`] - The guild to add the command to. If not given then it - becomes a global command instead. - """ - - def decorator(func: ContextMenuCallback) -> ContextMenu: - if not inspect.iscoroutinefunction(func): - raise TypeError('context menu function must be a coroutine function') - - context_menu = ContextMenu._from_decorator(func, name=name) - self.add_command(context_menu, guild=guild) - return context_menu - - return decorator - - async def sync(self, *, guild: Optional[Snowflake] = None) -> List[AppCommand]: - """|coro| - - Syncs the application commands to Discord. - - This must be called for the application commands to show up. - - Global commands take up to 1-hour to propagate but guild - commands propagate instantly. - - Parameters - ----------- - guild: Optional[:class:`~discord.abc.Snowflake`] - The guild to sync the commands to. If ``None`` then it - syncs all global commands instead. - - Raises - ------- - HTTPException - Syncing the commands failed. - ClientException - The client does not have an application ID. - - Returns - -------- - List[:class:`AppCommand`] - The application's commands that got synced. - """ - - if self.client.application_id is None: - raise ClientException('Client does not have an application ID set') - - commands = self._get_all_commands(guild=guild) - payload = [command.to_dict() for command in commands] - if guild is None: - data = await self._http.bulk_upsert_global_commands(self.client.application_id, payload=payload) - else: - data = await self._http.bulk_upsert_guild_commands(self.client.application_id, guild.id, payload=payload) - - return [AppCommand(data=d, state=self._state) for d in data] - - def _from_interaction(self, interaction: Interaction): - async def wrapper(): - try: - await self.call(interaction) - except AppCommandError as e: - await self.on_error(interaction, None, e) - - self.client.loop.create_task(wrapper(), name='CommandTree-invoker') - - async def _call_context_menu(self, interaction: Interaction, data: ApplicationCommandInteractionData, type: int): - name = data['name'] - guild_id = interaction.guild_id - ctx_menu = self._context_menus.get((name, guild_id, type)) - if ctx_menu is None: - raise CommandNotFound(name, [], AppCommandType(type)) - - resolved = Namespace._get_resolved_items(interaction, data.get('resolved', {})) - - target_id = data.get('target_id') - # Right now, the only types are message and user - # Therefore, there's no conflict with snowflakes - - # This will always work at runtime - key = ResolveKey.any_with(target_id) # type: ignore - value = resolved.get(key) - if ctx_menu.type.value != type: - raise CommandSignatureMismatch(ctx_menu) - - if value is None: - raise AppCommandError('This should not happen if Discord sent well-formed data.') - - # I assume I don't have to type check here. - try: - await ctx_menu._invoke(interaction, value) - except AppCommandError as e: - await self.on_error(interaction, ctx_menu, e) - - async def call(self, interaction: Interaction): - """|coro| - - Given an :class:`~discord.Interaction`, calls the matching - application command that's being invoked. - - This is usually called automatically by the library. - - Parameters - ----------- - interaction: :class:`~discord.Interaction` - The interaction to dispatch from. - - Raises - -------- - CommandNotFound - The application command referred to could not be found. - CommandSignatureMismatch - The interaction data referred to a parameter that was not found in the - application command definition. - AppCommandError - An error occurred while calling the command. - """ - data: ApplicationCommandInteractionData = interaction.data # type: ignore - type = data.get('type', 1) - if type != 1: - # Context menu command... - await self._call_context_menu(interaction, data, type) - return - - parents: List[str] = [] - name = data['name'] - command = self._global_commands.get(name) - if interaction.guild_id: - try: - guild_commands = self._guild_commands[interaction.guild_id] - except KeyError: - pass - else: - command = guild_commands.get(name) or command - - # If it's not found at this point then it's not gonna be found at any point - if command is None: - raise CommandNotFound(name, parents) - - # This could be done recursively but it'd be a bother due to the state needed - # to be tracked above like the parents, the actual command type, and the - # resulting options we care about - searching = True - options: List[ApplicationCommandInteractionDataOption] = data.get('options', []) - while searching: - for option in options: - # Find subcommands - if option.get('type', 0) in (1, 2): - parents.append(name) - name = option['name'] - command = command._get_internal_command(name) - if command is None: - raise CommandNotFound(name, parents) - options = option.get('options', []) - break - else: - searching = False - break - else: - break - - if isinstance(command, Group): - # Right now, groups can't be invoked. This is a Discord limitation in how they - # do slash commands. So if we're here and we have a Group rather than a Command instance - # then something in the code is out of date from the data that Discord has. - raise CommandSignatureMismatch(command) - - # At this point options refers to the arguments of the command - # and command refers to the class type we care about - namespace = Namespace(interaction, data.get('resolved', {}), options) - - # Auto complete handles the namespace differently... so at this point this is where we decide where that is. - if interaction.type is InteractionType.autocomplete: - focused = next((opt['name'] for opt in options if opt.get('focused')), None) - if focused is None: - raise AppCommandError('This should not happen, but there is no focused element. This is a Discord bug.') - await command._invoke_autocomplete(interaction, focused, namespace) - return - - try: - await command._invoke_with_namespace(interaction, namespace) - except AppCommandError as e: - await command._invoke_error_handler(interaction, e) - await self.on_error(interaction, command, e) diff --git a/discord/appinfo.py b/discord/appinfo.py index 6b0266aad..c412dd703 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -28,142 +28,198 @@ from typing import List, TYPE_CHECKING, Optional from . import utils from .asset import Asset +from .enums import ApplicationType, ApplicationVerificationState, RPCApplicationState, StoreApplicationState, try_enum from .flags import ApplicationFlags +from .mixins import Hashable +from .permissions import Permissions +from .user import User if TYPE_CHECKING: + from .abc import Snowflake, User as abcUser from .guild import Guild from .types.appinfo import ( AppInfo as AppInfoPayload, PartialAppInfo as PartialAppInfoPayload, Team as TeamPayload, ) - from .user import User from .state import ConnectionState __all__ = ( - 'AppInfo', - 'PartialAppInfo', + 'Application', + 'PartialApplication', + 'InteractionApplication', ) +MISSING = utils.MISSING -class AppInfo: - """Represents the application info for the bot provided by Discord. +class ApplicationBot(User): + """Represents a bot attached to an application. Attributes - ------------- - id: :class:`int` - The application ID. - name: :class:`str` - The application name. - owner: :class:`User` - The application owner. - team: Optional[:class:`Team`] - The application's team. - - .. versionadded:: 1.3 - - description: :class:`str` - The application description. - bot_public: :class:`bool` + ----------- + application: :class:`Application` + The application that the bot is attached to. + public: :class:`bool` Whether the bot can be invited by anyone or if it is locked to the application owner. - bot_require_code_grant: :class:`bool` - Whether the bot requires the completion of the full oauth2 code + require_code_grant: :class:`bool` + Whether the bot requires the completion of the full OAuth2 code grant flow to join. - rpc_origins: Optional[List[:class:`str`]] - A list of RPC origin URLs, if RPC is enabled. + """ + __slots__ = ('public', 'require_code_grant') - verify_key: :class:`str` - The hex encoded key for verification in interactions and the - GameSDK's `GetTicket `_. + def __init__(self, *, data, state: ConnectionState, application: Application): + super().__init__(state=state, data=data) + self.application = application + self.public: bool = data['public'] + self.require_code_grant: bool = data['require_code_grant'] - .. versionadded:: 1.3 + async def reset_token(self) -> None: + """|coro| - guild_id: Optional[:class:`int`] - If this application is a game sold on Discord, - this field will be the guild to which it has been linked to. + Resets the bot's token. - .. versionadded:: 1.3 + Raises + ------ + HTTPException + Resetting the token failed. - primary_sku_id: Optional[:class:`int`] - If this application is a game sold on Discord, - this field will be the id of the "Game SKU" that is created, - if it exists. + Returns + ------- + :class:`str` + The new token. + """ + data = await self._state.http.reset_token(self.application.id) + return data['token'] + + async def edit( + self, + *, + public: bool = MISSING, + require_code_grant: bool = MISSING, + ) -> None: + """|coro| + + Edits the bot. + + Parameters + ----------- + public: :class:`bool` + Whether the bot is public or not. + require_code_grant: :class:`bool` + Whether the bot requires a code grant or not. + + Raises + ------ + Forbidden + You are not allowed to edit this bot. + HTTPException + Editing the bot failed. + """ + payload = {} + if public is not MISSING: + payload['bot_public'] = public + if require_code_grant is not MISSING: + payload['bot_require_code_grant'] = require_code_grant - .. versionadded:: 1.3 + data = await self._state.http.edit_application(self.application.id, payload=payload) + self.public = data.get('bot_public', True) + self.require_code_grant = data.get('bot_require_code_grant', False) + self.application._update(data) - slug: Optional[:class:`str`] - If this application is a game sold on Discord, - this field will be the URL slug that links to the store page. - .. versionadded:: 1.3 +class PartialApplication(Hashable): + """Represents a partial Application. + .. versionadded:: 2.0 + + Attributes + ------------- + id: :class:`int` + The application ID. + name: :class:`str` + The application name. + description: :class:`str` + The application description. + rpc_origins: Optional[List[:class:`str`]] + A list of RPC origin URLs, if RPC is enabled. + verify_key: :class:`str` + The hex encoded key for verification in interactions and the + GameSDK's `GetTicket `_. terms_of_service_url: Optional[:class:`str`] The application's terms of service URL, if set. - - .. versionadded:: 2.0 - privacy_policy_url: Optional[:class:`str`] The application's privacy policy URL, if set. - - .. versionadded:: 2.0 + public: :class:`bool` + Whether the integration can be invited by anyone or if it is locked + to the application owner. + require_code_grant: :class:`bool` + Whether the integration requires the completion of the full OAuth2 code + grant flow to join + max_participants: Optional[:class:`int`] + The max number of people that can participate in the activity. + Only available for embedded activities. + premium_tier_level: Optional[:class:`int`] + The required premium tier level to launch the activity. + Only available for embedded activities. + type: :class:`ApplicationType` + The type of application. + tags: List[:class:`str`] + A list of tags that describe the application. """ __slots__ = ( '_state', - 'description', 'id', 'name', + 'description', 'rpc_origins', - 'bot_public', - 'bot_require_code_grant', - 'owner', - '_icon', 'verify_key', - 'team', - 'guild_id', - 'primary_sku_id', - 'slug', - '_cover_image', - '_flags', 'terms_of_service_url', 'privacy_policy_url', + '_icon', + '_flags' + '_cover_image', + 'public', + 'require_code_grant', + 'type', + 'hook', + 'premium_tier_level', + 'tags', ) - def __init__(self, state: ConnectionState, data: AppInfoPayload): - from .team import Team - + def __init__(self, *, state: ConnectionState, data: PartialAppInfoPayload): self._state: ConnectionState = state + self._update(data) + + def _update(self, data: PartialAppInfoPayload) -> None: self.id: int = int(data['id']) self.name: str = data['name'] self.description: str = data['description'] - self._icon: Optional[str] = data['icon'] - self.rpc_origins: List[str] = data['rpc_origins'] - self.bot_public: bool = data['bot_public'] - self.bot_require_code_grant: bool = data['bot_require_code_grant'] - self.owner: User = state.create_user(data['owner']) - - team: Optional[TeamPayload] = data.get('team') - self.team: Optional[Team] = Team(state, team) if team else None - + self.rpc_origins: Optional[List[str]] = data.get('rpc_origins') self.verify_key: str = data['verify_key'] - self.guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id') - - self.primary_sku_id: Optional[int] = utils._get_as_snowflake(data, 'primary_sku_id') - self.slug: Optional[str] = data.get('slug') - self._flags: int = data.get('flags', 0) + self._icon: Optional[str] = data.get('icon') self._cover_image: Optional[str] = data.get('cover_image') + self.terms_of_service_url: Optional[str] = data.get('terms_of_service_url') self.privacy_policy_url: Optional[str] = data.get('privacy_policy_url') + self._flags: int = data.get('flags', 0) + self.type: ApplicationType = try_enum(ApplicationType, data.get('type')) + self.hook: bool = data.get('hook', False) + self.max_participants: Optional[int] = data.get('max_participants') + self.premium_tier_level: Optional[int] = data.get('embedded_activity_config', {}).get('activity_premium_tier_level') + self.tags: List[str] = data.get('tags', []) + + install_params = data.get('install_params', {}) + self.install_url = data.get('custom_install_url') if not install_params else utils.oauth_url(self.id, permissions=Permissions(int(install_params.get('permissions', 0))), scopes=install_params.get('scopes', utils.MISSING)) + + self.public: bool = data.get('integration_public', data.get('bot_public', True)) # The two seem to be used interchangeably? + self.require_code_grant: bool = data.get('integration_require_code_grant', data.get('bot_require_code_grant', False)) # Same here def __repr__(self) -> str: - return ( - f'<{self.__class__.__name__} id={self.id} name={self.name!r} ' - f'description={self.description!r} public={self.bot_public} ' - f'owner={self.owner!r}>' - ) + return f'<{self.__class__.__name__} id={self.id} name={self.name!r} description={self.description!r}>' @property def icon(self) -> Optional[Asset]: @@ -182,26 +238,254 @@ class AppInfo: return None return Asset._from_cover_image(self._state, self.id, self._cover_image) + @property + def flags(self) -> ApplicationFlags: + """:class:`ApplicationFlags`: The flags of this application.""" + return ApplicationFlags._from_value(self._flags) + + +class Application(PartialApplication): + """Represents application info for an application you own. + + .. versionadded:: 2.0 + + Attributes + ------------- + owner: :class:`abc.User` + The application owner. + team: Optional[:class:`Team`] + The application's team. + bot: Optional[:class:`ApplicationBot`] + The bot attached to the application, if any. + guild_id: Optional[:class:`int`] + If this application is a game sold on Discord, + this field will be the guild to which it has been linked to. + primary_sku_id: Optional[:class:`int`] + If this application is a game sold on Discord, + this field will be the id of the "Game SKU" that is created, + if it exists. + slug: Optional[:class:`str`] + If this application is a game sold on Discord, + this field will be the URL slug that links to the store page. + interactions_endpoint_url: Optional[:class:`str`] + The URL interactions will be sent to, if set. + redirect_uris: List[:class:`str`] + A list of redirect URIs authorized for this application. + verification_state: :class:`ApplicationVerificationState` + The verification state of the application. + store_application_state: :class:`StoreApplicationState` + The approval state of the commerce application. + rpc_application_state: :class:`RPCApplicationState` + The approval state of the RPC usage application. + """ + + __slots__ = ( + 'owner', + 'team', + 'guild_id', + 'primary_sku_id', + 'slug', + 'redirect_uris', + 'bot', + 'verification_state', + 'store_application_state', + 'rpc_application_state', + 'interactions_endpoint_url', + ) + + def _update(self, data: AppInfoPayload) -> None: + super()._update(data) + from .team import Team + + self.guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id') + self.redirect_uris: List[str] = data.get('redirect_uris', []) + self.primary_sku_id: Optional[int] = utils._get_as_snowflake(data, 'primary_sku_id') + self.slug: Optional[str] = data.get('slug') + self.interactions_endpoint_url: Optional[str] = data['interactions_endpoint_url'] + + self.verification_state = try_enum(ApplicationVerificationState, data['verification_state']) + self.store_application_state = try_enum(StoreApplicationState, data['store_application_state']) + self.rpc_application_state = try_enum(RPCApplicationState, data['rpc_application_state']) + + state = self._state + team: Optional[TeamPayload] = data.get('team') + self.team: Optional[Team] = Team(state, team) if team else None + + if (bot := data.get('bot')): + bot['public'] = data.get('bot_public', self.public) + bot['require_code_grant'] = data.get('bot_require_code_grant', self.require_code_grant) + self.bot: Optional[ApplicationBot] = ApplicationBot(data=bot, state=state, application=self) if bot else None + + owner = data.get('owner') + if owner is not None: + self.owner: abcUser = state.create_user(owner) + else: + self.owner: abcUser = state.user # type: ignore - state.user will always be present here + + def __repr__(self) -> str: + return ( + f'<{self.__class__.__name__} id={self.id} name={self.name!r} ' + f'description={self.description!r} public={self.public} ' + f'owner={self.owner!r}>' + ) + @property def guild(self) -> Optional[Guild]: """Optional[:class:`Guild`]: If this application is a game sold on Discord, - this field will be the guild to which it has been linked - - .. versionadded:: 1.3 + this field will be the guild to which it has been linked. """ return self._state._get_guild(self.guild_id) - @property - def flags(self) -> ApplicationFlags: - """:class:`ApplicationFlags`: The application's flags. + async def edit( + self, + *, + name: str = MISSING, + description: Optional[str] = MISSING, + icon: Optional[bytes] = MISSING, + cover_image: Optional[bytes] = MISSING, + tags: List[str] = MISSING, + terms_of_service_url: Optional[str] = MISSING, + privacy_policy_url: Optional[str] = MISSING, + interactions_endpoint_url: Optional[str] = MISSING, + redirect_uris: List[str] = MISSING, + rpc_origins: List[str] = MISSING, + public: bool = MISSING, + require_code_grant: bool = MISSING, + flags: ApplicationFlags = MISSING, + team: Snowflake = MISSING, + ) -> None: + """|coro| + + Edits the application. + + Parameters + ----------- + name: :class:`str` + The name of the application. + description: :class:`str` + The description of the application. + icon: Optional[:class:`bytes`] + The icon of the application. + cover_image: Optional[:class:`bytes`] + The cover image of the application. + tags: List[:class:`str`] + A list of tags that describe the application. + terms_of_service_url: Optional[:class:`str`] + The URL to the terms of service of the application. + privacy_policy_url: Optional[:class:`str`] + The URL to the privacy policy of the application. + interactions_endpoint_url: Optional[:class:`str`] + The URL interactions will be sent to, if set. + redirect_uris: List[:class:`str`] + A list of redirect URIs authorized for this application. + rpc_origins: List[:class:`str`] + A list of RPC origins authorized for this application. + public: :class:`bool` + Whether the application is public or not. + require_code_grant: :class:`bool` + Whether the application requires a code grant or not. + flags: :class:`ApplicationFlags` + The flags of the application. + team: :class:`Snowflake` + The team to transfer the application to. + + Raises + ------- + Forbidden + You do not have permissions to edit this application. + HTTPException + Editing the application failed. + """ + payload = {} + if name is not MISSING: + payload['name'] = name or '' + if description is not MISSING: + payload['description'] = description or '' + if icon is not MISSING: + if icon is not None: + payload['icon'] = utils._bytes_to_base64_data(icon) + else: + payload['icon'] = '' + if cover_image is not MISSING: + if cover_image is not None: + payload['cover_image'] = utils._bytes_to_base64_data(cover_image) + else: + payload['cover_image'] = '' + if tags is not MISSING: + payload['tags'] = tags + if terms_of_service_url is not MISSING: + payload['terms_of_service_url'] = terms_of_service_url or '' + if privacy_policy_url is not MISSING: + payload['privacy_policy_url'] = privacy_policy_url or '' + if interactions_endpoint_url is not MISSING: + payload['interactions_endpoint_url'] = interactions_endpoint_url or '' + if redirect_uris is not MISSING: + payload['redirect_uris'] = redirect_uris + if rpc_origins is not MISSING: + payload['rpc_origins'] = rpc_origins + if public is not MISSING: + payload['integration_public'] = public + if require_code_grant is not MISSING: + payload['integration_require_code_grant'] = require_code_grant + if flags is not MISSING: + payload['flags'] = flags.value + + data = await self._state.http.edit_application(self.id, payload) + if team is not MISSING: + data = await self._state.http.transfer_application(self.id, team.id) + + self._update(data) + + async def reset_secret(self) -> str: + """|coro| + + Resets the application's secret. + + Raises + ------ + Forbidden + You do not have permissions to reset the secret. + HTTPException + Resetting the secret failed. + + Returns + ------- + :class:`str` + The new secret. + """ + data = await self._state.http.reset_secret(self.id) + return data['secret'] + + async def create_bot(self) -> ApplicationBot: + """|coro| + + Creates a bot attached to this application. + + Raises + ------ + Forbidden + You do not have permissions to create bots. + HTTPException + Creating the bot failed. - .. versionadded:: 2.0 + Returns + ------- + :class:`ApplicationBot` + The newly created bot. """ - return ApplicationFlags._from_value(self._flags) + state = self._state + data = await state.http.botify_app(self.id) + + data['public'] = self.public + data['require_code_grant'] = self.require_code_grant + + bot = ApplicationBot(data=data, state=state, application=self) + self.bot = bot + return bot -class PartialAppInfo: - """Represents a partial AppInfo given by :func:`~discord.abc.GuildChannel.create_invite` +class InteractionApplication(Hashable): + """Represents a very partial Application received in interaction contexts. .. versionadded:: 2.0 @@ -211,17 +495,17 @@ class PartialAppInfo: The application ID. name: :class:`str` The application name. - description: :class:`str` + bot: :class:`User` + The bot attached to the application. + description: Optional[:class:`str`] The application description. - rpc_origins: Optional[List[:class:`str`]] - A list of RPC origin URLs, if RPC is enabled. - verify_key: :class:`str` - The hex encoded key for verification in interactions and the - GameSDK's `GetTicket `_. - terms_of_service_url: Optional[:class:`str`] - The application's terms of service URL, if set. - privacy_policy_url: Optional[:class:`str`] - The application's privacy policy URL, if set. + Only available from :attr:`~Modal.application`. + type: Optional[:class:`ApplicationType`] + The type of application. + Only available from :attr:`~Modal.application`. + command_count: Optional[:class:`int`] + The number of commands the application has. + Only available from :attr:`~ApplicationCommand.application`. """ __slots__ = ( @@ -229,28 +513,29 @@ class PartialAppInfo: 'id', 'name', 'description', - 'rpc_origins', - 'verify_key', - 'terms_of_service_url', - 'privacy_policy_url', '_icon', - '_flags', + 'type', + 'bot', ) - def __init__(self, *, state: ConnectionState, data: PartialAppInfoPayload): + def __init__(self, *, state: ConnectionState, data: dict): self._state: ConnectionState = state + self._update(data) + + def _update(self, data: dict) -> None: self.id: int = int(data['id']) self.name: str = data['name'] + self.description: Optional[str] = data.get('description') self._icon: Optional[str] = data.get('icon') - self._flags: int = data.get('flags', 0) - self.description: str = data['description'] - self.rpc_origins: Optional[List[str]] = data.get('rpc_origins') - self.verify_key: str = data['verify_key'] - self.terms_of_service_url: Optional[str] = data.get('terms_of_service_url') - self.privacy_policy_url: Optional[str] = data.get('privacy_policy_url') + self.type: Optional[ApplicationType] = try_enum(ApplicationType, data['type']) if 'type' in data else None + + self.bot: User = None # type: ignore - This should never be None but it's volatile + user = data.get('bot') + if user is not None: + self.bot = User(state=self._state, data=user) def __repr__(self) -> str: - return f'<{self.__class__.__name__} id={self.id} name={self.name!r} description={self.description!r}>' + return f'<{self.__class__.__name__} id={self.id} name={self.name!r}>' @property def icon(self) -> Optional[Asset]: @@ -258,11 +543,3 @@ class PartialAppInfo: if self._icon is None: return None return Asset._from_icon(self._state, self.id, self._icon, path='app') - - @property - def flags(self) -> ApplicationFlags: - """:class:`ApplicationFlags`: The application's flags. - - .. versionadded:: 2.0 - """ - return ApplicationFlags._from_value(self._flags) diff --git a/discord/asset.py b/discord/asset.py index 34acad6ff..7d7383adb 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -259,6 +259,15 @@ class Asset(AssetMixin): animated=animated, ) + @classmethod + def _from_role_icon(cls, state, role_id: int, icon_hash: str) -> Asset: + return cls( + state, + url=f'{cls.BASE}/role-icons/{role_id}/{icon_hash}.png', + key=icon_hash, + animated=False, + ) + def __str__(self) -> str: return self._url diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 7099113d5..56c56cb2a 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -247,7 +247,7 @@ class AuditLogChanges: for elem in data: attr = elem['key'] - # special cases for role add/remove + # Special cases for role add/remove if attr == '$add': self._handle_role(self.before, self.after, entry, elem['new_value']) # type: ignore - new_value is a list of roles in this case continue @@ -285,7 +285,7 @@ class AuditLogChanges: setattr(self.after, attr, after) - # add an alias + # Add aliases if hasattr(self.after, 'colour'): self.after.color = self.after.colour self.before.color = self.before.colour @@ -398,7 +398,6 @@ class AuditLogEntry(Hashable): self.action = enums.try_enum(enums.AuditLogAction, data['action_type']) self.id = int(data['id']) - # this key is technically not usually present self.reason = data.get('reason') extra = data.get('options') @@ -524,8 +523,8 @@ class AuditLogEntry(Hashable): return self.guild.get_role(target_id) or Object(id=target_id) def _convert_target_invite(self, target_id: int) -> Invite: - # invites have target_id set to null - # so figure out which change has the full invite data + # Invites have target_id set to null + # So figure out which change has the full invite data changeset = self.before if self.action is enums.AuditLogAction.invite_delete else self.after fake_payload: InvitePayload = { diff --git a/discord/calls.py b/discord/calls.py new file mode 100644 index 000000000..d183e9cdd --- /dev/null +++ b/discord/calls.py @@ -0,0 +1,473 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +import datetime +from typing import Callable, Dict, List, Optional, TYPE_CHECKING, Union + +from . import utils +from .errors import ClientException +from .utils import MISSING + +if TYPE_CHECKING: + from .abc import Connectable, PrivateChannel, User as abcUser, T as ConnectReturn + from .channel import DMChannel, GroupChannel + from .client import Client + from .member import VoiceState + from .message import Message + from .state import ConnectionState + from .types.snowflake import Snowflake, SnowflakeList + from .types.voice import GuildVoiceState + from .user import User + + _PrivateChannel = Union[DMChannel, GroupChannel] + +__all__ = ( + 'CallMessage', + 'PrivateCall', + 'GroupCall', +) + + +def _running_only(func: Callable): + def decorator(self: Call, *args, **kwargs): + if self._ended: + raise ClientException('Call is over') + else: + return func(self, *args, **kwargs) + return decorator + + +class CallMessage: + """Represents a group call message from Discord. + + This is only received in cases where the message type is equivalent to + :attr:`MessageType.call`. + + Attributes + ----------- + ended_timestamp: Optional[:class:`datetime.datetime`] + A naive UTC datetime object that represents the time that the call has ended. + participants: List[:class:`User`] + A list of users that participated in the call. + message: :class:`Message` + The message associated with this call message. + """ + + def __init__( + self, message: Message, *, participants: List[User], ended_timestamp: str + ) -> None: + self.message = message + self.ended_timestamp = utils.parse_time(ended_timestamp) + self.participants = participants + + @property + def call_ended(self) -> bool: + """:class:`bool`: Indicates if the call has ended.""" + return self.ended_timestamp is not None + + @property + def initiator(self) -> User: + """:class:`User`: Returns the user that started the call.""" + return self.message.author # type: ignore - Cannot be a Member in private messages + + @property + def channel(self) -> _PrivateChannel: + r""":class:`PrivateChannel`\: The private channel associated with this message.""" + return self.message.channel # type: ignore - Can only be a private channel here + + @property + def duration(self) -> datetime.timedelta: + """Queries the duration of the call. + + If the call has not ended then the current duration will + be returned. + + Returns + --------- + :class:`datetime.timedelta` + The timedelta object representing the duration. + """ + if self.ended_timestamp is None: + return datetime.datetime.utcnow() - self.message.created_at + else: + return self.ended_timestamp - self.message.created_at + + +class PrivateCall: + """Represents the actual group call from Discord. + + This is accompanied with a :class:`CallMessage` denoting the information. + + Attributes + ----------- + channel: :class:`DMChannel` + The channel the call is in. + message: Optional[:class:`Message`] + The message associated with this call (if available). + unavailable: :class:`bool` + Denotes if this call is unavailable. + ringing: List[:class:`~discord.abc.User`] + A list of users that are currently being rung to join the call. + region: :class:`str` + The region the call is being hosted at. + + .. versionchanged:: 2.0 + The type of this attribute has changed to :class:`str`. + """ + + if TYPE_CHECKING: + channel: DMChannel + ringing: List[abcUser] + region: str + + def __init__( + self, + state: ConnectionState, + *, + message_id: Snowflake, + channel_id: Snowflake, + message: Optional[Message] = None, + channel: PrivateChannel, + unavailable: bool, + voice_states: List[GuildVoiceState] = [], + **kwargs, + ) -> None: + self._state = state + self._message_id: int = int(message_id) + self._channel_id: int = int(channel_id) + self.message: Optional[Message] = message + self.channel = channel # type: ignore + self.unavailable: bool = unavailable + self._ended: bool = False + + for vs in voice_states: + state._update_voice_state(vs, int(channel_id)) + + self._update(**kwargs) + + def _deleteup(self) -> None: + self.ringing = [] + self._ended = True + + def _is_participating(self, user: abcUser) -> bool: + state = self.voice_state_for(user) + return bool(state and state.channel and state.channel.id == self._channel_id) + + def _update( + self, *, ringing: SnowflakeList = [], region: str = MISSING + ) -> None: + if region is not MISSING: + self.region = region + channel = self.channel + recipients = {channel.me, channel.recipient} + lookup = {u.id: u for u in recipients} + self.ringing = list(filter(None, map(lookup.get, ringing))) + + @property + def initiator(self) -> Optional[User]: + """Optional[:class:`User`]: Returns the user that started the call. The call message must be available to obtain this information.""" + if self.message: + return self.message.author # type: ignore - Cannot be a Member in private messages + + @property + def connected(self) -> bool: + """:class:`bool`: Returns whether you're in the call (this does not mean you're in the call through the lib).""" + return self._is_participating(self.channel.me) + + @property + def members(self) -> List[User]: + """List[:class:`User`]: Returns all users that are currently in this call.""" + channel = self.channel + recipients = {channel.me, channel.recipient} + return [u for u in recipients if self._is_participating(u)] + + @property + def voice_states(self) -> Dict[int, VoiceState]: + """Mapping[:class:`int`, :class:`VoiceState`]: Returns a mapping of user IDs who have voice states in this call.""" + return {k: v for k, v in self._state._voice_states.items() if bool(v and v.channel and v.channel.id == self._channel_id)} + + async def fetch_message(self) -> Optional[Message]: + """|coro| + + Fetches and caches the message associated with this call. + + Raises + ------- + HTTPException + Retrieving the message failed. + + Returns + ------- + Optional[:class:`Message`] + The message associated with this call. + """ + message = await self.channel.fetch_message(self._message_id) + if message is not None and self.message is None: + self.message = message + return message + + async def change_region(self, region: str) -> None: + """|coro| + + Changes the channel's voice region. + + Parameters + ----------- + region: :class:`str` + A region to change the voice region to. + + .. versionchanged:: 2.0 + The type of this paramter has changed to :class:`str`. + + Raises + ------- + HTTPException + Failed to change the channel's voice region. + """ + await self._state.http.change_call_voice_region(self._channel_id, str(region)) + + @_running_only + async def ring(self) -> None: + """|coro| + + Rings the other recipient. + + Raises + ------- + HTTPException + Ringing failed. + ClientException + The call has ended. + """ + channel = self.channel + await self._state.http.ring(channel.id, channel.recipient.id) + + @_running_only + async def stop_ringing(self) -> None: + """|coro| + + Stops ringing the other recipient. + + Raises + ------- + HTTPException + Stopping the ringing failed. + ClientException + The call has ended. + """ + channel = self.channel + await self._state.http.stop_ringing(channel.id, channel.recipient.id) + + @_running_only + async def connect( + self, + *, + timeout: float = 60.0, + reconnect: bool = True, + cls: Callable[[Client, Connectable], ConnectReturn] = MISSING, + ring: bool = True, + ) -> ConnectReturn: + """|coro| + + Connects to voice and creates a :class:`~discord.VoiceClient` to establish + your connection to the voice server. + + There is an alias of this called :attr:`join`. + + Parameters + ----------- + timeout: :class:`float` + The timeout in seconds to wait for the voice endpoint. + reconnect: :class:`bool` + Whether the bot should automatically attempt + a reconnect if a part of the handshake fails + or the gateway goes down. + cls: Type[:class:`~discord.VoiceProtocol`] + A type that subclasses :class:`~discord.VoiceProtocol` to connect with. + Defaults to :class:`~discord.VoiceClient`. + ring: :class:`bool` + Whether to ring the other user. + + Raises + ------- + asyncio.TimeoutError + Could not connect to the voice channel in time. + ~discord.ClientException + You are already connected to a voice channel. + ~discord.opus.OpusNotLoaded + The opus library has not been loaded. + + Returns + -------- + :class:`~discord.VoiceProtocol` + A voice client that is fully connected to the voice server. + """ + return await self.channel.connect(timeout=timeout, reconnect=reconnect, cls=cls, ring=ring) + + @_running_only + async def join( + self, + *, + timeout: float = 60.0, + reconnect: bool = True, + cls: Callable[[Client, Connectable], ConnectReturn] = MISSING, + ring: bool = True, + ) -> ConnectReturn: + """|coro| + + Connects to voice and creates a :class:`~discord.VoiceClient` to establish + your connection to the voice server. + + This is an alias of :attr:`connect`. + + Parameters + ----------- + timeout: :class:`float` + The timeout in seconds to wait for the voice endpoint. + reconnect: :class:`bool` + Whether the bot should automatically attempt + a reconnect if a part of the handshake fails + or the gateway goes down. + cls: Type[:class:`~discord.VoiceProtocol`] + A type that subclasses :class:`~discord.VoiceProtocol` to connect with. + Defaults to :class:`~discord.VoiceClient`. + ring: :class:`bool` + Whether to ring the other user. + + Raises + ------- + asyncio.TimeoutError + Could not connect to the voice channel in time. + ~discord.ClientException + You are already connected to a voice channel. + ~discord.opus.OpusNotLoaded + The opus library has not been loaded. + + Returns + -------- + :class:`~discord.VoiceProtocol` + A voice client that is fully connected to the voice server. + """ + return await self.connect(timeout=timeout, reconnect=reconnect, cls=cls, ring=ring) + + @_running_only + async def disconnect(self, **kwargs) -> None: + """|coro| + + Disconnects this voice client from voice. + + There is an alias of this called :attr:`leave`. + """ + state = self._state + if not (client := state._get_voice_client(self.channel.me.id)): + return + + return await client.disconnect(**kwargs) + + @_running_only + async def leave(self, **kwargs) -> None: + """|coro| + + Disconnects this voice client from voice. + + This is an alias of :attr:`disconnect`. + """ + return await self.disconnect(**kwargs) + + def voice_state_for(self, user) -> Optional[VoiceState]: + """Retrieves the :class:`VoiceState` for a specified :class:`User`. + + If the :class:`User` has no voice state then this function returns + ``None``. + + Parameters + ------------ + user: :class:`User` + The user to retrieve the voice state for. + + Returns + -------- + Optional[:class:`VoiceState`] + The voice state associated with this user. + """ + return self._state._voice_state_for(user.id) + + +class GroupCall(PrivateCall): + """Represents a Discord group call. + + This is accompanied with a :class:`CallMessage` denoting the information. + + Attributes + ----------- + channel: :class:`GroupChannel` + The channel the group call is in. + message: Optional[:class:`Message`] + The message associated with this group call (if available). + unavailable: :class:`bool` + Denotes if this group call is unavailable. + ringing: List[:class:`~discord.abc.User`] + A list of users that are currently being rung to join the call. + region: :class:`str` + The region the group call is being hosted in. + + .. versionchanged:: 2.0 + The type of this attribute has changed to :class:`str`. + """ + + if TYPE_CHECKING: + channel: GroupChannel + + def _update( + self, *, ringing: List[int] = [], region: str = MISSING + ) -> None: + if region is not MISSING: + self.region = region + + lookup: Dict[int, abcUser] = {u.id: u for u in self.channel.recipients} + me = self.channel.me + lookup[me.id] = me + self.ringing = list(filter(None, map(lookup.get, ringing))) + + @property + def members(self) -> List[abcUser]: + """List[:class:`User`]: Returns all users that are currently in this call.""" + ret: List[abcUser] = [u for u in self.channel.recipients if self._is_participating(u)] + me = self.channel.me + if self._is_participating(me): + ret.append(me) + + return ret + + @_running_only + async def ring(self, *recipients) -> None: + await self._state.http.ring(self._channel_id, *{r.id for r in recipients}) + + @_running_only + async def stop_ringing(self, *recipients) -> None: + await self._state.http.stop_ringing(self._channel_id, *{r.id for r in recipients}) + + +Call = Union[PrivateCall, GroupCall] diff --git a/discord/channel.py b/discord/channel.py index e4eb7e859..28c9efe2d 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -24,8 +24,6 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -import time -import asyncio from typing import ( Any, AsyncIterator, @@ -46,6 +44,7 @@ import discord.abc from .scheduled_event import ScheduledEvent from .permissions import PermissionOverwrite, Permissions from .enums import ChannelType, PrivacyLevel, try_enum, VideoQualityMode +from .calls import PrivateCall, GroupCall from .mixins import Hashable from .object import Object from . import utils @@ -54,6 +53,7 @@ from .asset import Asset from .errors import ClientException from .stage_instance import StageInstance from .threads import Thread +from .invite import Invite __all__ = ( 'TextChannel', @@ -70,9 +70,10 @@ if TYPE_CHECKING: from typing_extensions import Self from .types.threads import ThreadArchiveDuration + from .client import Client from .role import Role from .member import Member, VoiceState - from .abc import Snowflake, SnowflakeTime + from .abc import Snowflake, SnowflakeTime, T as ConnectReturn from .message import Message, PartialMessage from .webhook import Webhook from .state import ConnectionState @@ -87,12 +88,6 @@ if TYPE_CHECKING: StoreChannel as StoreChannelPayload, GroupDMChannel as GroupChannelPayload, ) - from .types.snowflake import SnowflakeList - - -async def _single_delete_strategy(messages: Iterable[Message], *, reason: Optional[str] = None): - for m in messages: - await m.delete() class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): @@ -372,15 +367,13 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): Deletes a list of messages. This is similar to :meth:`Message.delete` except it bulk deletes multiple messages. - As a special case, if the number of messages is 0, then nothing - is done. If the number of messages is 1 then single message - delete is done. If it's more than two, then bulk delete is used. - - You cannot bulk delete more than 100 messages or messages that - are older than 14 days old. - You must have the :attr:`~Permissions.manage_messages` permission to - use this. + use this (unless they're your own). + + .. note:: + Users do not have access to the message bulk-delete endpoint. + Since messages are just iterated over and deleted one-by-one, + it's easy to get ratelimited using this method. .. versionchanged:: 2.0 @@ -397,12 +390,8 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): Raises ------ - ClientException - The number of messages to delete was more than 100. Forbidden You do not have proper permissions to delete the messages. - NotFound - If single delete, then the message was already deleted. HTTPException Deleting the messages failed. """ @@ -410,18 +399,9 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): messages = list(messages) if len(messages) == 0: - return # do nothing + return # Do nothing - if len(messages) == 1: - message_id: int = messages[0].id - await self._state.http.delete_message(self.id, message_id) - return - - if len(messages) > 100: - raise ClientException('Can only bulk delete messages up to 100 messages') - - message_ids: SnowflakeList = [m.id for m in messages] - await self._state.http.delete_messages(self.id, message_ids, reason=reason) + await self._state._delete_messages(self.id, messages, reason=reason) async def purge( self, @@ -432,7 +412,6 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): after: Optional[SnowflakeTime] = None, around: Optional[SnowflakeTime] = None, oldest_first: Optional[bool] = False, - bulk: bool = True, reason: Optional[str] = None, ) -> List[Message]: """|coro| @@ -441,10 +420,8 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): ``check``. If a ``check`` is not provided then all messages are deleted without discrimination. - You must have the :attr:`~Permissions.manage_messages` permission to - delete messages even if they are your own. - The :attr:`~Permissions.read_message_history` permission is - also needed to retrieve message history. + The :attr:`~Permissions.read_message_history` permission is needed to + retrieve message history. .. versionchanged:: 2.0 @@ -477,10 +454,6 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): Same as ``around`` in :meth:`history`. oldest_first: Optional[:class:`bool`] Same as ``oldest_first`` in :meth:`history`. - bulk: :class:`bool` - If ``True``, use bulk delete. Setting this to ``False`` is useful for mass-deleting - a bot's own messages without :attr:`Permissions.manage_messages`. When ``True``, will - fall back to single delete if messages are older than two weeks. reason: Optional[:class:`str`] The reason for purging the messages. Shows up on the audit log. @@ -496,49 +469,30 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): List[:class:`.Message`] The list of messages that were deleted. """ - if check is MISSING: check = lambda m: True + state = self._state + channel_id = self.id iterator = self.history(limit=limit, before=before, after=after, oldest_first=oldest_first, around=around) ret: List[Message] = [] count = 0 - minimum_time = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 - strategy = self.delete_messages if bulk else _single_delete_strategy - async for message in iterator: - if count == 100: - to_delete = ret[-100:] - await strategy(to_delete, reason=reason) + if count == 50: + to_delete = ret[-50:] + await state._delete_messages(channel_id, to_delete) count = 0 - await asyncio.sleep(1) if not check(message): continue - if message.id < minimum_time: - # older than 14 days old - if count == 1: - await ret[-1].delete() - elif count >= 2: - to_delete = ret[-count:] - await strategy(to_delete, reason=reason) - - count = 0 - strategy = _single_delete_strategy - count += 1 ret.append(message) # Some messages remaining to poll - if count >= 2: - # more than 2 messages -> bulk delete - to_delete = ret[-count:] - await strategy(to_delete, reason=reason) - elif count == 1: - # delete a single message - await ret[-1].delete() + to_delete = ret[-count:] + await state._delete_messages(channel_id, to_delete, reason=reason) return ret @@ -739,7 +693,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): The type of thread to create. If a ``message`` is passed then this parameter is ignored, as a thread created with a message is always a public thread. By default this creates a private thread if this is ``None``. - reason: :class:`str` + reason: Optional[:class:`str`] The reason for creating a new thread. Shows up on the audit log. invitable: :class:`bool` Whether non-modertators can add users to the thread. Only applicable to private threads. @@ -1292,7 +1246,7 @@ class StageChannel(VocalGuildChannel): The stage instance's topic. privacy_level: :class:`PrivacyLevel` The stage instance's privacy level. Defaults to :attr:`PrivacyLevel.guild_only`. - reason: :class:`str` + reason: Optional[:class:`str`] The reason the stage instance was created. Shows up on the audit log. Raises @@ -1834,7 +1788,7 @@ class StoreChannel(discord.abc.GuildChannel, Hashable): return self.__class__(state=self._state, guild=self.guild, data=payload) # type: ignore -class DMChannel(discord.abc.Messageable, Hashable): +class DMChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): """Represents a Discord direct message channel. .. container:: operations @@ -1857,6 +1811,11 @@ class DMChannel(discord.abc.Messageable, Hashable): Attributes ---------- + last_message_id: Optional[:class:`int`] + The last message ID of the message sent to this channel. It may + *not* point to an existing or valid message. + + .. versionadded:: 2.0 recipient: Optional[:class:`User`] The user you are participating with in the direct message channel. If this channel is received through the gateway, the recipient information @@ -1867,17 +1826,31 @@ class DMChannel(discord.abc.Messageable, Hashable): The direct message channel ID. """ - __slots__ = ('id', 'recipient', 'me', '_state') + __slots__ = ('id', 'recipient', 'me', 'last_message_id', '_state') def __init__(self, *, me: ClientUser, state: ConnectionState, data: DMChannelPayload): self._state: ConnectionState = state - self.recipient: Optional[User] = state.store_user(data['recipients'][0]) + self.last_message_id: Optional[int] = utils._get_as_snowflake(data, 'last_message_id') + self.recipient: User = state.store_user(data['recipients'][0]) self.me: ClientUser = me self.id: int = int(data['id']) + def _get_voice_client_key(self) -> Tuple[int, str]: + return self.me.id, 'self_id' + + def _get_voice_state_pair(self) -> Tuple[int, int]: + return self.me.id, self.id + + def _add_call(self, **kwargs) -> PrivateCall: + return PrivateCall(**kwargs) + async def _get_channel(self): + await self._state.access_private_channel(self.id) return self + def _initial_ring(self): + return self._state.http.ring(self.id) + def __str__(self) -> str: if self.recipient: return f'Direct Message with {self.recipient}' @@ -1886,15 +1859,10 @@ class DMChannel(discord.abc.Messageable, Hashable): def __repr__(self) -> str: return f'' - @classmethod - def _from_message(cls, state: ConnectionState, channel_id: int) -> Self: - self = cls.__new__(cls) - self._state = state - self.id = channel_id - self.recipient = None - # state.user won't be None here - self.me = state.user # type: ignore - return self + @property + def call(self) -> Optional[PrivateCall]: + """Optional[:class:`PrivateCall`]: The channel's currently active call.""" + return self._state._calls.get(self.id) @property def type(self) -> ChannelType: @@ -1906,6 +1874,35 @@ class DMChannel(discord.abc.Messageable, Hashable): """:class:`datetime.datetime`: Returns the direct message channel's creation time in UTC.""" return utils.snowflake_time(self.id) + @property + def jump_url(self) -> str: + """:class:`str`: Returns a URL that allows the client to jump to the channel. + + .. versionadded:: 2.0 + """ + return f'https://discord.com/channels/@me/{self.id}' + + @property + def last_message(self) -> Optional[Message]: + """Fetches the last message from this channel in cache. + + The message might not be valid or point to an existing message. + + .. admonition:: Reliable Fetching + :class: helpful + + For a slightly more reliable method of fetching the + last message, consider using either :meth:`history` + or :meth:`fetch_message` with the :attr:`last_message_id` + attribute. + + Returns + --------- + Optional[:class:`Message`] + The last message in this channel or ``None`` if not found. + """ + return self._state._get_message(self.last_message_id) if self.last_message_id else None + def permissions_for(self, obj: Any = None, /) -> Permissions: """Handles permission resolution for a :class:`User`. @@ -1967,8 +1964,38 @@ class DMChannel(discord.abc.Messageable, Hashable): return PartialMessage(channel=self, id=message_id) + async def close(self): + """|coro| + + "Deletes" the channel. + + In reality, if you recreate a DM with the same user, + all your message history will be there. -class GroupChannel(discord.abc.Messageable, Hashable): + Raises + ------- + HTTPException + Closing the channel failed. + """ + await self._state.http.delete_channel(self.id) + + @utils.copy_doc(discord.abc.Connectable.connect) + async def connect( + self, + *, + timeout: float = 60.0, + reconnect: bool = True, + cls: Callable[[Client, discord.abc.Connectable], ConnectReturn] = MISSING, + ring: bool = True, + ) -> ConnectReturn: + await self._get_channel() + call = self.call + if call is None and ring: + await self._initial_ring() + return await super().connect(timeout=timeout, reconnect=reconnect, cls=cls) + + +class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): """Represents a Discord group channel. .. container:: operations @@ -1991,6 +2018,11 @@ class GroupChannel(discord.abc.Messageable, Hashable): Attributes ---------- + last_message_id: Optional[:class:`int`] + The last message ID of the message sent to this channel. It may + *not* point to an existing or valid message. + + .. versionadded:: 2.0 recipients: List[:class:`User`] The users you are participating with in the group channel. me: :class:`ClientUser` @@ -2007,7 +2039,7 @@ class GroupChannel(discord.abc.Messageable, Hashable): The group channel's name if provided. """ - __slots__ = ('id', 'recipients', 'owner_id', 'owner', '_icon', 'name', 'me', '_state') + __slots__ = ('last_message_id', 'id', 'recipients', 'owner_id', 'owner', '_icon', 'name', 'me', '_state') def __init__(self, *, me: ClientUser, state: ConnectionState, data: GroupChannelPayload): self._state: ConnectionState = state @@ -2020,6 +2052,7 @@ class GroupChannel(discord.abc.Messageable, Hashable): self._icon: Optional[str] = data.get('icon') self.name: Optional[str] = data.get('name') self.recipients: List[User] = [self._state.store_user(u) for u in data.get('recipients', [])] + self.last_message_id: Optional[int] = utils._get_as_snowflake(data, 'last_message_id') self.owner: Optional[BaseUser] if self.owner_id == self.me.id: @@ -2027,9 +2060,22 @@ class GroupChannel(discord.abc.Messageable, Hashable): else: self.owner = utils.find(lambda u: u.id == self.owner_id, self.recipients) + def _get_voice_client_key(self) -> Tuple[int, str]: + return self.me.id, 'self_id' + + def _get_voice_state_pair(self) -> Tuple[int, int]: + return self.me.id, self.id + async def _get_channel(self): + await self._state.access_private_channel(self.id) return self + def _initial_ring(self): + return self._state.http.ring(self.id) + + def _add_call(self, **kwargs) -> GroupCall: + return GroupCall(**kwargs) + def __str__(self) -> str: if self.name: return self.name @@ -2042,6 +2088,11 @@ class GroupChannel(discord.abc.Messageable, Hashable): def __repr__(self) -> str: return f'' + @property + def call(self) -> Optional[PrivateCall]: + """Optional[:class:`PrivateCall`]: The channel's currently active call.""" + return self._state._calls.get(self.id) + @property def type(self) -> ChannelType: """:class:`ChannelType`: The channel's Discord type.""" @@ -2059,6 +2110,35 @@ class GroupChannel(discord.abc.Messageable, Hashable): """:class:`datetime.datetime`: Returns the channel's creation time in UTC.""" return utils.snowflake_time(self.id) + @property + def jump_url(self) -> str: + """:class:`str`: Returns a URL that allows the client to jump to the channel. + + .. versionadded:: 2.0 + """ + return f'https://discord.com/channels/@me/{self.id}' + + @property + def last_message(self) -> Optional[Message]: + """Fetches the last message from this channel in cache. + + The message might not be valid or point to an existing message. + + .. admonition:: Reliable Fetching + :class: helpful + + For a slightly more reliable method of fetching the + last message, consider using either :meth:`history` + or :meth:`fetch_message` with the :attr:`last_message_id` + attribute. + + Returns + --------- + Optional[:class:`Message`] + The last message in this channel or ``None`` if not found. + """ + return self._state._get_message(self.last_message_id) if self.last_message_id else None + def permissions_for(self, obj: Snowflake, /) -> Permissions: """Handles permission resolution for a :class:`User`. @@ -2087,7 +2167,6 @@ class GroupChannel(discord.abc.Messageable, Hashable): :class:`Permissions` The resolved permissions for the user. """ - base = Permissions.text() base.read_messages = True base.send_tts_messages = False @@ -2099,6 +2178,100 @@ class GroupChannel(discord.abc.Messageable, Hashable): return base + async def add_recipients(self, *recipients) -> None: + r"""|coro| + + Adds recipients to this group. + + A group can only have a maximum of 10 members. + Attempting to add more ends up in an exception. To + add a recipient to the group, you must have a relationship + with the user of type :attr:`RelationshipType.friend`. + + Parameters + ----------- + \*recipients: :class:`User` + An argument list of users to add to this group. + + Raises + ------- + HTTPException + Adding a recipient to this group failed. + """ + # TODO: wait for the corresponding WS event + await self._get_channel() + req = self._state.http.add_group_recipient + for recipient in recipients: + await req(self.id, recipient.id) + + async def remove_recipients(self, *recipients) -> None: + r"""|coro| + + Removes recipients from this group. + + Parameters + ----------- + \*recipients: :class:`User` + An argument list of users to remove from this group. + + Raises + ------- + HTTPException + Removing a recipient from this group failed. + """ + # TODO: wait for the corresponding WS event + await self._get_channel() + req = self._state.http.remove_group_recipient + for recipient in recipients: + await req(self.id, recipient.id) + + @overload + async def edit( + self, *, name: Optional[str] = ..., icon: Optional[bytes] = ..., + ) -> Optional[GroupChannel]: + ... + + @overload + async def edit(self) -> Optional[GroupChannel]: + ... + + async def edit(self, **fields) -> Optional[GroupChannel]: + """|coro| + + Edits the group. + + .. versionchanged:: 2.0 + Edits are no longer in-place, the newly edited channel is returned instead. + + Parameters + ----------- + name: Optional[:class:`str`] + The new name to change the group to. + Could be ``None`` to remove the name. + icon: Optional[:class:`bytes`] + A :term:`py:bytes-like object` representing the new icon. + Could be ``None`` to remove the icon. + + Raises + ------- + HTTPException + Editing the group failed. + """ + await self._get_channel() + + try: + icon_bytes = fields['icon'] + except KeyError: + pass + else: + if icon_bytes is not None: + fields['icon'] = utils._bytes_to_base64_data(icon_bytes) + + data = await self._state.http.edit_group(self.id, **fields) + if data is not None: + # The payload will always be the proper channel payload + return self.__class__(me=self.me, state=self._state, data=payload) # type: ignore + async def leave(self) -> None: """|coro| @@ -2111,8 +2284,47 @@ class GroupChannel(discord.abc.Messageable, Hashable): HTTPException Leaving the group failed. """ + await self._state.http.delete_channel(self.id) + + async def create_invite(self, *, max_age: int = 86400) -> Invite: + """|coro| + + Creates an instant invite from a group channel. + + Parameters + ------------ + max_age: :class:`int` + How long the invite should last in seconds. + Defaults to 86400. Does not support 0. + + Raises + ------- + ~discord.HTTPException + Invite creation failed. + + Returns + -------- + :class:`~discord.Invite` + The invite that was created. + """ + data = await self._state.http.create_group_invite(self.id, max_age=max_age) + return Invite.from_incomplete(data=data, state=self._state) + + @utils.copy_doc(discord.abc.Connectable.connect) + async def connect( + self, + *, + timeout: float = 60.0, + reconnect: bool = True, + cls: Callable[[Client, discord.abc.Connectable], ConnectReturn] = MISSING, + ring: bool = True, + ) -> ConnectReturn: + await self._get_channel() + call = self.call + if call is None and ring: + await self._initial_ring() + return await super().connect(timeout=timeout, reconnect=reconnect, cls=cls) - await self._state.http.leave_group(self.id) class PartialMessageable(discord.abc.Messageable, Hashable): @@ -2151,6 +2363,7 @@ class PartialMessageable(discord.abc.Messageable, Hashable): self._state: ConnectionState = state self.id: int = id self.type: Optional[ChannelType] = type + self.last_message_id: Optional[int] = None async def _get_channel(self) -> PartialMessageable: return self @@ -2195,14 +2408,21 @@ def _guild_channel_factory(channel_type: int): return None, value -def _channel_factory(channel_type: int): - cls, value = _guild_channel_factory(channel_type) +def _private_channel_factory(channel_type: int): + value = try_enum(ChannelType, channel_type) if value is ChannelType.private: return DMChannel, value elif value is ChannelType.group: return GroupChannel, value else: - return cls, value + return None, value + + +def _channel_factory(channel_type: int): + cls, value = _guild_channel_factory(channel_type) + if cls is None: + cls, value = _private_channel_factory(channel_type) + return cls, value def _threaded_channel_factory(channel_type: int): diff --git a/discord/client.py b/discord/client.py index 3561cef1a..17d70cab4 100644 --- a/discord/client.py +++ b/discord/client.py @@ -32,13 +32,13 @@ import sys import traceback from typing import ( Any, - AsyncIterator, Callable, Coroutine, Dict, Generator, List, Optional, + overload, Sequence, TYPE_CHECKING, Tuple, @@ -48,19 +48,19 @@ from typing import ( import aiohttp -from .user import User, ClientUser +from .user import BaseUser, User, ClientUser, Note from .invite import Invite from .template import Template from .widget import Widget from .guild import Guild from .emoji import Emoji -from .channel import _threaded_channel_factory, PartialMessageable -from .enums import ChannelType +from .channel import _private_channel_factory, _threaded_channel_factory, GroupChannel, PartialMessageable +from .enums import ActivityType, ChannelType, Status, InviteType, try_enum from .mentions import AllowedMentions from .errors import * from .enums import Status -from .flags import ApplicationFlags, Intents from .gateway import * +from .gateway import ConnectionClosed from .activity import ActivityTypes, BaseActivity, create_activity from .voice_client import VoiceClient from .http import HTTPClient @@ -70,19 +70,23 @@ from .utils import MISSING, time_snowflake from .object import Object from .backoff import ExponentialBackoff from .webhook import Webhook -from .appinfo import AppInfo -from .ui.view import View +from .appinfo import Application, PartialApplication from .stage_instance import StageInstance from .threads import Thread from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory +from .profile import UserProfile +from .connections import Connection +from .team import Team +from .member import _ClientStatus if TYPE_CHECKING: from .types.guild import Guild as GuildPayload - from .abc import SnowflakeTime, Snowflake, PrivateChannel from .guild import GuildChannel + from .abc import PrivateChannel, GuildChannel, Snowflake, SnowflakeTime from .channel import DMChannel from .message import Message from .member import Member + from .relationship import Relationship from .voice_client import VoiceProtocol # fmt: off @@ -156,31 +160,22 @@ class Client: 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. - application_id: :class:`int` - The client's application ID. - 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. - - .. versionadded:: 1.5 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. + If not given, defaults to cache as much as possible. .. versionadded:: 1.5 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``. + amounts of guilds. The default is ``True``. .. versionadded:: 1.5 + request_guilds: :class:`bool` + Whether to request guilds at startup (behaves similarly to the old + guild_subscriptions option). Defaults to True. + + .. versionadded:: 1.10 status: Optional[:class:`.Status`] A status to start your presence with upon logging on to Discord. activity: Optional[:class:`.BaseActivity`] @@ -194,11 +189,6 @@ class Client: 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 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 @@ -215,6 +205,9 @@ class Client: To enable these events, this must be set to ``True``. Defaults to ``False``. .. versionadded:: 2.0 + sync_presence: :class:`bool` + Whether to keep presences up-to-date across clients. + The default behavior is ``True`` (what the client does). Attributes ----------- @@ -230,12 +223,10 @@ class Client: loop: Optional[asyncio.AbstractEventLoop] = None, **options: Any, ): - # self.ws is set in the connect method + # Set in the connect method self.ws: DiscordWebSocket = None # type: ignore self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() if loop is None else loop self._listeners: Dict[str, List[Tuple[asyncio.Future, Callable[..., bool]]]] = {} - self.shard_id: Optional[int] = options.get('shard_id') - self.shard_count: Optional[int] = options.get('shard_count') connector: Optional[aiohttp.BaseConnector] = options.pop('connector', None) proxy: Optional[str] = options.pop('proxy', None) @@ -247,6 +238,7 @@ class Client: self._handlers: Dict[str, Callable] = { 'ready': self._handle_ready, + 'connect': self._handle_connect } self._hooks: Dict[str, Callable] = { @@ -254,30 +246,40 @@ class Client: } self._enable_debug_events: bool = options.pop('enable_debug_events', False) + self._sync_presences: bool = options.pop('sync_presence', True) self._connection: ConnectionState = self._get_state(**options) - self._connection.shard_count = self.shard_count self._closed: bool = False self._ready: asyncio.Event = asyncio.Event() - self._connection._get_websocket = self._get_websocket - self._connection._get_client = lambda: self + + self._client_status: _ClientStatus = _ClientStatus() + self._client_activities: Dict[Optional[str], Tuple[ActivityTypes, ...]] = { + None: None, + 'this': None, + } + self._session_count = 1 if VoiceClient.warn_nacl: VoiceClient.warn_nacl = False - _log.warning("PyNaCl is not installed, voice will NOT be supported") - - # internals + _log.warning('PyNaCl is not installed, voice will NOT be supported.') - def _get_websocket(self, guild_id: Optional[int] = None, *, shard_id: Optional[int] = None) -> DiscordWebSocket: - return self.ws + # Internals def _get_state(self, **options: Any) -> ConnectionState: - return ConnectionState( - dispatch=self.dispatch, handlers=self._handlers, hooks=self._hooks, http=self.http, loop=self.loop, **options - ) + return ConnectionState(dispatch=self.dispatch, handlers=self._handlers, + hooks=self._hooks, http=self.http, loop=self.loop, + client=self, **options) def _handle_ready(self) -> None: self._ready.set() + def _handle_connect(self) -> None: + state = self._connection + activities = self.initial_activities + status = self.initial_status + if status is None: + status = getattr(state.settings, 'status', None) + self.loop.create_task(self.change_presence(activities=activities, status=status)) + @property def latency(self) -> float: """:class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. @@ -332,13 +334,7 @@ class Client: @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. - """ + """List[:class:`.abc.PrivateChannel`]: The private channels that the connected client is participating on.""" return self._connection.private_channels @property @@ -349,26 +345,6 @@ class Client: """ return self._connection.voice_clients - @property - def application_id(self) -> Optional[int]: - """Optional[:class:`int`]: The client's application ID. - - If this is not passed via ``__init__`` then this is retrieved - through the gateway when an event contains the data. Usually - after :func:`~discord.on_connect` is called. - - .. versionadded:: 2.0 - """ - return self._connection.application_id - - @property - def application_flags(self) -> ApplicationFlags: - """:class:`~discord.ApplicationFlags`: The client's application flags. - - .. versionadded:: 2.0 - """ - return self._connection.application_flags - def is_ready(self) -> bool: """:class:`bool`: Specifies if the client's internal cache is ready for use.""" return self._ready.is_set() @@ -398,11 +374,10 @@ class Client: **kwargs: Any, ) -> asyncio.Task: wrapped = self._run_event(coro, event_name, *args, **kwargs) - # Schedules the task return asyncio.create_task(wrapped, name=f'discord.py: {event_name}') def dispatch(self, event: str, *args: Any, **kwargs: Any) -> None: - _log.debug('Dispatching event %s', event) + _log.debug('Dispatching event %s.', event) method = 'on_' + event listeners = self._listeners.get(event) @@ -453,49 +428,64 @@ class Client: print(f'Ignoring exception in {event_method}', file=sys.stderr) traceback.print_exc() - # hooks + async def on_internal_settings_update(self, old_settings, new_settings): + if not self._sync_presences: + return + + if old_settings._status == new_settings._status and old_settings._custom_status == new_settings._custom_status: + return # Nothing changed + + status = new_settings.status + activities = [a for a in self.activities if a.type != ActivityType.custom] + if (activity := new_settings.custom_activity) is not None: + activities.append(activity) - async def _call_before_identify_hook(self, shard_id: Optional[int], *, initial: bool = False) -> None: - # This hook is an internal hook that actually calls the public one. + await self.change_presence(status=status, activities=activities, edit_settings=False) + + # Hooks + + async def _call_before_identify_hook(self, *, initial: bool = False) -> None: + # 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) + # toes of those who need to override their own hook + await self.before_identify_hook(initial=initial) - async def before_identify_hook(self, shard_id: Optional[int], *, initial: bool = False) -> None: + async def before_identify_hook(self, *, initial: bool = False) -> None: """|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. + The default implementation does nothing. .. 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. """ + pass - if not initial: - await asyncio.sleep(5.0) - - # login state management + # Login state management async def login(self, token: str) -> None: """|coro| Logs in the client with the specified credentials. + .. warning:: + + Logging on with a user token is unfortunately against the Discord + `Terms of Service `_ + and doing so might potentially get your account banned. + Use this at your own risk. Parameters ----------- token: :class:`str` - The authentication token. Do not prefix this token with - anything as the library will do it for you. + The authentication token. Raises ------ @@ -507,10 +497,12 @@ class Client: passing status code. """ - _log.info('logging in using static token') + _log.info('Logging in using static token.') - data = await self.http.static_login(token.strip()) - self._connection.user = ClientUser(state=self._connection, data=data) + state = self._connection + data = await state.http.static_login(token.strip()) + state.analytics_token = data.get('analytics_token', '') + state.user = ClientUser(state=state, data=data) async def connect(self, *, reconnect: bool = True) -> None: """|coro| @@ -525,8 +517,8 @@ class Client: 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). + disconnects that lead to bad state will not be handled + (such as bad tokens). Raises ------- @@ -540,7 +532,6 @@ class Client: backoff = ExponentialBackoff() ws_params = { 'initial': True, - 'shard_id': self.shard_id, } while not self.is_closed(): try: @@ -567,7 +558,7 @@ class Client: if not reconnect: await self.close() if isinstance(exc, ConnectionClosed) and exc.code == 1000: - # clean close, don't re-raise this + # Clean close, don't re-raise this return raise @@ -580,12 +571,10 @@ class Client: 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 + # such as a clean disconnect (1000) or a bad state (bad token, 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: - raise PrivilegedIntentsRequired(exc.shard_id) from None if exc.code != 1000: await self.close() raise @@ -594,8 +583,8 @@ class Client: _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. + # 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) async def close(self) -> None: @@ -612,7 +601,7 @@ class Client: try: await voice.disconnect(force=True) except Exception: - # if an error happens during disconnects, disregard it. + # If an error happens during disconnects, disregard it pass if self.ws is not None and self.ws.open: @@ -637,11 +626,6 @@ class Client: """|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) @@ -706,48 +690,215 @@ class Client: # I am unsure why this gets raised here but suppress it anyway return None - # properties + # Properties def is_closed(self) -> bool: """:class:`bool`: Indicates if the websocket connection is closed.""" return self._closed @property - def activity(self) -> Optional[ActivityTypes]: - """Optional[:class:`.BaseActivity`]: The activity being used upon - logging in. + def voice_client(self) -> Optional[VoiceProtocol]: + """Optional[:class:`VoiceProtocol`]: Returns the :class:`VoiceProtocol` associated with private calls, if any.""" + return self._connection._get_voice_client(self._connection.self_id) + + @property + def initial_activity(self) -> Optional[ActivityTypes]: + """Optional[:class:`.BaseActivity`]: The primary activity set upon logging in. + + .. note:: + + The client may be setting multiple activities, these can be accessed under :attr:`initial_activities`. """ - return create_activity(self._connection._activity) + return create_activity(self._connection._activities[0]) if self._connection._activities else None - @activity.setter - def activity(self, value: Optional[ActivityTypes]) -> None: + @initial_activity.setter + def initial_activity(self, value: Optional[ActivityTypes]) -> None: if value is None: - self._connection._activity = None + self._connection._activities = [] elif isinstance(value, BaseActivity): - # ConnectionState._activity is typehinted as ActivityPayload, we're passing Dict[str, Any] - self._connection._activity = value.to_dict() # type: ignore + # ConnectionState._activities is typehinted as List[ActivityPayload], we're passing List[Dict[str, Any]] + self._connection._activities = [value.to_dict()] # type: ignore + else: + raise TypeError('activity must derive from BaseActivity') + + @property + def initial_activities(self) -> List[ActivityTypes]: + """List[:class:`.BaseActivity`]: The activities set upon logging in.""" + return [create_activity(activity) for activity in self._connection._activities] + + @initial_activities.setter + def initial_activities(self, values: List[ActivityTypes]) -> None: + if not values: + self._connection._activities = [] + elif all(isinstance(value, BaseActivity) for value in values): + # ConnectionState._activities is typehinted as List[ActivityPayload], we're passing List[Dict[str, Any]] + self._connection._activities = [value.to_dict() for value in values] # type: ignore else: - raise TypeError('activity must derive from BaseActivity.') + raise TypeError('activity must derive from BaseActivity') @property - def status(self): - """:class:`.Status`: - The status being used upon logging on to Discord. + def initial_status(self): + """Optional[:class:`.Status`]: The status set upon logging in. - .. versionadded: 2.0 + .. versionadded:: 2.0 """ - if self._connection._status in set(state.value for state in Status): + if self._connection._status in {state.value for state in Status}: return Status(self._connection._status) - return Status.online + return - @status.setter - def status(self, value): + @initial_status.setter + def initial_status(self, value): if value is Status.offline: self._connection._status = 'invisible' elif isinstance(value, Status): self._connection._status = str(value) else: - raise TypeError('status must derive from Status.') + raise TypeError('status must derive from Status') + + @property + def status(self) -> Status: + """:class:`Status`: The user's overall status. + + .. versionadded:: 2.0 + """ + status = try_enum(Status, self._client_status._status) + if status is Status.offline and not self.is_closed(): + status = getattr(self._connection.settings, 'status', status) + return status + + @property + def raw_status(self) -> str: + """:class:`str`: The user's overall status as a string value. + + .. versionadded:: 2.0 + """ + return str(self.status) + + @status.setter + def status(self, value: Status) -> None: + # Internal use only + self._client_status._status = str(value) + + @property + def mobile_status(self) -> Status: + """:class:`Status`: The user's status on a mobile device, if applicable. + + .. versionadded:: 2.0 + """ + return try_enum(Status, self._client_status.mobile or 'offline') + + @property + def desktop_status(self) -> Status: + """:class:`Status`: The user's status on the desktop client, if applicable. + + .. versionadded:: 2.0 + """ + return try_enum(Status, self._client_status.desktop or 'offline') + + @property + def web_status(self) -> Status: + """:class:`Status`: The user's status on the web client, if applicable. + + .. versionadded:: 2.0 + """ + return try_enum(Status, self._client_status.web or 'offline') + + @property + def client_status(self) -> Status: + """:class:`Status`: The library's status. + + .. versionadded:: 2.0 + """ + status = try_enum(Status, self._client_status._this) + if status is Status.offline and not self.is_closed(): + status = getattr(self._connection.settings, 'status', status) + return status + + def is_on_mobile(self) -> bool: + """:class:`bool`: A helper function that determines if a member is active on a mobile device. + + .. versionadded:: 2.0 + """ + return self._client_status.mobile is not None + + @property + def activities(self) -> Tuple[ActivityTypes]: + """Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]]: Returns the activities + the client is currently doing. + + .. versionadded:: 2.0 + + .. note:: + + Due to a Discord API limitation, this may be ``None`` if + the user is listening to a song on Spotify with a title longer + than 128 characters. See :issue:`1738` for more information. + """ + activities = tuple(map(create_activity, self._client_activities[None])) + if activities is None and not self.is_closed(): + activities = getattr(self._connection.settings, 'custom_activity', []) + activities = [activities] if activities else activities + return activities + + @property + def activity(self) -> Optional[ActivityTypes]: + """Optional[Union[:class:`BaseActivity`, :class:`Spotify`]]: Returns the primary + activity the client is currently doing. Could be ``None`` if no activity is being done. + + .. versionadded:: 2.0 + + .. note:: + + Due to a Discord API limitation, this may be ``None`` if + the user is listening to a song on Spotify with a title longer + than 128 characters. See :issue:`1738` for more information. + + .. note:: + + The client may have multiple activities, these can be accessed under :attr:`activities`. + """ + if (activities := self.activities): + return activities[0] + + @property + def mobile_activities(self) -> Tuple[ActivityTypes]: + """Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]]: Returns the activities + the client is currently doing on a mobile device, if applicable. + + .. versionadded:: 2.0 + """ + return tuple(map(create_activity, self._client_activities.get('mobile', []))) + + @property + def desktop_activities(self) -> Tuple[ActivityTypes]: + """Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]]: Returns the activities + the client is currently doing on the desktop client, if applicable. + + .. versionadded:: 2.0 + """ + return tuple(map(create_activity, self._client_activities.get('desktop', []))) + + @property + def web_activities(self) -> Tuple[ActivityTypes]: + """Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]]: Returns the activities + the client is currently doing on the web client, if applicable. + + .. versionadded:: 2.0 + """ + return tuple(map(create_activity, self._client_activities.get('web', []))) + + @property + def client_activities(self) -> Tuple[ActivityTypes]: + """Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]]: Returns the activities + the client is currently doing through this library, if applicable. + + .. versionadded:: 2.0 + """ + activities = tuple(map(create_activity, self._client_activities.get('this', []))) + if activities is None and not self.is_closed(): + activities = getattr(self._connection.settings, 'custom_activity', []) + activities = [activities] if activities else activities + return activities @property def allowed_mentions(self) -> Optional[AllowedMentions]: @@ -764,15 +915,7 @@ class Client: else: raise TypeError(f'allowed_mentions must be AllowedMentions not {value.__class__!r}') - @property - def intents(self) -> Intents: - """:class:`~discord.Intents`: The intents configured for this connection. - - .. versionadded:: 1.5 - """ - return self._connection.intents - - # helpers/getters + # Helpers/Getters @property def users(self) -> List[User]: @@ -907,7 +1050,7 @@ class Client: .. note:: To retrieve standard stickers, use :meth:`.fetch_sticker`. - or :meth:`.fetch_premium_sticker_packs`. + or :meth:`.fetch_sticker_packs`. Returns -------- @@ -957,7 +1100,7 @@ class Client: for guild in self.guilds: yield from guild.members - # listeners/waiters + # Listeners/Waiters async def wait_until_ready(self) -> None: """|coro| @@ -1072,7 +1215,7 @@ class Client: listeners.append((future, check)) return asyncio.wait_for(future, timeout) - # event registration + # Event registration def event(self, coro: Coro) -> Coro: """A decorator that registers an event to listen to. @@ -1107,12 +1250,23 @@ class Client: self, *, activity: Optional[BaseActivity] = None, + activities: Optional[List[BaseActivity]] = None, status: Optional[Status] = None, + afk: bool = False, + edit_settings: bool = True, ): """|coro| Changes the client's presence. + .. versionchanged:: 2.0 + Edits are no longer in place most of the time. + Added option to update settings. + + .. versionchanged:: 2.0 + This function no-longer raises ``InvalidArgument`` instead raising + :exc:`TypeError`. + Example --------- @@ -1121,60 +1275,117 @@ class Client: game = discord.Game("with the API") await client.change_presence(status=discord.Status.idle, activity=game) - .. versionchanged:: 2.0 - Removed the ``afk`` keyword-only parameter. - - .. versionchanged:: 2.0 - This function no-longer raises ``InvalidArgument`` instead raising - :exc:`TypeError`. - Parameters ---------- activity: Optional[:class:`.BaseActivity`] - The activity being done. ``None`` if no currently active activity is done. + The activity being done. ``None`` if no activity is done. + activities: Optional[List[:class:`BaseActivity`]] + A list of the activities being done. ``None`` if no activities + are done. Cannot be sent with ``activity``. status: Optional[:class:`.Status`] Indicates what status to change to. If ``None``, then :attr:`.Status.online` is used. + afk: :class:`bool` + Indicates if you are going AFK. This allows the Discord + client to know how to handle push notifications better + for you in case you are actually idle and not lying. + edit_settings: :class:`bool` + Whether to update the settings with the new status and/or + custom activity. This will broadcast the change and cause + all connected (official) clients to change presence as well. + Defaults to ``True``. Required for setting/editing expires_at + for custom activities. + It's not recommended to change this. Raises ------ TypeError - If the ``activity`` parameter is not the proper type. + The ``activity`` parameter is not the proper type. + Both ``activity`` and ``activities`` were passed. """ + if activity and activities: + raise TypeError('Cannot pass both activity and activities') + activities = activities or activity and [activity] + if activities is None: + activities = [] if status is None: - status_str = 'online' status = Status.online elif status is Status.offline: - status_str = 'invisible' - status = Status.offline - else: - status_str = str(status) + status = Status.invisible - await self.ws.change_presence(activity=activity, status=status_str) + await self.ws.change_presence(status=status, activities=activities, afk=afk) - for guild in self._connection.guilds: - me = guild.me - if me is None: - continue + if edit_settings: + custom_activity = None - if activity is not None: - me.activities = (activity,) # type: ignore - Type checker does not understand the downcast here - else: - me.activities = () + for activity in activities: + if getattr(activity, 'type', None) is ActivityType.custom: + custom_activity = activity + + payload: Dict[str, Any] = {'status': status} + payload['custom_activity'] = custom_activity + await self.user.edit_settings(**payload) - me.status = status + status_str = str(status) + activities_tuple = tuple(a.to_dict() for a in activities) + self._client_status._this = str(status) + self._client_activities['this'] = activities_tuple + if self._session_count <= 1: + self._client_status._status = status_str + self._client_activities[None] = self._client_activities['this'] = activities_tuple + + async def change_voice_state( + self, + *, + channel: Optional[Snowflake], + self_mute: bool = False, + self_deaf: bool = False, + self_video: bool = False, + preferred_region: Optional[str] = MISSING + ) -> None: + """|coro| + + Changes client's private channel voice state. + + .. versionadded:: 1.10 + + Parameters + ----------- + channel: Optional[:class:`abc.Snowflake`] + Channel the client wants to join (must be a private channel). 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. + self_video: :class:`bool` + Indicates if the client is using video. Untested & unconfirmed + (do not use). + preferred_region: Optional[:class:`str`] + The preferred region to connect to. + + .. versionchanged:: 2.0 + The type of this parameter has changed to :class:`str`. + """ + state = self._connection + ws = self.ws + channel_id = channel.id if channel else None + + if preferred_region is None or channel_id is None: + region = None + else: + region = str(preferred_region) if preferred_region else state.preferred_region + + await ws.voice_state(None, channel_id, self_mute, self_deaf, self_video, preferred_region=region) # Guild stuff async def fetch_guilds( self, *, - limit: Optional[int] = 100, - before: Optional[SnowflakeTime] = None, - after: Optional[SnowflakeTime] = None, - ) -> AsyncIterator[Guild]: - """Retrieves an :term:`asynchronous iterator` that enables receiving your guilds. + with_counts: bool = True + ) -> List[Guild]: + """Retrieves all your your guilds. .. note:: @@ -1185,101 +1396,25 @@ class Client: 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 = [guild async for guild in client.fetch_guilds(limit=150)] - # 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 datetime is provided, it is recommended to use a UTC aware datetime. - If the datetime is naive, it is assumed to be local time. - after: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] - Retrieve guilds after this date or object. - If a datetime is provided, it is recommended to use a UTC aware datetime. - If the datetime is naive, it is assumed to be local time. + with_counts: :class:`bool` + Whether to return approximate :attr:`.Guild.member_count` and :attr:`.Guild.presence_count`. + Defaults to ``True``. Raises ------ HTTPException Getting the guilds failed. - Yields + Returns -------- - :class:`.Guild` - The guild with the guild data parsed. + List[:class:`.Guild`] + A list of all your guilds. """ - - async def _before_strategy(retrieve, before, limit): - before_id = before.id if before else None - data = await self.http.get_guilds(retrieve, before=before_id) - - if data: - if limit is not None: - limit -= len(data) - - before = Object(id=int(data[-1]['id'])) - - return data, before, limit - - async def _after_strategy(retrieve, after, limit): - after_id = after.id if after else None - data = await self.http.get_guilds(retrieve, after=after_id) - - if data: - if limit is not None: - limit -= len(data) - - after = Object(id=int(data[0]['id'])) - - return data, after, limit - - if isinstance(before, datetime.datetime): - before = Object(id=time_snowflake(before, high=False)) - if isinstance(after, datetime.datetime): - after = Object(id=time_snowflake(after, high=True)) - - predicate: Optional[Callable[[GuildPayload], bool]] = None - strategy, state = _before_strategy, before - - if before and after: - predicate = lambda m: int(m['id']) > after.id - elif after: - strategy, state = _after_strategy, after - - while True: - retrieve = min(100 if limit is None else limit, 100) - if retrieve < 1: - return - - data, state, limit = await strategy(retrieve, state, limit) - - # Terminate loop on next iteration; there's no data left after this - if len(data) < 100: - limit = 0 - - if predicate: - data = filter(predicate, data) - - for raw_guild in data: - yield Guild(state=self._connection, data=raw_guild) + state = self._connection + guilds = await state.http.get_guilds(with_counts) + return [Guild(data=data, state=state) for data in guilds] async def fetch_template(self, code: Union[Template, str]) -> Template: """|coro| @@ -1314,8 +1449,7 @@ class Client: .. note:: - Using this, you will **not** receive :attr:`.Guild.channels`, :attr:`.Guild.members`, - :attr:`.Member.activity` and :attr:`.Member.voice` per :class:`.Member`. + Using this, you will **not** receive :attr:`.Guild.channels` and :attr:`.Guild.members`. .. note:: @@ -1325,17 +1459,16 @@ class Client: ``guild_id`` parameter is now positional-only. - with_counts: :class:`bool` - Whether to include count information in the guild. This fills the - :attr:`.Guild.approximate_member_count` and :attr:`.Guild.approximate_presence_count` - attributes without needing any privileged intents. Defaults to ``True``. - - .. versionadded:: 2.0 - Parameters ----------- guild_id: :class:`int` The guild's ID to fetch from. + with_counts: :class:`bool` + Whether to include count information in the guild. This fills the + :attr:`.Guild.approximate_member_count` and :attr:`.Guild.approximate_presence_count`. + Defaults to ``True``. + + .. versionadded:: 2.0 Raises ------ @@ -1349,12 +1482,11 @@ class Client: :class:`.Guild` The guild from the ID. """ - data = await self.http.get_guild(guild_id, with_counts=with_counts) + data = await self.http.get_guild(guild_id, with_counts) return Guild(data=data, state=self._connection) async def create_guild( self, - *, name: str, icon: bytes = MISSING, code: str = MISSING, @@ -1363,8 +1495,6 @@ class Client: Creates a :class:`.Guild`. - Bot accounts in more than 10 guilds are not allowed to create guilds. - .. versionchanged:: 2.0 ``name`` and ``icon`` parameters are now keyword-only. The `region`` parameter has been removed. @@ -1411,7 +1541,7 @@ class Client: async def fetch_stage_instance(self, channel_id: int, /) -> StageInstance: """|coro| - Gets a :class:`.StageInstance` for a stage channel id. + Gets a :class:`.StageInstance` for a stage channel ID. .. versionadded:: 2.0 @@ -1540,6 +1670,52 @@ class Client: resolved = utils.resolve_invite(invite) await self.http.delete_invite(resolved.code) + async def accept_invite(self, invite: Union[Invite, str]) -> Union[Guild, User, GroupChannel]: + """|coro| + + Uses an invite. + Either joins a guild, joins a group DM, or adds a friend. + + .. versionadded:: 1.9 + + Parameters + ---------- + invite: Union[:class:`.Invite`, :class:`str`] + The Discord invite ID, URL (must be a discord.gg URL), or :class:`.Invite`. + + Raises + ------ + HTTPException + Using the invite failed. + + Returns + ------- + :class:`.Guild` + The guild joined. This is not the same guild that is + added to cache. + """ + + if not isinstance(invite, Invite): + invite = await self.fetch_invite(invite, with_counts=False, with_expiration=False) + + state = self._connection + type = invite.type + if (message := invite._message): + kwargs = {'message': message} + else: + kwargs = { + 'guild_id': getattr(invite.guild, 'id', MISSING), + 'channel_id': getattr(invite.channel, 'id', MISSING), + 'channel_type': getattr(invite.channel, 'type', MISSING), + } + data = await state.http.accept_invite(invite.code, type, **kwargs) + if type is InviteType.guild: + return Guild(data=data['guild'], state=state) + elif type is InviteType.group_dm: + return GroupChannel(data=data['channel'], state=state, me=state.user) # type: ignore + else: + return User(data=data['inviter'], state=state) + # Miscellaneous stuff async def fetch_widget(self, guild_id: int, /) -> Widget: @@ -1573,39 +1749,23 @@ class Client: 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 - ------- - 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 - return AppInfo(self._connection, data) - async def fetch_user(self, user_id: int, /) -> User: """|coro| Retrieves a :class:`~discord.User` based on their ID. - You do not have to share any guilds with the user to get this information, - however many operations do require that you do. + 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:`discord.Intents.members` and member cache enabled, consider :meth:`get_user` instead. + This method is an API call. If you have member cache enabled, consider :meth:`get_user` instead. + + .. warning:: + + This API route is not used by the Discord client and may increase your chances at getting detected. + Consider :meth:`fetch_user_profile` if you share a guild/relationship with the user. .. versionchanged:: 2.0 @@ -1631,6 +1791,59 @@ class Client: data = await self.http.get_user(user_id) return User(state=self._connection, data=data) + async def fetch_user_profile( + self, user_id: int, /, *, with_mutuals: bool = True, fetch_note: bool = True + ) -> UserProfile: + """|coro| + + Gets an arbitrary user's profile. + + You must share a guild or be friends with this user to + get this information (unless the user is a bot). + + .. versionchanged:: 2.0 + + ``user_id`` parameter is now positional-only. + + Parameters + ------------ + user_id: :class:`int` + The ID of the user to fetch their profile for. + with_mutuals: :class:`bool` + Whether to fetch mutual guilds and friends. + This fills in :attr:`mutual_guilds` & :attr:`mutual_friends`. + fetch_note: :class:`bool` + Whether to pre-fetch the user's note. + + Raises + ------- + NotFound + A user with this ID does not exist. + Forbidden + Not allowed to fetch this profile. + HTTPException + Fetching the profile failed. + + Returns + -------- + :class:`.Profile` + The profile of the user. + """ + state = self._connection + data = await state.http.get_user_profile(user_id, with_mutual_guilds=with_mutuals) + + if with_mutuals: + if not data['user'].get('bot', False): + data['mutual_friends'] = await state.http.get_mutual_friends(user_id) + else: + data['mutual_friends'] = [] + profile = UserProfile(state=state, data=data) + + if fetch_note: + await profile.note.fetch() + + return profile + async def fetch_channel(self, channel_id: int, /) -> Union[GuildChannel, PrivateChannel, Thread]: """|coro| @@ -1669,11 +1882,11 @@ class Client: raise InvalidData('Unknown channel type {type} for channel ID {id}.'.format_map(data)) if ch_type in (ChannelType.group, ChannelType.private): - # the factory will be a DMChannel or GroupChannel here - channel = factory(me=self.user, data=data, state=self._connection) # type: ignore + # The factory will be a DMChannel or GroupChannel here + channel = factory(me=self.user, data=data, state=self._connection) # type: ignore else: - # the factory can't be a DMChannel or GroupChannel here - guild_id = int(data['guild_id']) # type: ignore + # The factory can't be a DMChannel or GroupChannel here + guild_id = int(data['guild_id']) # type: ignore guild = self.get_guild(guild_id) or Object(id=guild_id) # GuildChannels expect a Guild, we may be passing an Object channel = factory(guild=guild, state=self._connection, data=data) # type: ignore @@ -1726,17 +1939,29 @@ class Client: The sticker you requested. """ data = await self.http.get_sticker(sticker_id) - cls, _ = _sticker_factory(data['type']) - # The type checker is not smart enough to figure out the constructor is correct + cls, _ = _sticker_factory(data['type']) # type: ignore return cls(state=self._connection, data=data) # type: ignore - async def fetch_premium_sticker_packs(self) -> List[StickerPack]: + async def fetch_sticker_packs( + self, *, country: str = 'US', locale: str = 'en-US', payment_source_id: int = MISSING + ) -> List[StickerPack]: """|coro| - Retrieves all available premium sticker packs. + Retrieves all available default sticker packs. .. versionadded:: 2.0 + Parameters + ----------- + country: :class:`str` + ISO 3166 country code to fetch the sticker packs for. + Defaults to ``US``. + locale: :class:`str` + ISO 639 language code the name and description should be in. + Defaults to ``en-US``. + payment_source_id: :class:`int` + Unknown. + Raises ------- HTTPException @@ -1745,11 +1970,112 @@ class Client: Returns --------- List[:class:`.StickerPack`] - All available premium sticker packs. + All available sticker packs. """ - data = await self.http.list_premium_sticker_packs() + data = await self.http.list_premium_sticker_packs(country, locale, payment_source_id) return [StickerPack(state=self._connection, data=pack) for pack in data['sticker_packs']] + async def fetch_sticker_pack(self, pack_id: int, /): + """|coro| + + Retrieves a sticker pack with the specified ID. + + Raises + ------- + NotFound + A sticker pack with that ID was not found. + HTTPException + Retrieving the sticker packs failed. + + Returns + ------- + :class:`.StickerPack` + The sticker pack you requested. + """ + data = await self.http.get_sticker_pack(pack_id) + return StickerPack(state=self._connection, data=data) + + async def notes(self) -> List[Note]: + """|coro| + + Retrieves a list of :class:`Note` objects representing all your notes. + + Raises + ------- + HTTPException + Retreiving the notes failed. + + Returns + -------- + List[:class:`Note`] + All your notes. + """ + state = self._connection + data = await state.http.get_notes() + return [Note(state, int(id), note=note) for id, note in data.items()] + + async def fetch_note(self, user_id: int) -> Note: + """|coro| + + Retrieves a :class:`Note` for the specified user ID. + + Parameters + ----------- + user_id: :class:`int` + The ID of the user to fetch the note for. + + Raises + ------- + HTTPException + Retreiving the note failed. + + Returns + -------- + :class:`Note` + The note you requested. + """ + note = Note(self._connection, int(user_id)) + await note.fetch() + return note + + async def connections(self) -> List[Connection]: + """|coro| + + Retrieves all of your connections. + + Raises + ------- + HTTPException + Retreiving your connections failed. + + Returns + ------- + List[:class:`Connection`] + All your connections. + """ + state = self._connection + data = await state.http.get_connections() + return [Connection(data=d, state=state) for d in data] + + async def fetch_private_channels(self) -> List[PrivateChannel]: + """|coro| + + Retrieves all your private channels. + + Raises + ------- + HTTPException + Retreiving your private channels failed. + + Returns + -------- + List[:class:`PrivateChannel`] + All your private channels. + """ + state = self._connection + channels = await state.http.get_private_channels() + return [_private_channel_factory(data['type'])[0](me=self.user, data=data, state=state) for data in channels] + async def create_dm(self, user: Snowflake) -> DMChannel: """|coro| @@ -1778,44 +2104,263 @@ class Client: data = await state.http.start_private_message(user.id) return state.add_dm_channel(data) - def add_view(self, view: View, *, message_id: Optional[int] = None) -> None: - """Registers a :class:`~discord.ui.View` for persistent listening. + async def create_group(self, *recipients) -> GroupChannel: + r"""|coro| + + Creates a group direct message with the recipients + provided. These recipients must be have a relationship + of type :attr:`RelationshipType.friend`. + + Parameters + ----------- + \*recipients: :class:`User` + An argument :class:`list` of :class:`User` to have in + your group. + + Raises + ------- + HTTPException + Failed to create the group direct message. + + Returns + ------- + :class:`.GroupChannel` + The new group channel. + """ + users = [str(u.id) for u in recipients] + state = self._connection + data = await state.http.start_group(users) + return GroupChannel(me=self.user, data=data, state=state) + + @overload + async def send_friend_request(self, user: BaseUser) -> Relationship: + ... + + @overload + async def send_friend_request(self, user: str) -> Relationship: + ... + + @overload + async def send_friend_request(self, username: str, discriminator: str) -> Relationship: + ... + + async def send_friend_request(self, *args: Union[BaseUser, str]) -> Relationship: + """|coro| - This method should be used for when a view is comprised of components - that last longer than the lifecycle of the program. + Sends a friend request to another user. + + This function can be used in multiple ways. + + .. code-block:: python + + # Passing a user object: + await client.send_friend_request(user) + + # Passing a stringified user: + await client.send_friend_request('Jake#0001') + + # Passing a username and discriminator: + await client.send_friend_request('Jake', '0001') + + Parameters + ----------- + user: Union[:class:`User`, :class:`str`] + The user to send the friend request to. + username: :class:`str` + The username of the user to send the friend request to. + discriminator: :class:`str` + The discriminator of the user to send the friend request to. + + More than 2 parameters or less than 1 parameter raises a :exc:`TypeError`. + + Raises + ------- + Forbidden + Not allowed to send a friend request to this user. + HTTPException + Sending the friend request failed. + + Returns + ------- + :class:`.Relationship` + The new relationship. + """ + username: str + discrim: str + if len(args) == 1: + user = args[0] + if isinstance(user, BaseUser): + user = str(user) + username, discrim = user.split('#') # type: ignore + elif len(args) == 2: + username, discrim = args # type: ignore + else: + raise TypeError(f'send_friend_request() takes 1 or 2 arguments but {len(args)} were given') + + state = self._connection + data = await state.http.send_friend_request(username, discrim) + return Relationship(state=state, data=data) + + async def applications(self, *, with_team_applications: bool = True) -> List[Application]: + """|coro| + + Retrieves all applications owned by you. .. versionadded:: 2.0 Parameters - ------------ - view: :class:`discord.ui.View` - The view to register for dispatching. - message_id: Optional[:class:`int`] - The message ID that the view is attached to. This is currently used to - refresh the view's state during message update events. If not given - then message update events are not propagated for the view. + ----------- + with_team_applications: :class:`bool` + Whether to include applications owned by teams you're a part of. + Defaults to ``True``. Raises ------- - TypeError - A view was not passed. - ValueError - The view is not persistent. A persistent view has no timeout - and all their components have an explicitly provided custom_id. + HTTPException + Retrieving the applications failed. + + Returns + ------- + List[:class:`.Application`] + The applications you own. """ + state = self._connection + data = await state.http.get_my_applications(with_team_applications=with_team_applications) + return [Application(state=state, data=d) for d in data] + + async def fetch_application(self, app_id: int, /) -> Application: + """|coro| - if not isinstance(view, View): - raise TypeError(f'expected an instance of View not {view.__class__!r}') + Retrieves the application with the given ID. - if not view.is_persistent(): - raise ValueError('View is not persistent. Items need to have a custom_id set and View must have no timeout') + The application must be owned by you. - self._connection.store_view(view, message_id) + .. versionadded:: 2.0 - @property - def persistent_views(self) -> Sequence[View]: - """Sequence[:class:`.View`]: A sequence of persistent views added to the client. + Parameters + ----------- + id: :class:`int` + The ID of the application to fetch. + + Raises + ------- + HTTPException + Retrieving the application failed. + + Returns + ------- + :class:`.Application` + The retrieved application. + """ + state = self._connection + data = await state.http.get_my_application(app_id) + return Application(state=state, data=data) + + async def fetch_partial_application(self, app_id: int, /) -> PartialApplication: + """|coro| + + Retrieves the partial application with the given ID. + + Returns + -------- + :class:`.PartialApplication` + The retrieved application. + """ + state = self._connection + data = await state.http.get_partial_application(app_id) + return PartialApplication(state=state, data=data) + + async def teams(self) -> List[Team]: + """|coro| + + Retrieves all the teams you're a part of. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Retrieving the teams failed. + + Returns + ------- + List[:class:`.Team`] + The teams you're a part of. + """ + state = self._connection + data = await state.http.get_teams() + return [Team(state=state, data=d) for d in data] + + async def fetch_team(self, team_id: int, /) -> Team: + """|coro| + + Retrieves the team with the given ID. .. versionadded:: 2.0 + + Parameters + ----------- + id: :class:`int` + The ID of the team to fetch. + + Raises + ------- + HTTPException + Retrieving the team failed. + + Returns + ------- + :class:`.Team` + The retrieved team. """ - return self._connection.persistent_views + state = self._connection + data = await state.http.get_team(team_id) + return Team(state=state, data=data) + + async def create_application(self, name: str): + """|coro| + + Creates an application. + + Parameters + ---------- + name: :class:`str` + The name of the application. + + Raises + ------- + HTTPException + Failed to create the application. + + Returns + ------- + :class:`.Application` + The newly-created application. + """ + state = self._connection + data = await state.http.create_app(name) + return Application(state=state, data=data) + + async def create_team(self, name: str): + """|coro| + + Creates a team. + + Parameters + ---------- + name: :class:`str` + The name of the team. + + Raises + ------- + HTTPException + Failed to create the team. + + Returns + ------- + :class:`.Team` + The newly-created team. + """ + state = self._connection + data = await state.http.create_team(name) + return Team(state=state, data=data) diff --git a/discord/commands.py b/discord/commands.py new file mode 100644 index 000000000..9d9d6e671 --- /dev/null +++ b/discord/commands.py @@ -0,0 +1,705 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 datetime import datetime +from typing import Any, Dict, List, Optional, Protocol, Tuple, Type, runtime_checkable, TYPE_CHECKING, Union + +from .enums import AppCommandOptionType, AppCommandType, ChannelType, InteractionType, try_enum +from .errors import InvalidData +from .utils import _generate_session_id, time_snowflake + +if TYPE_CHECKING: + from .abc import Messageable, Snowflake + from .interactions import Interaction + from .message import Message + from .state import ConnectionState + +__all__ = ( + 'ApplicationCommand', + 'BaseCommand', + 'UserCommand', + 'MessageCommand', + 'SlashCommand', + 'SubCommand', + 'Option', + 'OptionChoice', +) + + +@runtime_checkable +class ApplicationCommand(Protocol): + """An ABC that represents a useable application command. + + The following implement this ABC: + + - :class:`~discord.BaseCommand` + - :class:`~discord.UserCommand` + - :class:`~discord.MessageCommand` + - :class:`~discord.SlashCommand` + - :class:`~discord.SubCommand` + + Attributes + ----------- + name: :class:`str` + The command's name. + description: :class:`str` + The command's description, if any. + version: :class:`int` + The command's version. + type: :class:`AppCommandType` + The type of application command. + default_permission: :class:`bool` + Whether the command is enabled in guilds by default. + """ + + __slots__ = () + + if TYPE_CHECKING: + _state: ConnectionState + _application_id: int + name: str + description: str + version: int + type: AppCommandType + target_channel: Optional[Messageable] + default_permission: bool + + async def __call__(self, data, channel: Optional[Messageable] = None) -> Interaction: + channel = channel or self.target_channel + if channel is None: + raise TypeError('__call__() missing 1 required argument: \'channel\'') + state = self._state + acc_channel = await channel._get_channel() + nonce = str(time_snowflake(datetime.utcnow())) + type = InteractionType.application_command + + state._interaction_cache[nonce] = (type.value, data['name'], acc_channel) + try: + await state.http.interact(type, data, acc_channel, form_data=True, nonce=nonce, application_id=self._application_id) + i = await state.client.wait_for( + 'interaction_finish', + check=lambda d: d.nonce == nonce, + timeout=7, + ) + except TimeoutError as exc: + raise InvalidData('Did not receive a response from Discord') from exc + finally: # Cleanup even if we failed + state._interaction_cache.pop(nonce, None) + return i + + +class BaseCommand(ApplicationCommand): + """Represents a base command. + + Attributes + ---------- + id: :class:`int` + The command's ID. + name: :class:`str` + The command's name. + description: :class:`str` + The command's description, if any. + version: :class:`int` + The command's version. + type: :class:`AppCommandType` + The type of application command. + default_permission: :class:`bool` + Whether the command is enabled in guilds by default. + """ + + __slots__ = ( + 'name', + 'description', + 'id', + 'version', + 'type', + 'default_permission', + '_data', + '_state', + '_channel', + '_application_id', + '_dm_permission', + '_default_member_permissions', + ) + + def __init__( + self, *, state: ConnectionState, data: Dict[str, Any], channel: Optional[Messageable] = None + ) -> None: + self._state = state + self._data = data + self.name = data['name'] + self.description = data['description'] + self._channel = channel + self._application_id: int = int(data['application_id']) + self.id: int = int(data['id']) + self.version = int(data['version']) + self.type = try_enum(AppCommandType, data['type']) + self.default_permission: bool = data['default_permission'] + self._dm_permission = data['dm_permission'] + self._default_member_permissions = data['default_member_permissions'] + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} id={self.id} name={self.name}>' + + def is_group(self) -> bool: + """Query whether this command is a group. + + Here for compatibility purposes. + + Returns + ------- + :class:`bool` + Whether this command is a group. + """ + return False + + @property + def application(self): + """The application this command belongs to.""" + ... + #return self._state.get_application(self._application_id) + + @property + def target_channel(self) -> Optional[Messageable]: + """Optional[:class:`Messageable`]: The channel this application command will be used on. + + You can set this in order to use this command in a different channel without re-fetching it. + """ + return self._channel + + @target_channel.setter + def target_channel(self, value: Optional[Messageable]) -> None: + from .abc import Messageable + if not isinstance(value, Messageable) and value is not None: + raise TypeError('channel must derive from Messageable') + self._channel = value + + +class SlashMixin(ApplicationCommand, Protocol): + if TYPE_CHECKING: + _parent: SlashCommand + options: List[Option] + children: List[SubCommand] + + async def __call__(self, options, channel=None): + obj = self._parent + command = obj._data + command['name_localized'] = command['name'] + data = { + 'application_command': command, + 'attachments': [], + 'id': str(obj.id), + 'name': obj.name, + 'options': options, + 'type': obj.type.value, + 'version': str(obj.version), + } + return await super().__call__(data, channel) + + def _parse_kwargs(self, kwargs: Dict[str, Any]) -> List[Dict[str, Any]]: + possible_options = {o.name: o for o in self.options} + kwargs = {k: v for k, v in kwargs.items() if k in possible_options} + options = [] + + for k, v in kwargs.items(): + option = possible_options[k] + type = option.type + + if type in { + AppCommandOptionType.user, + AppCommandOptionType.channel, + AppCommandOptionType.role, + AppCommandOptionType.mentionable, + }: + v = str(v.id) + elif type is AppCommandOptionType.boolean: + v = bool(v) + else: + v = option._convert(v) + + if type is AppCommandOptionType.string: + v = str(v) + elif type is AppCommandOptionType.integer: + v = int(v) + elif type is AppCommandOptionType.number: + v = float(v) + + options.append({'name': k, 'value': v, 'type': type.value}) + + return options + + def _unwrap_options(self, data: List[Dict[str, Any]]) -> None: + options = [] + children = [] + for option in data: + type = try_enum(AppCommandOptionType, option['type']) + if type in { + AppCommandOptionType.sub_command, + AppCommandOptionType.sub_command_group, + }: + children.append(SubCommand(parent=self, data=option)) + else: + options.append(Option(option)) + + for child in children: + setattr(self, child.name, child) + + self.options = options + self.children = children + + +class UserCommand(BaseCommand): + """Represents a user command.""" + + __slots__ = ('_user',) + + def __init__(self, *, user: Optional[Snowflake] = None, **kwargs): + super().__init__(**kwargs) + self._user = user + + async def __call__( + self, user: Optional[Snowflake] = None, *, channel: Optional[Messageable] = None + ): + """Use the user command. + + Parameters + ---------- + user: Optional[:class:`User`] + The user to use the command on. Overrides :attr:`target_user`. + Required if :attr:`target_user` is not set. + channel: Optional[:class:`abc.Messageable`] + The channel to use the command on. Overrides :attr:`target_channel`. + Required if :attr:`target_channel` is not set. + """ + user = user or self._user + if user is None: + raise TypeError('__call__() missing 1 required positional argument: \'user\'') + + command = self._data + command['name_localized'] = command['name'] + data = { + 'application_command': command, + 'attachments': [], + 'id': str(self.id), + 'name': self.name, + 'options': [], + 'target_id': str(user.id), + 'type': self.type.value, + 'version': str(self.version), + } + return await super().__call__(data, channel) + + @property + def target_user(self) -> Optional[Snowflake]: + """Optional[:class:`Snowflake`]: The user this application command will be used on. + + You can set this in order to use this command on a different user without re-fetching it. + """ + return self._user + + @target_user.setter + def target_user(self, value: Optional[Snowflake]) -> None: + from .abc import Snowflake + if not isinstance(value, Snowflake) and value is not None: + raise TypeError('user must be Snowflake') + self._user = value + + +class MessageCommand(BaseCommand): + """Represents a message command. + + Attributes + ---------- + id: :class:`int` + The command's ID. + name: :class:`str` + The command's name. + description: :class:`str` + The command's description, if any. + type: :class:`AppCommandType` + The type of application command. Always :class:`AppCommandType.message`. + default_permission: :class:`bool` + Whether the command is enabled in guilds by default. + """ + + __slots__ = ('_message',) + + def __init__(self, *, message: Optional[Message] = None, **kwargs): + super().__init__(**kwargs) + self._message = message + + async def __call__( + self, message: Optional[Message] = None, *, channel: Optional[Messageable] = None + ): + """Use the message command. + + Parameters + ---------- + message: Optional[:class:`Message`] + The message to use the command on. Overrides :attr:`target_message`. + Required if :attr:`target_message` is not set. + channel: Optional[:class:`abc.Messageable`] + The channel to use the command on. Overrides :attr:`target_channel`. + Required if :attr:`target_channel` is not set. + """ + message = message or self._message + if message is None: + raise TypeError('__call__() missing 1 required positional argument: \'message\'') + + command = self._data + command['name_localized'] = command['name'] + data = { + 'application_command': command, + 'attachments': [], + 'id': str(self.id), + 'name': self.name, + 'options': [], + 'target_id': str(message.id), + 'type': self.type.value, + 'version': str(self.version), + } + return await super().__call__(data, channel) + + @property + def target_message(self) -> Optional[Message]: + """Optional[:class:`Message`]: The message this application command will be used on. + + You can set this in order to use this command on a different message without re-fetching it. + """ + return self._message + + @target_message.setter + def target_message(self, value: Optional[Message]) -> None: + from .message import Message + if not isinstance(value, Message) and value is not None: + raise TypeError('message must be Message') + self._message = value + + +class SlashCommand(BaseCommand, SlashMixin): + """Represents a slash command. + + Attributes + ---------- + id: :class:`int` + The command's ID. + name: :class:`str` + The command's name. + description: :class:`str` + The command's description, if any. + type: :class:`AppCommandType` + The type of application command. Always :class:`AppCommandType.chat_input`. + default_permission: :class:`bool` + Whether the command is enabled in guilds by default. + options: List[:class:`Option`] + The command's options. + children: List[:class:`SubCommand`] + The command's subcommands. If a command has subcommands, it is a group and cannot be used. + You can access (and use) subcommands directly as attributes of the class. + """ + + __slots__ = ('_parent', 'options', 'children') + + def __init__( + self, *, data: Dict[str, Any], **kwargs + ) -> None: + super().__init__(data=data, **kwargs) + self._parent = self + self._unwrap_options(data.get('options', [])) + + async def __call__(self, channel: Optional[Messageable] = None, /, **kwargs): + r"""Use the slash command. + + Parameters + ---------- + channel: Optional[:class:`abc.Messageable`] + The channel to use the command on. Overrides :attr:`target_channel`. + Required if :attr:`target_message` is not set. + \*\*kwargs: Any + The options to use. These will be casted to the correct type. + If an option has choices, they are automatically converted from name to value for you. + + Raises + ------ + TypeError + Attempted to use a group. + """ + if self.is_group(): + raise TypeError('Cannot use a group') + + return await super().__call__(self._parse_kwargs(kwargs), channel) + + def __repr__(self) -> str: + BASE = f'' + + def is_group(self) -> bool: + """Query whether this command is a group. + + Returns + ------- + :class:`bool` + Whether this command is a group. + """ + return bool(self.children) + + +class SubCommand(SlashMixin): + """Represents a slash command child. + + This could be a subcommand, or a subgroup. + + Attributes + ---------- + parent: :class:`SlashCommand` + The parent command. + name: :class:`str` + The command's name. + description: :class:`str` + The command's description, if any. + type: :class:`AppCommandType` + The type of application command. Always :class:`AppCommandType.chat_input`. + """ + + __slots__ = ( + '_parent', + '_state', + '_type', + 'parent', + 'options', + 'children', + 'type', + ) + + def __init__(self, *, parent, data): + self.name = data['name'] + self.description = data.get('description') + self._state = parent._state + self.parent: Union[SlashCommand, SubCommand] = parent + self._parent: SlashCommand = getattr(parent, 'parent', parent) # type: ignore + self.type = AppCommandType.chat_input # Avoid confusion I guess + self._type: AppCommandOptionType = try_enum(AppCommandOptionType, data['type']) + self._unwrap_options(data.get('options', [])) + + def _walk_parents(self): + parent = self.parent + while True: + if isinstance(parent, SlashCommand): + break + else: + yield parent + parent = parent.parent + + async def __call__(self, channel: Optional[Messageable] = None, /, **kwargs): + r"""Use the sub command. + + Parameters + ---------- + channel: Optional[:class:`abc.Messageable`] + The channel to use the command on. Overrides :attr:`target_channel`. + Required if :attr:`target_message` is not set. + \*\*kwargs: Any + The options to use. These will be casted to the correct type. + If an option has choices, they are automatically converted from name to value for you. + + Raises + ------ + TypeError + Attempted to use a group. + """ + if self.is_group(): + raise TypeError('Cannot use a group') + + options = [{ + 'type': self._type.value, + 'name': self.name, + 'options': self._parse_kwargs(kwargs), + }] + for parent in self._walk_parents(): + options = [{ + 'type': parent._type.value, + 'name': parent.name, + 'options': options, + }] + + return await super().__call__(options, channel) + + def __repr__(self) -> str: + BASE = f'' + + @property + def _application_id(self) -> int: + return self._parent._application_id + + @property + def version(self) -> int: + """:class:`int`: The version of the command.""" + return self._parent.version + + @property + def default_permission(self) -> bool: + """:class:`bool`: Whether the command is enabled in guilds by default.""" + return self._parent.default_permission + + def is_group(self) -> bool: + """Query whether this command is a group. + + Returns + ------- + :class:`bool` + Whether this command is a group. + """ + return self._type is AppCommandOptionType.sub_command_group + + @property + def application(self): + """The application this command belongs to.""" + return self._parent.application + + @property + def target_channel(self) -> Optional[Messageable]: + """Optional[:class:`abc.Messageable`]: The channel this command will be used on. + + You can set this in order to use this command on a different channel without re-fetching it. + """ + return self._parent.target_channel + + @target_channel.setter + def target_channel(self, value: Optional[Messageable]) -> None: + self._parent.target_channel = value + + +class Option: # TODO: Add validation + """Represents a command option. + + Attributes + ---------- + name: :class:`str` + The option's name. + description: :class:`str` + The option's description, if any. + type: :class:`AppCommandOptionType` + The type of option. + required: :class:`bool` + Whether the option is required. + min_value: Optional[Union[:class:`int`, :class:`float`]] + Minimum value of the option. Only applicable to :attr:`AppCommandOptionType.integer` and :attr:`AppCommandOptionType.number`. + max_value: Optional[Union[:class:`int`, :class:`float`]] + Maximum value of the option. Only applicable to :attr:`AppCommandOptionType.integer` and :attr:`AppCommandOptionType.number`. + choices: List[:class:`OptionChoice`] + A list of possible choices to choose from. If these are present, you must choose one from them. + Only applicable to :attr:`AppCommandOptionType.string`, :attr:`AppCommandOptionType.integer`, and :attr:`AppCommandOptionType.number`. + channel_types: List[:class:`ChannelType`] + A list of channel types that you can choose from. If these are present, you must choose a channel that is one of these types. + Only applicable to :attr:`AppCommandOptionType.channel`. + autocomplete: :class:`bool` + Whether the option autocompletes. Always ``False`` if :attr:`choices` are present. + """ + + __slots__ = ( + 'name', + 'description', + 'type', + 'required', + 'min_value', + 'max_value', + 'choices', + 'channel_types', + 'autocomplete', + ) + + def __init__(self, data): + self.name: str = data['name'] + self.description: str = data['description'] + self.type: AppCommandOptionType = try_enum(AppCommandOptionType, data['type']) + self.required: bool = data.get('required', False) + self.min_value: Optional[Union[int, float]] = data.get('min_value') + self.max_value: Optional[int] = data.get('max_value') + self.choices = [OptionChoice(choice, self.type) for choice in data.get('choices', [])] + self.channel_types: List[ChannelType] = [try_enum(ChannelType, c) for c in data.get('channel_types', [])] + self.autocomplete: bool = data.get('autocomplete', False) + + def __repr__(self) -> str: + return f'